@bongos/core 1.21.52 → 1.21.53
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 +76 -66
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +135 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +23 -11
- package/docs/copy-registry.json +142 -13
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +202 -202
- package/modules/hall-ui/public/atlas.html +3 -3
- package/modules/hall-ui/public/blockers.html +3 -3
- package/modules/hall-ui/public/board-room.html +3 -3
- package/modules/hall-ui/public/city.html +3 -3
- package/modules/hall-ui/public/collab.html +3 -3
- package/modules/hall-ui/public/commands.html +3 -3
- package/modules/hall-ui/public/copy-desk.html +3 -3
- package/modules/hall-ui/public/craft-mode.js +7 -1
- package/modules/hall-ui/public/deploy.html +3 -3
- package/modules/hall-ui/public/diagrams.html +3 -3
- package/modules/hall-ui/public/drachmae.html +3 -3
- package/modules/hall-ui/public/fleet.html +3 -3
- package/modules/hall-ui/public/gate.html +3 -3
- package/modules/hall-ui/public/goal-map.html +3 -3
- package/modules/hall-ui/public/goals.html +3 -3
- package/modules/hall-ui/public/government.html +3 -3
- package/modules/hall-ui/public/idea.html +3 -3
- package/modules/hall-ui/public/ideas.html +3 -3
- package/modules/hall-ui/public/index.html +3 -3
- package/modules/hall-ui/public/lightbulb.js +213 -0
- package/modules/hall-ui/public/modules.html +3 -3
- package/modules/hall-ui/public/primer.html +3 -3
- package/modules/hall-ui/public/profile.html +3 -3
- package/modules/hall-ui/public/project-settings.html +3 -3
- package/modules/hall-ui/public/ranks.html +3 -3
- package/modules/hall-ui/public/roadmap.html +3 -3
- package/modules/hall-ui/public/roster.html +3 -3
- package/modules/hall-ui/public/sessions.html +3 -3
- package/modules/hall-ui/public/settings.html +3 -3
- package/modules/hall-ui/public/street.html +3 -3
- package/modules/hall-ui/public/studio.html +3 -3
- package/modules/hall-ui/public/style.css +1 -0
- package/modules/hall-ui/public/task.html +3 -3
- package/modules/hall-ui/public/thinking.html +3 -3
- package/modules/hall-ui/public/tweak-editor.html +3 -3
- package/modules/hall-ui/public/watch.html +3 -3
- package/modules/hall-ui/public/work.html +3 -3
- package/modules/hall-ui/records/craft-mode.md +8 -0
- package/modules/page-notes/db.js +34 -0
- package/modules/page-notes/notes.js +54 -8
- package/modules/page-notes/routes/page-notes.js +49 -1
- package/modules/page-notes/split.js +39 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/lightbulb.mjs +385 -0
- package/tests/page_notes_panel.mjs +3 -1
- package/tests/paintbrush.mjs +2 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// tests/lightbulb.mjs — the Ideating lightbulb (task 1004498): an ideator's
|
|
2
|
+
// text-only notes about the page they are on, split into attributed ideas.
|
|
3
|
+
//
|
|
4
|
+
// Owner decisions, 2026-10-04: an AI splits the notes (one idea if it cannot);
|
|
5
|
+
// the text itself is NOT kept — only the ideas, each linking back to the page;
|
|
6
|
+
// the panel lists the ideas already filed from the page; unsent text waits in
|
|
7
|
+
// this browser only.
|
|
8
|
+
//
|
|
9
|
+
// Three altitudes: the pure link/body rules (notes.js), the routes over a
|
|
10
|
+
// stubbed db with an injected model and capture, and the REAL lightbulb.js over
|
|
11
|
+
// a small DOM (the fake-DOM idiom of tests/paintbrush.mjs).
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import vm from 'node:vm';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const LIGHTBULB = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/lightbulb.js'), 'utf8');
|
|
24
|
+
const N = require('../modules/page-notes/notes.js');
|
|
25
|
+
// The real read, held before the route tests below stub the doc store.
|
|
26
|
+
const realIdeasFromPage = require('../modules/page-notes/db.js').ideasFromPage;
|
|
27
|
+
|
|
28
|
+
// ---------- the pure rules ----------
|
|
29
|
+
|
|
30
|
+
test('the page link each idea carries is exactly what the page list looks for — from the lightbulb AND the palette', () => {
|
|
31
|
+
const page = { page_id: 'builders:studio', page_path: '/studio', page_title: 'Studio' };
|
|
32
|
+
const mine = N.ideatorIdeaBody({ title: 'A', body: 'Group the drafts by page.' }, page, 'Ana');
|
|
33
|
+
assert.match(mine, /^Group the drafts by page\.\n\nFrom Ana's ideas on Studio\. Page: \[Studio\]\(\/studio\)$/);
|
|
34
|
+
const theirs = N.ideaBody({ title: 'B', body: 'Warmer.' }, { ...page, id: 7 }, 'Bo');
|
|
35
|
+
const [bare, doc] = N.pageLinks('/studio');
|
|
36
|
+
assert.ok(mine.includes(bare), 'the lightbulb idea is found by its page link');
|
|
37
|
+
assert.ok(theirs.includes(doc), 'and so is a palette idea, by its doc link on the page');
|
|
38
|
+
assert.ok(!N.ideatorIdeaBody({ body: 'x' }, { ...page, page_path: '/studio-old' }, 'Ana').includes(bare), 'a longer path is not this page');
|
|
39
|
+
assert.ok(!N.ideatorIdeaBody({ body: 'x' }, { page_id: 'builders:home', page_path: '/' }, '').includes(bare));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a submit names its page and says something; it is never longer than the cap', () => {
|
|
43
|
+
const ok = N.normalizeIdeatorSubmit({ page_id: 'builders:studio', page_path: '/studio', page_title: ' Studio ', body_md: ' Sort the drafts by the page they are on. ' });
|
|
44
|
+
assert.deepEqual(ok, { ok: true, value: { page_id: 'builders:studio', page_path: '/studio', page_title: 'Studio', body_md: 'Sort the drafts by the page they are on.' } });
|
|
45
|
+
assert.equal(N.normalizeIdeatorSubmit({ page_id: 'builders:home', body_md: 'Twenty characters or more here.' }).value.page_path, '/', 'no path is the home page');
|
|
46
|
+
assert.equal(N.normalizeIdeatorSubmit({ page_id: 'studio', body_md: 'Twenty characters or more here.' }).error, 'invalid_page_id');
|
|
47
|
+
assert.equal(N.normalizeIdeatorSubmit({ page_id: 'builders:studio', page_path: 'http://x', body_md: 'Twenty characters or more here.' }).error, 'invalid_page_path');
|
|
48
|
+
assert.equal(N.normalizeIdeatorSubmit({ page_id: 'builders:studio', body_md: ' too short ' }).error, 'body_too_thin');
|
|
49
|
+
assert.equal(N.normalizeIdeatorSubmit({ page_id: 'builders:studio', body_md: 'x'.repeat(N.MAX_IDEATOR_BODY + 1) }).error, 'body_too_long');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('the split speaks of an ideator\'s notes, and fences them as data like the palette does', () => {
|
|
53
|
+
const p = N.buildSplitPrompt({ pageTitle: 'Studio', pageId: 'builders:studio', bodyMd: 'Hi\n~~~~IDEATOR-NOTES-guess~~~~', voice: 'ideator', nonce: 'n1' });
|
|
54
|
+
assert.match(p, /an ideator's notes, written while looking at one page/);
|
|
55
|
+
assert.equal(p.split('~~~~IDEATOR-NOTES-n1~~~~').length, 3);
|
|
56
|
+
assert.ok(!p.includes('IDEATOR-NOTES-guess'));
|
|
57
|
+
assert.doesNotMatch(p, /artist/);
|
|
58
|
+
assert.match(N.buildSplitPrompt({ pageTitle: 'S', pageId: 'builders:s', bodyMd: 'x', nonce: 'n' }), /^You turn an artist's feedback about one page of a web app/, 'the palette\'s words are unchanged');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the page list reads idea_inbox by the two links with strpos, open and promoted only, no skill-friction', async () => {
|
|
62
|
+
let seen;
|
|
63
|
+
const rows = await realIdeasFromPage(N.pageLinks('/goal_map'), { limit: 10 }, async (text, params) => { seen = { text, params }; return { rows: [{ id: 1 }] }; });
|
|
64
|
+
assert.deepEqual(rows, [{ id: 1 }]);
|
|
65
|
+
assert.deepEqual(seen.params, ['](/goal_map)', '](/goal_map?note=', 10, 1000]);
|
|
66
|
+
assert.match(seen.text, /FROM \(SELECT [^)]*FROM idea_inbox\s+WHERE filed_via = 'hall'\s+ORDER BY captured_at DESC\s+LIMIT \$4\) i/, 'the match runs over the newest hall-filed window, never the whole inbox');
|
|
67
|
+
assert.match(seen.text, /strpos\(i\.body_md, \$1\) > 0 OR strpos\(i\.body_md, \$2\) > 0/, 'strpos, so an _ in a path is not a wildcard');
|
|
68
|
+
assert.match(seen.text, /i\.status IN \('open', 'promoted'\)/);
|
|
69
|
+
assert.match(seen.text, /i\.kind <> 'skill-friction' AND i\.title NOT LIKE '\[skill-friction\]%'/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the palette\'s splits never push the lightbulb into the whole-text fallback: each tool has its own cap', async () => {
|
|
73
|
+
const { fileNote, fileIdeatorNotes } = require('../modules/page-notes/split.js');
|
|
74
|
+
let release;
|
|
75
|
+
const held = new Promise((r) => { release = r; });
|
|
76
|
+
const slow = { runSubagent: async () => { await held; return { stdout: '{"ideas":[{"title":"A","body":"b"}]}' }; } };
|
|
77
|
+
const note = { id: 1, page_id: 'builders:studio', page_path: '/studio', page_title: 'Studio', body_md: 'Make the title bigger please.', author_id: 9 };
|
|
78
|
+
const capture = async () => ({ id: 1 });
|
|
79
|
+
const fakeDb = { markFiled: async () => true };
|
|
80
|
+
const artists = [fileNote(note, { grade: slow, capture, db: fakeDb }), fileNote(note, { grade: slow, capture, db: fakeDb })];
|
|
81
|
+
const quick = { runSubagent: async () => ({ stdout: '{"ideas":[{"title":"One","body":"b"}]}' }) };
|
|
82
|
+
const r = await fileIdeatorNotes({ ...note, body_md: 'Sort the drafts by page.' }, { grade: quick, capture, authorId: 9, authorName: 'Ana' });
|
|
83
|
+
assert.equal(r.splitBy, 'ai', 'two palette splits in flight leave the lightbulb its own');
|
|
84
|
+
release();
|
|
85
|
+
await Promise.all(artists);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------- the routes ----------
|
|
89
|
+
|
|
90
|
+
const db = require('../modules/page-notes/db.js');
|
|
91
|
+
const api = require('../src/module-api.js');
|
|
92
|
+
// Nothing of the doc store may be touched: the notes are never kept.
|
|
93
|
+
for (const k of Object.keys(db)) db[k] = async () => { throw new Error(`the lightbulb touched the doc store (${k})`); };
|
|
94
|
+
let listed = null;
|
|
95
|
+
db.ideasFromPage = async (links, opts) => { listed = { links, opts }; return [{ id: 31, title: 'Sort drafts', status: 'open', captured_at: '2026-10-04T10:00:00Z', author_name: 'Ana' }]; };
|
|
96
|
+
let viewer = { id: 9, rank: 'thetes', display_name: 'Ana', github_login: 'ana' };
|
|
97
|
+
api.requireBuilder = (req, res, next) => { if (!viewer) return res.status(401).json({ error: { code: 'unauthenticated' } }); req.builder = viewer; next(); };
|
|
98
|
+
let captured = [];
|
|
99
|
+
let captureFails = false;
|
|
100
|
+
let grade = null;
|
|
101
|
+
let captureOn = true;
|
|
102
|
+
api.resolveOptional = (name) => {
|
|
103
|
+
if (name === 'ideas.capture') return captureOn ? async (x) => { if (captureFails) throw new Error('down'); captured.push(x); return { id: 700 + captured.length }; } : null;
|
|
104
|
+
if (name === 'grade') return grade;
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
const modelSaying = (reply, wait = 0) => ({
|
|
108
|
+
runSubagent: async ({ prompt, opts }) => {
|
|
109
|
+
assert.equal(opts.tools, '', 'the split runs with zero tools');
|
|
110
|
+
assert.match(prompt, /ideator/);
|
|
111
|
+
if (wait) await new Promise((r) => setTimeout(r, wait));
|
|
112
|
+
return { stdout: reply };
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const express = require('express');
|
|
117
|
+
const app = express();
|
|
118
|
+
app.use((req, res, next) => {
|
|
119
|
+
res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code, message: opts && opts.message } });
|
|
120
|
+
next();
|
|
121
|
+
});
|
|
122
|
+
app.use(express.json({ limit: '64kb' }));
|
|
123
|
+
app.use('/api/bongos', require('../modules/page-notes/routes/page-notes.js')());
|
|
124
|
+
const server = app.listen(0);
|
|
125
|
+
await new Promise((r) => server.once('listening', r));
|
|
126
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
127
|
+
test.after(() => server.close());
|
|
128
|
+
|
|
129
|
+
function call(method, p, body) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const data = body === undefined ? null : JSON.stringify(body);
|
|
132
|
+
const req = http.request(`${base}${p}`, { method, headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
|
|
133
|
+
const chunks = [];
|
|
134
|
+
res.on('data', (c) => chunks.push(c));
|
|
135
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(Buffer.concat(chunks).toString()); } catch { /* none */ } resolve({ status: res.statusCode, body: j }); });
|
|
136
|
+
});
|
|
137
|
+
req.on('error', reject);
|
|
138
|
+
if (data) req.write(data);
|
|
139
|
+
req.end();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const NOTES = { page_id: 'builders:studio', page_path: '/studio', page_title: 'Studio', body_md: 'Sort the drafts by page.\n\nAlso show who wrote each one.' };
|
|
143
|
+
|
|
144
|
+
test('THE WHOLE PATH: an ideator\'s notes come back as ideas under their name, each linking to the page, and nothing is stored', async () => {
|
|
145
|
+
viewer = { id: 9, rank: 'thetes', display_name: 'Ana', github_login: 'ana' };
|
|
146
|
+
captured = []; captureFails = false; captureOn = true;
|
|
147
|
+
grade = modelSaying('{"ideas":[{"title":"Sort drafts by page","body":"Group them."},{"title":"Show each draft\'s writer","body":"Name them."}]}');
|
|
148
|
+
const r = await call('POST', '/page-notes/ideas', NOTES);
|
|
149
|
+
assert.equal(r.status, 201);
|
|
150
|
+
assert.deepEqual(r.body, { ideas: [{ id: '701', title: 'Sort drafts by page' }, { id: '702', title: 'Show each draft\'s writer' }], split_by: 'ai' });
|
|
151
|
+
assert.deepEqual(captured.map((c) => [c.capturedBy, c.filedVia]), [[9, 'hall'], [9, 'hall']], 'attributed to the ideator, filed through the hall');
|
|
152
|
+
for (const c of captured) assert.ok(c.bodyMd.endsWith("From Ana's ideas on Studio. Page: [Studio](/studio)"), 'each idea links back to the page');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('FAIL-OPEN: with no model the notes file whole, as one idea', async () => {
|
|
156
|
+
captured = []; grade = null;
|
|
157
|
+
const r = await call('POST', '/page-notes/ideas', NOTES);
|
|
158
|
+
assert.equal(r.status, 201);
|
|
159
|
+
assert.equal(r.body.split_by, 'whole');
|
|
160
|
+
assert.equal(captured.length, 1);
|
|
161
|
+
assert.match(captured[0].title, /^Studio: Sort the drafts by page\./);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('refusals: thin, unnamed, signed out, no inbox, nothing filed — each says so and files nothing', async () => {
|
|
165
|
+
captured = []; grade = null;
|
|
166
|
+
assert.equal((await call('POST', '/page-notes/ideas', { ...NOTES, body_md: 'short' })).body.error.code, 'body_too_thin');
|
|
167
|
+
assert.equal((await call('POST', '/page-notes/ideas', { ...NOTES, page_id: 'nope' })).status, 400);
|
|
168
|
+
assert.equal((await call('POST', '/page-notes/ideas', { page_id: 'builders:studio' })).status, 400, 'no text is refused');
|
|
169
|
+
captureOn = false;
|
|
170
|
+
assert.equal((await call('POST', '/page-notes/ideas', NOTES)).status, 503);
|
|
171
|
+
captureOn = true; captureFails = true;
|
|
172
|
+
const failed = await call('POST', '/page-notes/ideas', NOTES);
|
|
173
|
+
assert.equal(failed.status, 502);
|
|
174
|
+
assert.match(failed.body.error.message, /Your text is still here/);
|
|
175
|
+
captureFails = false;
|
|
176
|
+
viewer = null;
|
|
177
|
+
assert.equal((await call('POST', '/page-notes/ideas', NOTES)).status, 401);
|
|
178
|
+
assert.equal((await call('GET', '/page-notes/ideas?page_path=/studio')).status, 401);
|
|
179
|
+
viewer = { id: 9, rank: 'thetes', display_name: 'Ana' };
|
|
180
|
+
assert.equal(captured.length, 0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a double press files once: a second submit while the first is filing is refused', async () => {
|
|
184
|
+
captured = [];
|
|
185
|
+
grade = modelSaying('{"ideas":[{"title":"One","body":"b"}]}', 60);
|
|
186
|
+
const [a, b] = await Promise.all([call('POST', '/page-notes/ideas', NOTES), call('POST', '/page-notes/ideas', NOTES)]);
|
|
187
|
+
assert.deepEqual([a.status, b.status].sort(), [201, 409]);
|
|
188
|
+
assert.equal(captured.length, 1);
|
|
189
|
+
assert.equal((await call('POST', '/page-notes/ideas', NOTES)).status, 201, 'and the next one, after, goes through');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('the page list answers on its own path (not as a doc id) and checks the page path', async () => {
|
|
193
|
+
const r = await call('GET', '/page-notes/ideas?page_path=/studio');
|
|
194
|
+
assert.equal(r.status, 200);
|
|
195
|
+
assert.deepEqual(listed, { links: ['](/studio)', '](/studio?note='], opts: { limit: 10 } });
|
|
196
|
+
assert.deepEqual(r.body.ideas, [{ id: '31', title: 'Sort drafts', status: 'open', captured_at: '2026-10-04T10:00:00Z', author_name: 'Ana' }]);
|
|
197
|
+
assert.equal((await call('GET', '/page-notes/ideas')).status, 400);
|
|
198
|
+
assert.equal((await call('GET', '/page-notes/ideas?page_path=javascript:1')).status, 400);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ---------- the panel: the REAL lightbulb.js over a small DOM ----------
|
|
202
|
+
|
|
203
|
+
function unesc(s) { return s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&'); }
|
|
204
|
+
class El {
|
|
205
|
+
constructor(tag, doc) {
|
|
206
|
+
this.tagName = tag.toUpperCase(); this.doc = doc; this.attrs = {}; this.children = []; this.parentNode = null;
|
|
207
|
+
this.listeners = {}; this._html = ''; this.textContent = ''; this.value = ''; this.disabled = false; this.readOnly = false;
|
|
208
|
+
const cls = new Set();
|
|
209
|
+
this.classList = { add: (c) => cls.add(c), remove: (c) => cls.delete(c), contains: (c) => cls.has(c) };
|
|
210
|
+
}
|
|
211
|
+
get id() { return this.attrs.id || ''; }
|
|
212
|
+
set id(v) { this.attrs.id = String(v); }
|
|
213
|
+
set className(v) { this.attrs.class = String(v); }
|
|
214
|
+
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
215
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
216
|
+
hasAttribute(k) { return k in this.attrs; }
|
|
217
|
+
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
|
218
|
+
removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; }
|
|
219
|
+
// innerHTML is parsed flat: every opening tag becomes a child carrying its
|
|
220
|
+
// attributes, which is all the panel's selectors ask of it.
|
|
221
|
+
get innerHTML() { return this._html; }
|
|
222
|
+
set innerHTML(v) {
|
|
223
|
+
this._html = String(v);
|
|
224
|
+
this.children = [];
|
|
225
|
+
const re = /<([a-z0-9]+)((?:\s+[a-z-]+(?:="[^"]*")?)*)\s*>/gi;
|
|
226
|
+
let m;
|
|
227
|
+
while ((m = re.exec(this._html))) {
|
|
228
|
+
const el = new El(m[1], this.doc);
|
|
229
|
+
for (const a of m[2].matchAll(/([a-z-]+)(?:="([^"]*)")?/gi)) el.attrs[a[1]] = a[2] === undefined ? '' : unesc(a[2]);
|
|
230
|
+
if (el.tagName === 'TEXTAREA') el.value = unesc(this._html.slice(re.lastIndex, this._html.indexOf('</textarea>', re.lastIndex)));
|
|
231
|
+
this.appendChild(el);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
all() { return this.children.flatMap((c) => [c, ...c.all()]); }
|
|
235
|
+
is(sel) {
|
|
236
|
+
if (sel.startsWith('#')) return this.id === sel.slice(1);
|
|
237
|
+
const a = /^\[([\w-]+)\]$/.exec(sel);
|
|
238
|
+
if (a) return this.hasAttribute(a[1]);
|
|
239
|
+
return this.tagName === sel.toUpperCase();
|
|
240
|
+
}
|
|
241
|
+
querySelector(sel) { return this.all().find((n) => sel.split(',').some((s) => n.is(s.trim()))) || null; }
|
|
242
|
+
querySelectorAll(sel) { return this.all().filter((n) => sel.split(',').some((s) => n.is(s.trim()))); }
|
|
243
|
+
closest(sel) { for (let n = this; n; n = n.parentNode) if (n.is && n.is(sel)) return n; return null; }
|
|
244
|
+
addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
|
|
245
|
+
fire(t, e = {}) { (this.listeners[t] || []).forEach((fn) => fn({ target: this, ...e })); }
|
|
246
|
+
focus() { this.doc.activeElement = this; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function boot({ storage = {}, throwing = false, respond } = {}) {
|
|
250
|
+
const doc = { activeElement: null, listeners: {}, title: 'Studio — Cloud Bongos' };
|
|
251
|
+
doc.documentElement = new El('html', doc);
|
|
252
|
+
doc.body = new El('body', doc);
|
|
253
|
+
doc.body.dataset = { page: 'studio', pageTitle: 'Studio' };
|
|
254
|
+
doc.createElement = (t) => new El(t, doc);
|
|
255
|
+
doc.addEventListener = (t, fn) => { (doc.listeners[t] ||= []).push(fn); };
|
|
256
|
+
doc.removeEventListener = (t, fn) => { doc.listeners[t] = (doc.listeners[t] || []).filter((f) => f !== fn); };
|
|
257
|
+
const calls = [];
|
|
258
|
+
const timers = [];
|
|
259
|
+
const store = {
|
|
260
|
+
getItem: (k) => { if (throwing) throw new Error('blocked'); return k in storage ? storage[k] : null; },
|
|
261
|
+
setItem: (k, v) => { if (throwing) throw new Error('blocked'); storage[k] = String(v); },
|
|
262
|
+
removeItem: (k) => { if (throwing) throw new Error('blocked'); delete storage[k]; },
|
|
263
|
+
};
|
|
264
|
+
const answer = respond || ((method) => (method === 'GET'
|
|
265
|
+
? { ok: true, status: 200, json: { ideas: [{ id: '5', title: '<b>Bold</b> idea', author_name: 'Bo', captured_at: '2026-10-01T00:00:00Z' }] } }
|
|
266
|
+
: { ok: true, status: 201, json: { ideas: [{ id: '801', title: 'Sort drafts' }, { id: '802', title: 'Name writers' }], split_by: 'ai' } }));
|
|
267
|
+
const win = {
|
|
268
|
+
location: { pathname: '/studio' },
|
|
269
|
+
localStorage: store,
|
|
270
|
+
fetch: (url, opts) => {
|
|
271
|
+
calls.push({ url, method: opts.method, body: opts.body ? JSON.parse(opts.body) : undefined });
|
|
272
|
+
const r = answer(opts.method, url);
|
|
273
|
+
return Promise.resolve({ ok: r.ok, status: r.status, json: () => Promise.resolve(r.json) });
|
|
274
|
+
},
|
|
275
|
+
setTimeout: (fn) => { timers.push(fn); return timers.length; },
|
|
276
|
+
clearTimeout: (id) => { timers[id - 1] = null; },
|
|
277
|
+
};
|
|
278
|
+
win.window = win;
|
|
279
|
+
win.document = doc;
|
|
280
|
+
vm.runInNewContext(LIGHTBULB, win);
|
|
281
|
+
const sheet = () => doc.body.querySelector('#lightbulb');
|
|
282
|
+
const q = (sel) => sheet() && sheet().querySelector(sel);
|
|
283
|
+
return {
|
|
284
|
+
win, doc, calls, storage, sheet, q,
|
|
285
|
+
lb: win.OTBLightbulb,
|
|
286
|
+
flush: () => new Promise((r) => setTimeout(r, 0)),
|
|
287
|
+
runTimers: () => { timers.splice(0).forEach((fn) => fn && fn()); },
|
|
288
|
+
type: (text) => { q('#lb-text').value = text; q('#lb-text').fire('input'); },
|
|
289
|
+
press: (sel) => sheet().fire('click', { target: q(sel) }),
|
|
290
|
+
key: (key) => (doc.listeners.keydown || []).forEach((fn) => fn({ key, preventDefault() {} })),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
test('opening shows a text box and this page\'s ideas — escaped, each linking to its idea; nothing else loads or posts', async () => {
|
|
295
|
+
const p = boot();
|
|
296
|
+
p.lb.open();
|
|
297
|
+
await p.flush();
|
|
298
|
+
assert.ok(p.sheet(), 'the sheet opens');
|
|
299
|
+
assert.ok(p.q('#lb-text'), 'with a text box');
|
|
300
|
+
assert.equal(p.q('input'), null, 'and no upload: the lightbulb is text only');
|
|
301
|
+
assert.deepEqual(p.calls, [{ url: '/api/bongos/page-notes/ideas?page_path=%2Fstudio', method: 'GET', body: undefined }]);
|
|
302
|
+
const list = p.q('#lb-list').innerHTML;
|
|
303
|
+
assert.match(list, /href="\/#\/idea\/5"/);
|
|
304
|
+
assert.match(list, /<b>Bold<\/b> idea/, 'a title is text, never markup');
|
|
305
|
+
assert.match(p.sheet().innerHTML, /Ideas · Studio/);
|
|
306
|
+
assert.ok(p.doc.documentElement.classList.contains('pn-open'));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('unsent text waits in THIS browser, per page, and comes back on reopening', async () => {
|
|
310
|
+
const p = boot();
|
|
311
|
+
p.lb.open();
|
|
312
|
+
p.type('Sort the drafts by page, please.');
|
|
313
|
+
p.runTimers();
|
|
314
|
+
assert.equal(p.storage['hall-lightbulb:/studio'], 'Sort the drafts by page, please.');
|
|
315
|
+
assert.equal(p.calls.filter((c) => c.method !== 'GET').length, 0, 'nothing reaches the server before a submit');
|
|
316
|
+
p.key('Escape');
|
|
317
|
+
assert.equal(p.sheet(), null, 'Escape closes it');
|
|
318
|
+
p.lb.open();
|
|
319
|
+
assert.equal(p.q('#lb-text').value, 'Sort the drafts by page, please.');
|
|
320
|
+
p.type(' ');
|
|
321
|
+
p.runTimers();
|
|
322
|
+
assert.equal('hall-lightbulb:/studio' in p.storage, false, 'emptying the box forgets it');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('SUBMIT files the notes for this page once, then clears the kept text and shows the ideas', async () => {
|
|
326
|
+
const p = boot({ storage: { 'hall-lightbulb:/studio': 'Sort the drafts by page.\nName who wrote each.' } });
|
|
327
|
+
p.lb.open();
|
|
328
|
+
await p.flush();
|
|
329
|
+
p.press('[data-lb-submit]');
|
|
330
|
+
p.press('[data-lb-submit]');
|
|
331
|
+
assert.equal(p.q('[data-lb-submit]').disabled, true, 'the button holds while it files');
|
|
332
|
+
await p.flush(); await p.flush();
|
|
333
|
+
const posts = p.calls.filter((c) => c.method === 'POST');
|
|
334
|
+
assert.deepEqual(posts, [{ url: '/api/bongos/page-notes/ideas', method: 'POST', body: { page_id: 'builders:studio', page_path: '/studio', page_title: 'Studio', body_md: 'Sort the drafts by page.\nName who wrote each.' } }], 'one post, for this page');
|
|
335
|
+
assert.match(p.q('#lb-body').innerHTML, /Filed as 2 ideas under your name/);
|
|
336
|
+
assert.match(p.q('#lb-body').innerHTML, /href="\/#\/idea\/801"/);
|
|
337
|
+
assert.equal('hall-lightbulb:/studio' in p.storage, false, 'the text is not kept once it is ideas');
|
|
338
|
+
p.press('[data-lb-new]');
|
|
339
|
+
assert.equal(p.q('#lb-text').value, '', 'writing more starts empty');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('a refused or failed submit says why and keeps every word', async () => {
|
|
343
|
+
const p = boot({
|
|
344
|
+
storage: { 'hall-lightbulb:/studio': 'short' },
|
|
345
|
+
respond: (m) => (m === 'GET' ? { ok: true, status: 200, json: { ideas: [] } } : { ok: false, status: 502, json: { error: { message: 'Your ideas could not be filed. Your text is still here; try again in a moment.' } } }),
|
|
346
|
+
});
|
|
347
|
+
p.lb.open();
|
|
348
|
+
await p.flush();
|
|
349
|
+
assert.match(p.q('#lb-list').innerHTML, /None yet/);
|
|
350
|
+
p.press('[data-lb-submit]');
|
|
351
|
+
assert.match(p.q('#lb-error').textContent, /Write a little more/);
|
|
352
|
+
assert.equal(p.calls.filter((c) => c.method === 'POST').length, 0, 'too little text never posts');
|
|
353
|
+
p.type('Long enough to be an idea now.');
|
|
354
|
+
p.press('[data-lb-submit]');
|
|
355
|
+
await p.flush(); await p.flush();
|
|
356
|
+
assert.match(p.q('#lb-error').textContent, /could not be filed/);
|
|
357
|
+
assert.equal(p.q('[data-lb-submit]').disabled, false, 'the button comes back');
|
|
358
|
+
assert.equal(p.q('#lb-text').readOnly, false);
|
|
359
|
+
assert.equal(p.q('#lb-text').value, 'Long enough to be an idea now.');
|
|
360
|
+
assert.equal(p.storage['hall-lightbulb:/studio'], 'Long enough to be an idea now.', 'and the browser still holds it');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('storage that throws keeps nothing, and the panel still files', async () => {
|
|
364
|
+
const p = boot({ throwing: true });
|
|
365
|
+
p.lb.open();
|
|
366
|
+
p.type('Twenty characters or more of ideas.');
|
|
367
|
+
p.runTimers();
|
|
368
|
+
p.press('[data-lb-submit]');
|
|
369
|
+
await p.flush(); await p.flush();
|
|
370
|
+
assert.match(p.q('#lb-body').innerHTML, /Filed as 2 ideas/);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('craft-mode.js gives the tool its OWN icon, so it is never read as the Ideating pill beside it (owner, 2026-10-04)', () => {
|
|
374
|
+
const craft = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/craft-mode.js'), 'utf8');
|
|
375
|
+
assert.match(craft, /\{ id: 'lightbulb', icon: 'note', short: 'Ideas', label: 'Write ideas about this page', script: 'lightbulb\.js', api: 'OTBLightbulb' \}/);
|
|
376
|
+
assert.match(craft, /^ {4}note: '<path /m, 'the note glyph is drawn');
|
|
377
|
+
assert.doesNotMatch(/ideating: \[([\s\S]*?)\]/.exec(/var TOOLS = \{([\s\S]*?)\n {2}\};/.exec(craft)[1])[1], /icon: 'ideating'/);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('the only route it calls is the lightbulb\'s own, and it rides the palette\'s styles', () => {
|
|
381
|
+
const urls = LIGHTBULB.match(/\/api\/[a-z/-]+/g);
|
|
382
|
+
assert.deepEqual([...new Set(urls)], ['/api/bongos/page-notes/ideas']);
|
|
383
|
+
assert.doesNotMatch(LIGHTBULB, /type="file"|FileReader/, 'no uploads');
|
|
384
|
+
assert.doesNotMatch(LIGHTBULB, /#[0-9a-f]{3,6}\b|rgb\(/i, 'no colour literals: the look is style.css\'s');
|
|
385
|
+
});
|
|
@@ -80,10 +80,12 @@ test('THE PALETTE: in the top bar in Artistry alone, right before the switch, an
|
|
|
80
80
|
assert.equal(p.tools()[0].nextSibling, p.tools()[1], 'the palette comes first');
|
|
81
81
|
assert.equal(p.tools()[1].nextSibling, p.sw, 'and the pair sits right before the mode switch');
|
|
82
82
|
assert.equal(p.tools()[0].getAttribute('aria-label'), 'Write feedback on this page');
|
|
83
|
-
for (const m of ['building', '
|
|
83
|
+
for (const m of ['building', 'governing']) {
|
|
84
84
|
p.craft.choose(m);
|
|
85
85
|
assert.equal(p.tools().length, 0, `no palette in ${m}`);
|
|
86
86
|
}
|
|
87
|
+
p.craft.choose('ideating');
|
|
88
|
+
assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-lightbulb'], 'Ideating swaps them for its lightbulb alone (task 1004498)');
|
|
87
89
|
p.craft.choose('artistry');
|
|
88
90
|
p.craft.choose('artistry');
|
|
89
91
|
assert.equal(p.tools().length, 2, 'choosing Artistry twice draws one palette and one paintbrush');
|
package/tests/paintbrush.mjs
CHANGED
|
@@ -184,7 +184,8 @@ test('craft-mode.js puts the paintbrush beside the palette in Artistry, loading
|
|
|
184
184
|
assert.match(CRAFT, /\{ id: 'paintbrush', icon: 'brush', short: 'Edit words', label: 'Edit this page\\'s words', script: 'paintbrush.js', api: 'OTBPaintbrush' \}/);
|
|
185
185
|
const tools = /var TOOLS = \{([\s\S]*?)\n \};/.exec(CRAFT)[1];
|
|
186
186
|
assert.match(tools, /^\s*artistry: \[/m, 'it is an Artistry tool');
|
|
187
|
-
assert.doesNotMatch(tools, /building:|
|
|
187
|
+
assert.doesNotMatch(tools, /building:|governing:/, 'Building and Governing have none');
|
|
188
|
+
assert.doesNotMatch(/ideating: \[([\s\S]*?)\]/.exec(tools)[1], /paintbrush/, 'and the paintbrush is not Ideating\'s (task 1004498)');
|
|
188
189
|
});
|
|
189
190
|
|
|
190
191
|
test('pressing it on a page nobody holds OFFERS the page — nothing is editable and nothing is written until the artist takes it', async () => {
|