@bongos/core 1.19.1012 → 1.19.1014

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 (76) hide show
  1. package/.bongos-core.json +113 -78
  2. package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
  3. package/docs/copy-inventory.md +120 -66
  4. package/docs/copy-registry.json +984 -498
  5. package/docs/file-map.md +1 -1
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/collab.html +1 -1
  11. package/modules/hall-ui/public/copy-desk.html +1 -1
  12. package/modules/hall-ui/public/deploy.html +1 -1
  13. package/modules/hall-ui/public/diagrams.html +1 -1
  14. package/modules/hall-ui/public/drachmae.html +1 -1
  15. package/modules/hall-ui/public/fleet-costs.js +80 -0
  16. package/modules/hall-ui/public/fleet.html +64 -0
  17. package/modules/hall-ui/public/fleet.js +45 -0
  18. package/modules/hall-ui/public/fleet.states.json +14 -0
  19. package/modules/hall-ui/public/gate.html +1 -1
  20. package/modules/hall-ui/public/goal-map.html +1 -1
  21. package/modules/hall-ui/public/goals.html +1 -1
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/idea-objects.js +4 -1
  24. package/modules/hall-ui/public/idea.html +1 -1
  25. package/modules/hall-ui/public/ideas.html +1 -1
  26. package/modules/hall-ui/public/index.html +1 -1
  27. package/modules/hall-ui/public/modules.html +1 -1
  28. package/modules/hall-ui/public/people.html +1 -1
  29. package/modules/hall-ui/public/primer.html +1 -1
  30. package/modules/hall-ui/public/profile.html +1 -1
  31. package/modules/hall-ui/public/project-settings.html +1 -1
  32. package/modules/hall-ui/public/ranks.html +1 -1
  33. package/modules/hall-ui/public/roadmap.html +1 -1
  34. package/modules/hall-ui/public/roster.html +1 -1
  35. package/modules/hall-ui/public/sessions.html +1 -1
  36. package/modules/hall-ui/public/settings.html +1 -1
  37. package/modules/hall-ui/public/shell.js +4 -0
  38. package/modules/hall-ui/public/sky-composer.js +155 -0
  39. package/modules/hall-ui/public/sky-data.js +287 -0
  40. package/modules/hall-ui/public/sky-draw.js +244 -0
  41. package/modules/hall-ui/public/sky-interaction.js +137 -0
  42. package/modules/hall-ui/public/sky-layout.js +81 -0
  43. package/modules/hall-ui/public/sky-panel.js +141 -0
  44. package/modules/hall-ui/public/sky-tour.js +80 -0
  45. package/modules/hall-ui/public/studio.html +1 -1
  46. package/modules/hall-ui/public/task.html +1 -1
  47. package/modules/hall-ui/public/thinking.css +203 -267
  48. package/modules/hall-ui/public/thinking.html +119 -58
  49. package/modules/hall-ui/public/thinking.states.json +33 -9
  50. package/modules/hall-ui/public/watch.html +1 -1
  51. package/modules/hall-ui/public/work.html +1 -1
  52. package/modules/provisioning/routes/provisioning.js +4 -4
  53. package/modules/public-landing/public/projects.html +7 -0
  54. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -0
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/src/bongos/auth.js +8 -0
  58. package/src/bongos/routes/me.js +4 -1
  59. package/src/bongos/serve-internal.js +25 -6
  60. package/src/module-api.js +1 -1
  61. package/tests/auth_resolve_failure.mjs +3 -2
  62. package/tests/fleet_cost_view.mjs +189 -0
  63. package/tests/hall_audit.mjs +27 -0
  64. package/tests/hall_nav.mjs +3 -1
  65. package/tests/hall_page_gate_map.mjs +13 -5
  66. package/tests/hall_sky.mjs +393 -405
  67. package/tests/hall_thinking_room.mjs +274 -296
  68. package/tests/helpers.mjs +185 -217
  69. package/tests/ideator_large_dead_thought_proof.mjs +33 -25
  70. package/tests/ideator_traceable_proof.mjs +61 -39
  71. package/tests/nav_permission_atoms.mjs +1 -1
  72. package/modules/hall-ui/public/sky.css +0 -365
  73. package/modules/hall-ui/public/sky.html +0 -79
  74. package/modules/hall-ui/public/sky.js +0 -580
  75. package/modules/hall-ui/public/sky.states.json +0 -20
  76. package/modules/hall-ui/public/thinking.js +0 -418
package/tests/helpers.mjs CHANGED
@@ -736,249 +736,217 @@ export function bootIdeaPage({ idea = null, status = 200, template = null, viewe
736
736
  // One microtask turn — profile.js boots through a chain of awaits.
737
737
  export const settle = () => new Promise((r) => setTimeout(r, 0));
738
738
 
739
- // bootThinkingPage — boot the REAL modules/hall-ui/public/thinking.js in a vm
740
- // against a stub DOM, and hand back what it wrote into each of the page's four
741
- // regions plus every request it made.
739
+ // bootSkyPage — boot THE SKY for real: the REAL modules/hall-ui/public/thinking.html
740
+ // markup, the REAL idea-objects.js and the REAL seven sky-*.js files, in a vm
741
+ // against a stub DOM, a stub canvas and a stub client (task 1004231 / sky part 3
742
+ // of 5, goal 1000095; ADR 0331).
742
743
  //
743
- // The third sibling of bootProfilePage / bootIdeaPage, and here for the reason
744
- // the second one is: THE ROOM (task 1004098 / BV2.R03) is asserted at the
745
- // RENDERED layer. Everything this page shows already has a server-side test —
746
- // the five states, size, the descent, brightness — and every one of them can
747
- // still reach a reader wrong, because what a reader is handed is HTML. Testing
748
- // the read alone is exactly how a renderer bug survives (the first of the three
749
- // findings inherited from abandoned task 1004054).
744
+ // The successor of bootThinkingPage (the room, task 1004098) and the first
745
+ // bootSkyPage (the ambient sky, task 1004105), which this page replaced. It
746
+ // keeps their two rules — the real vocabulary is never stubbed, and every row
747
+ // goes in shaped the way the route shapes one — and adds a third: THE MARKUP
748
+ // IS THE PAGE'S OWN. The seven scripts wire ~60 elements by id at load and
749
+ // read `.brand h1`'s first text node, so a stub that invented elements on
750
+ // demand would pass a page whose html had lost a button. The body of
751
+ // thinking.html is parsed into the mini-DOM below and that is what the
752
+ // scripts find.
750
753
  //
751
- // THE REAL idea-objects.js IS LOADED INTO THE SAME SANDBOX, never stubbed. It
752
- // is half of the decision this page makes — the server says WHICH object, that
753
- // file says what the object is CALLED — so a suite that stubbed it would assert
754
- // the page renders a label the suite itself invented.
755
- const THINKING_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/thinking.js'), 'utf8');
756
- const IDEA_OBJECTS_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/idea-objects.js'), 'utf8');
757
- export function bootThinkingPage({ pages = [], viewer = { id: 95 }, meStatus = 200, trailStatus = 200 } = {}) {
758
- const els = new Map();
759
- const makeEl = () => ({
760
- dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
761
- classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
762
- setAttribute() {}, getAttribute() { return null; }, addEventListener() {},
763
- querySelector() { return null; }, querySelectorAll() { return []; }, appendChild() {},
764
- set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
765
- });
766
- const documentObj = {
767
- title: '',
768
- body: { dataset: { page: 'thinking' } },
769
- documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
770
- getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
771
- querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
772
- querySelectorAll() { return []; },
773
- createElement() { return makeEl(); },
774
- addEventListener() {},
775
- };
776
-
777
- // The kit's tab strip, reduced to what the page actually uses: the ids it was
778
- // built with, a per-id count, a per-id hidden flag and a way to activate one.
779
- // `querySelector` here answers a REAL object rather than null, because the
780
- // page's "hide a filter nobody has any of" rule is a behaviour the suite must
781
- // be able to read — a stub answering null for every selector would make that
782
- // rule untestable while looking exactly like a passing one.
783
- const tabState = { ids: [], counts: {}, hidden: {}, active: null, onChange: null };
784
- const tabsEl = makeEl();
785
- tabsEl.querySelector = (sel) => {
786
- const m = /\[data-tab="(.+)"\]/.exec(String(sel));
787
- if (!m) return null;
788
- const id = m[1];
789
- if (!tabState.ids.includes(id)) return null;
790
- return { set hidden(v) { tabState.hidden[id] = !!v; }, get hidden() { return !!tabState.hidden[id]; } };
791
- };
792
-
793
- const requests = [];
794
- const client = {
795
- request: async (method, url) => {
796
- requests.push(url);
797
- if (/\/me$/.test(url)) {
798
- return meStatus === 200
799
- ? { status: 200, ok: true, data: { builder: viewer } }
800
- : { status: meStatus, ok: false, data: null };
801
- }
802
- if (trailStatus !== 200) return { status: trailStatus, ok: false, data: null };
803
- const offset = Number((/offset=(\d+)/.exec(url) || [])[1] || 0);
804
- // The caller supplies whole PAGES keyed by offset, so the page's
805
- // background paging loop is exercised rather than bypassed — the figures
806
- // over the list are a claim about a whole trail, and a stub that always
807
- // answered the first window would never let that claim be wrong.
808
- const page = pages.find((p) => Number(p.offset || 0) === offset) || { ideas: [], total: 0 };
809
- return { status: 200, ok: true, data: page };
810
- },
811
- };
812
-
813
- const windowObj = {
814
- __BRANDING__: {}, __MODULES__: { enabled: {} },
815
- BongosClient: { createClient: () => client },
816
- CSS: { escape: (s) => String(s) },
817
- OTB: {
818
- escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
819
- $: (sel) => documentObj.querySelector(sel),
820
- fmtDate: (iso) => String(iso ?? '').slice(0, 10),
821
- toast() {},
822
- },
823
- OTBKit: {
824
- makeTabs({ tabs = [], onChange = null }) {
825
- tabState.ids = tabs.map((t) => t.id);
826
- tabState.labels = tabs.map((t) => t.label);
827
- tabState.onChange = onChange;
828
- tabState.active = tabs.length ? tabs[0].id : null;
829
- return {
830
- el: tabsEl,
831
- active: () => tabState.active,
832
- activate(id) { tabState.active = id; if (onChange) onChange(id); },
833
- setCount(id, n) { tabState.counts[id] = n; },
834
- };
835
- },
836
- },
837
- location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/thinking', hash: '', assign() {} },
838
- localStorage: { getItem: () => null, setItem() {} },
839
- addEventListener() {},
840
- };
841
-
842
- const sandbox = {
843
- window: windowObj, document: documentObj, location: windowObj.location,
844
- console, setTimeout, clearTimeout, URLSearchParams,
845
- };
846
- sandbox.globalThis = sandbox;
847
- vm.createContext(sandbox);
848
- vm.runInContext(IDEA_OBJECTS_JS, sandbox, { filename: 'idea-objects.js' });
849
- vm.runInContext(THINKING_JS, sandbox, { filename: 'thinking.js' });
754
+ // What a test can do with what comes back: read any element by id (`rows()`
755
+ // is the drawer, `panel()` the panel, `stats()` the figures line), press a key
756
+ // the way the window listener hears it (`key('l')`), type into the composer
757
+ // and submit (`type()`, `submit()`), run the pending debounce timers
758
+ // (`timers.flush()`), draw one frame (`frame()`), and read the live state the
759
+ // page keeps on `window.OTBSky.S`. Every request the page made is on
760
+ // `requests` with its body, so a filing can be checked byte for byte against
761
+ // what the hall form sends.
762
+ const SKY_FILES = ['idea-objects.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js'];
763
+ const SKY_SRC = SKY_FILES.map((f) => [f, fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8')]);
764
+ const SKY_HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/thinking.html'), 'utf8');
765
+ const SKY_BODY = (/<body[^>]*>([\s\S]*)<\/body>/.exec(SKY_HTML) || [, ''])[1].replace(/<script[\s\S]*?<\/script>/g, '');
850
766
 
851
- return {
852
- requests,
853
- objects: () => windowObj.OTBIdeaObjects,
854
- rows: () => documentObj.getElementById('tr-body').innerHTML,
855
- figs: () => documentObj.getElementById('tr-figs').innerHTML,
856
- foot: () => documentObj.getElementById('tr-foot'),
857
- sub: () => documentObj.getElementById('tr-sub').textContent,
858
- tabs: () => tabState,
859
- filter: (id) => { tabState.active = id; if (tabState.onChange) tabState.onChange(id); },
860
- };
861
- }
862
-
863
- // bootSkyPage — boot the REAL modules/hall-ui/public/sky.js in a vm against a
864
- // stub DOM, and hand back the field, the panel and the legend it wrote plus
865
- // every request it made.
866
- //
867
- // The fourth sibling of bootProfilePage / bootIdeaPage / bootThinkingPage, and
868
- // here for the reason the third one is: THE SKY (task 1004105 / BV2.R26) is a
869
- // DRAWING of facts that are all correct upstream, and the only place it can go
870
- // wrong is the last step. A glyph is geometry, so the questions this can ask
871
- // that no server test can are: does a remnant keep its diameter and lose its
872
- // fill, does an unreadable brightness leave a star lit, does the page draw a
873
- // second compression curve of its own, and does every thought carry its whole
874
- // sentence for a reader who cannot see the picture.
875
- //
876
- // THE REAL idea-objects.js IS LOADED INTO THE SAME SANDBOX, never stubbed —
877
- // the same rule bootThinkingPage keeps, and for the sharper reason here: the
878
- // pair exists so the sky and the room cannot call one object two names, and a
879
- // suite that stubbed the vocabulary would assert the sky says whatever the
880
- // suite told it to.
881
- const SKY_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/sky.js'), 'utf8');
882
767
  export function bootSkyPage({
883
- pages = [], viewer = { id: 95 }, meStatus = 200, trailStatus = 200,
884
- goals = null, criteria = {},
768
+ mine = null, sky = null, versions = null, me = null, template = undefined,
769
+ viewer = { id: 95, permissions: {} }, status = {}, tourSeen = true, storage = {},
770
+ innerWidth = 1280, innerHeight = 800, reduce = true,
885
771
  } = {}) {
886
- const els = new Map();
887
- const listeners = new Map();
888
- const makeEl = (id) => ({
889
- dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
890
- classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
891
- setAttribute() {}, getAttribute() { return null; },
892
- addEventListener(type, fn) { listeners.set(`${id}:${type}`, fn); },
893
- querySelector() { return null; }, querySelectorAll() { return []; }, appendChild() {},
894
- set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
772
+ // ── the DOM ──────────────────────────────────────────────────────────────
773
+ const windowListeners = new Map();
774
+ const docListeners = new Map();
775
+ const timers = [];
776
+ let timerId = 0;
777
+ let focused = null;
778
+ const isPoll = (t) => t.fn && t.fn.name === 'pollFrame';
779
+ const classListOf = (el) => ({
780
+ add: (...cs) => { const s = new Set(String(el.className || '').split(/\s+/).filter(Boolean)); cs.forEach((c) => s.add(c)); el.className = [...s].join(' '); },
781
+ remove: (...cs) => { const s = new Set(String(el.className || '').split(/\s+/).filter(Boolean)); cs.forEach((c) => s.delete(c)); el.className = [...s].join(' '); },
782
+ toggle: (c, force) => { const has = String(el.className || '').split(/\s+/).includes(c); const on = force === undefined ? !has : !!force; (on ? el.classList.add : el.classList.remove)(c); return on; },
783
+ contains: (c) => String(el.className || '').split(/\s+/).includes(c),
784
+ });
785
+ function node(tag, attrs = {}) {
786
+ const el = makeEl(tag, attrs);
787
+ el.classList = classListOf(el);
788
+ el.hidden = 'hidden' in attrs;
789
+ el.placeholder = attrs.placeholder || '';
790
+ el.offsetWidth = 200; el.offsetHeight = 60;
791
+ el.focus = () => { focused = el; };
792
+ el.blur = () => { if (focused === el) focused = null; };
793
+ el.dispatchEvent = (ev) => { el.fire(ev.type, ev); return true; };
794
+ el.removeEventListener = () => {};
795
+ Object.defineProperty(el, 'firstChild', { get() { return { get textContent() { return el._text; }, set textContent(v) { el._text = String(v); } }; } });
796
+ if (tag === 'canvas') {
797
+ el.width = 0; el.height = 0;
798
+ el.getContext = () => new Proxy({}, {
799
+ get: (_, prop) => {
800
+ if (prop === 'measureText') return (t) => ({ width: String(t).length * 6 });
801
+ if (prop === 'createRadialGradient' || prop === 'createLinearGradient') return () => ({ addColorStop() {} });
802
+ return () => {};
803
+ },
804
+ set: () => true,
805
+ });
806
+ el.setPointerCapture = () => {};
807
+ }
808
+ return el;
809
+ }
810
+ // A parser over the page's own body, producing the elements above.
811
+ function parse(host, html) {
812
+ host.children = []; host._text = '';
813
+ const stack = [host];
814
+ const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g;
815
+ let last = 0; let m;
816
+ const addText = (raw) => { if (raw) stack[stack.length - 1]._text += decodeEntities(raw); };
817
+ while ((m = re.exec(html))) {
818
+ addText(html.slice(last, m.index)); last = re.lastIndex;
819
+ const tag = m[2].toLowerCase();
820
+ if (m[1]) { for (let i = stack.length - 1; i > 0; i--) if (stack[i].tagName === tag) { stack.length = i; break; } continue; }
821
+ const el = node(tag, parseAttrs(m[3]));
822
+ stack[stack.length - 1].appendChild(el);
823
+ if (!m[4] && !VOID_TAGS.has(tag)) stack.push(el);
824
+ }
825
+ addText(html.slice(last));
826
+ }
827
+ // innerHTML on any element parses into children, so querySelectorAll('.row')
828
+ // finds what the page just wrote and the page's own listeners get wired.
829
+ const root = node('body', {});
830
+ const patchInnerHtml = (el) => Object.defineProperty(el, 'innerHTML', {
831
+ get() { return this._html === null ? '' : this._html; },
832
+ set(v) { this._html = String(v); this._value = null; parse(this, this._html); walk(this, (c) => { patchInnerHtml(c); return null; }); },
833
+ configurable: true,
895
834
  });
835
+ patchInnerHtml(root);
836
+ parse(root, SKY_BODY);
837
+ walk(root, (c) => { patchInnerHtml(c); return null; });
838
+ const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
839
+ // '.brand h1' and '.jump' are the two descendant selectors the page uses.
840
+ const querySelector = (sel) => {
841
+ const parts = String(sel).trim().split(/\s+/);
842
+ let scope = [root];
843
+ for (const part of parts) { const next = []; for (const s of scope) walk(s, (c) => { if (matchesOne(c, part)) next.push(c); return null; }); scope = next; if (!scope.length) return null; }
844
+ return scope[0] || null;
845
+ };
896
846
  const documentObj = {
897
- title: '',
898
- body: { dataset: { page: 'sky' } },
847
+ body: root, hidden: false,
899
848
  documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
900
- getElementById(id) { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); },
901
- querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
902
- querySelectorAll() { return []; },
903
- createElement() { return makeEl('new'); },
904
- addEventListener() {},
849
+ getElementById: (id) => byId(id),
850
+ querySelector,
851
+ querySelectorAll: (sel) => { const out = []; walk(root, (c) => { if (matchesOne(c, sel)) out.push(c); return null; }); return out; },
852
+ createElement: (tag) => node(tag, {}),
853
+ addEventListener: (type, fn) => { docListeners.set(type, fn); },
905
854
  };
906
855
 
856
+ // ── the client ───────────────────────────────────────────────────────────
907
857
  const requests = [];
858
+ const answers = {
859
+ sky: sky === null ? { status: status.sky || 404, ok: false, data: null } : { status: 200, ok: true, data: sky },
860
+ mine: mine === null ? { status: status.mine || 404, ok: false, data: null } : { status: 200, ok: true, data: mine },
861
+ versions: versions === null ? { status: 404, ok: false, data: null } : { status: 200, ok: true, data: versions },
862
+ me: me === null ? { status: 200, ok: true, data: { builder: viewer, permissions: viewer.permissions || {} } } : { status: 200, ok: true, data: me },
863
+ template: template === undefined
864
+ ? { status: 200, ok: true, data: { template: { key: 'base-v2', label: 'Full Idea', fields: [
865
+ { key: 'why_had', label: 'What made you have the idea', required: true, substance_min: 25 },
866
+ { key: 'scope', label: 'Scope it as best as you can here', required: true, substance_min: 65 },
867
+ { key: 'build_context', label: 'What should an engineer or artist know prior to building', required: true, substance_min: 45 },
868
+ { key: 'fit', label: 'How is this long term justified in the project', required: true, substance_min: 30 },
869
+ { key: 'success', label: 'What does a successful implementation of the idea look like', required: true, substance_min: 35 },
870
+ ] } } }
871
+ : (template === null ? { status: 404, ok: false, data: null } : { status: 200, ok: true, data: { template } }),
872
+ };
873
+ const handlers = [];
908
874
  const client = {
909
- request: async (method, url) => {
910
- requests.push(url);
911
- if (/\/me$/.test(url)) {
912
- return meStatus === 200
913
- ? { status: 200, ok: true, data: { builder: viewer } }
914
- : { status: meStatus, ok: false, data: null };
915
- }
916
- // The two well reads. `goals: null` is a route that could not be read at
917
- // all, which must leave the wells named by their id rather than blank;
918
- // `criteria` absent for a goal is "not read", which must leave that well
919
- // with no rings rather than zero of them.
920
- const one = /\/goals\/(\d+)$/.exec(url);
921
- if (one) {
922
- const list = criteria[one[1]];
923
- return list === undefined
924
- ? { status: 404, ok: false, data: null }
925
- : { status: 200, ok: true, data: { goal: { id: one[1] }, criteria: list } };
926
- }
927
- if (/\/goals$/.test(url)) {
928
- return goals === null
929
- ? { status: 500, ok: false, data: null }
930
- : { status: 200, ok: true, data: { goals } };
931
- }
932
- if (trailStatus !== 200) return { status: trailStatus, ok: false, data: null };
933
- const offset = Number((/offset=(\d+)/.exec(url) || [])[1] || 0);
934
- const page = pages.find((p) => Number(p.offset || 0) === offset) || { ideas: [], total: 0 };
935
- return { status: 200, ok: true, data: page };
875
+ request: async (method, url, opts = {}) => {
876
+ const body = opts.body; requests.push({ method, url, body });
877
+ for (const h of handlers) { const r = h(method, url, body); if (r) return r; }
878
+ if (/\/sky\/mine$/.test(url)) return answers.mine;
879
+ if (/\/sky$/.test(url)) return answers.sky;
880
+ if (/\/versions\b/.test(url)) return answers.versions;
881
+ if (/\/me$/.test(url)) return answers.me;
882
+ if (/\/inbox\/template$/.test(url)) return answers.template;
883
+ return { status: 404, ok: false, data: null };
936
884
  },
937
885
  };
938
886
 
887
+ // ── the window ───────────────────────────────────────────────────────────
888
+ const store = { ...(tourSeen ? { 'sky-tour-seen': '4' } : {}), ...storage };
889
+ let raf = null;
939
890
  const windowObj = {
940
891
  __BRANDING__: {}, __MODULES__: { enabled: {} },
941
892
  BongosClient: { createClient: () => client },
942
- CSS: { escape: (s) => String(s) },
943
- OTB: {
944
- escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
945
- $: (sel) => documentObj.querySelector(sel),
946
- fmtDate: (iso) => String(iso ?? '').slice(0, 10),
947
- toast() {},
948
- },
949
- location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/sky', hash: '', assign() {} },
950
- localStorage: { getItem: () => null, setItem() {} },
951
- addEventListener() {},
893
+ innerWidth, innerHeight, devicePixelRatio: 1,
894
+ matchMedia: () => ({ matches: reduce }),
895
+ localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
896
+ location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/thinking', hash: '', assign: (u) => { out.navigated = u; }, replace: (u) => { out.navigated = u; } },
897
+ addEventListener: (type, fn) => { windowListeners.set(type, fn); },
898
+ requestAnimationFrame: (fn) => { raf = fn; return 1; },
899
+ performance: { now: () => Date.now() },
900
+ setTimeout: (fn, ms) => { const id = ++timerId; timers.push({ id, fn, ms }); return id; },
901
+ clearTimeout: (id) => { const i = timers.findIndex((t) => t.id === id); if (i >= 0) timers.splice(i, 1); },
952
902
  };
903
+ class Event { constructor(type) { this.type = type; } preventDefault() {} }
953
904
 
954
905
  const sandbox = {
955
- window: windowObj, document: documentObj, location: windowObj.location,
956
- console, setTimeout, clearTimeout, URLSearchParams, Math, Number,
906
+ window: windowObj, document: documentObj, location: windowObj.location, localStorage: windowObj.localStorage,
907
+ innerWidth, innerHeight, devicePixelRatio: 1, matchMedia: windowObj.matchMedia, performance: windowObj.performance,
908
+ requestAnimationFrame: windowObj.requestAnimationFrame, addEventListener: windowObj.addEventListener,
909
+ setTimeout: windowObj.setTimeout, clearTimeout: windowObj.clearTimeout,
910
+ console, Event, URLSearchParams, Math, Number, Date, JSON, Map, Set, Promise, Object, Array, String, Infinity, NaN,
957
911
  };
958
912
  sandbox.globalThis = sandbox;
959
913
  vm.createContext(sandbox);
960
- vm.runInContext(IDEA_OBJECTS_JS, sandbox, { filename: 'idea-objects.js' });
961
- vm.runInContext(SKY_JS, sandbox, { filename: 'sky.js' });
962
-
963
- return {
964
- requests,
965
- objects: () => windowObj.OTBIdeaObjects,
966
- field: () => documentObj.getElementById('sky-map').innerHTML,
967
- panel: () => documentObj.getElementById('sky-panel').innerHTML,
968
- legend: () => documentObj.getElementById('sky-legend').innerHTML,
969
- sub: () => documentObj.getElementById('sky-sub').textContent,
970
- foot: () => documentObj.getElementById('sky-foot'),
971
- // Picking is the page's only control, and it is DELEGATED — one listener on
972
- // the field for a trail of 300 bodies — so the suite drives it the way a
973
- // click does rather than by calling an internal the page does not export.
974
- pick: (ideaId) => {
975
- const fn = listeners.get('sky-map:click');
976
- if (!fn) throw new Error('the field wired no click listener');
977
- fn({ target: { closest: (sel) => (sel === '[data-idea]'
978
- ? { getAttribute: () => String(ideaId) }
979
- : null) } });
914
+ for (const [name, src] of SKY_SRC) vm.runInContext(src, sandbox, { filename: name });
915
+
916
+ const out = {
917
+ requests, handlers, timers: {
918
+ // Run every pending timer (the composer's 300ms debounce, the tour's 600ms
919
+ // autostart), shortest first. Under reduced motion (the default here) the
920
+ // page polls for a redraw with a named 120ms timer instead of a rAF loop;
921
+ // that poll is not something a test waits on, so it is left alone and
922
+ // never counted.
923
+ flush: async () => { let guard = 0; while (timers.some((t) => !isPoll(t)) && guard++ < 50) { timers.sort((a, b) => a.ms - b.ms); const i = timers.findIndex((t) => !isPoll(t)); const [t] = timers.splice(i, 1); t.fn(); await settle(); } },
924
+ pending: () => timers.filter((t) => !isPoll(t)).length,
925
+ // Draw one polled frame, the way the page would after 120ms.
926
+ poll: () => { const i = timers.findIndex(isPoll); if (i >= 0) { const [t] = timers.splice(i, 1); t.fn(); } },
980
927
  },
928
+ navigated: null,
929
+ el: (id) => byId(id),
930
+ S: () => windowObj.OTBSky.S,
931
+ SKY: () => windowObj.OTBSky,
932
+ objects: () => windowObj.OTBIdeaObjects,
933
+ rows: () => byId('rows').innerHTML,
934
+ filters: () => byId('filters').innerHTML,
935
+ panel: () => byId('panel').innerHTML,
936
+ stats: () => byId('stats').innerHTML,
937
+ gravity: () => byId('gravity').innerHTML || byId('gravity').textContent,
938
+ receipt: () => byId('receiptText').innerHTML,
939
+ card: () => byId('card').innerHTML,
940
+ steps: () => byId('fullSteps').innerHTML,
941
+ focused: () => focused,
942
+ key: (k, target = null) => { const fn = windowListeners.get('keydown'); if (!fn) throw new Error('the page wired no keydown listener'); fn({ key: k, target, preventDefault() {} }); },
943
+ type: (v) => { const t = byId('thought'); t.value = v; t.fire('input', new Event('input')); },
944
+ submit: () => { byId('file').fire('submit', new Event('submit')); },
945
+ click: (id) => { const el = byId(id); if (!el) throw new Error(`no element #${id}`); el.fire('click', new Event('click')); },
946
+ frame: () => { if (raf) raf(Date.now()); },
947
+ ready: () => root.dataset.skyReady === '1',
981
948
  };
949
+ return out;
982
950
  }
983
951
 
984
952
  // namesFieldKey — does this source USE `key` as a Full Idea field key, as
@@ -174,32 +174,38 @@ async function trailRowsFor(cast) {
174
174
  return rows;
175
175
  }
176
176
 
177
- const page = (ideas) => ({ ideas, total: ideas.length, offset: 0, limit: 100 });
178
-
179
- // Read a glyph's geometry back off the markup the real renderer wrote.
180
- function glyphOf(html, ideaId) {
181
- const re = new RegExp(`data-idea="${ideaId}"[\\s\\S]*?<span class="sky-glyph"([^>]*)>`);
182
- const m = re.exec(html);
183
- if (!m) return null;
184
- const attrs = m[1];
185
- return {
186
- object: (/data-object="([^"]+)"/.exec(attrs) || [])[1] || null,
187
- d: Number((/--d:(\d+)px/.exec(attrs) || [])[1]),
188
- lit: Number((/--lit:([\d.]+)/.exec(attrs) || [])[1]),
189
- unknownSize: /data-size="unknown"/.test(attrs),
190
- };
191
- }
192
- const labelOf = (html, ideaId) =>
193
- (new RegExp(`data-idea="${ideaId}"[\\s\\S]*?aria-label="([^"]*)"`).exec(html) || [])[1] || '';
177
+ // The rows as GET /sky/mine hands them back — the SAME trail rows, whole
178
+ // (task 1004231: the sky reads its trail through that one route now).
179
+ const page = (ideas) => ({ builder_id: 95, ideas, total: ideas.length, cap: 1000, truncated: false });
194
180
 
195
181
  // Render a cast end to end and hand back the glyphs, so a case states the
196
- // SCENARIO and never the plumbing.
182
+ // SCENARIO and never the plumbing. A glyph is what the real page decided to
183
+ // draw for the thought — the object, the diameter (the page's one curve over
184
+ // the server's scale, sky-data.js `diameter`) and the light — read off the
185
+ // page's live state; `drawn` is the width the drawer's mark was actually
186
+ // written at, for the two objects the mark carries a width for. A label is
187
+ // every sentence the page writes about the thought: its row in the drawer,
188
+ // its panel and its hover card.
197
189
  async function render(cast) {
198
190
  const rows = await trailRowsFor(cast);
199
- const p = bootSkyPage({ pages: [page(rows)] });
191
+ const p = bootSkyPage({ mine: page(rows) });
200
192
  await drain();
201
- const html = p.field();
202
- return { html, rows, glyph: (id) => glyphOf(html, id), label: (id) => labelOf(html, id), page: p };
193
+ p.key('l');
194
+ const SKY = p.SKY();
195
+ const html = p.rows();
196
+ const find = (id) => p.S().ideas.find((x) => x.id === String(id));
197
+ const glyph = (id) => {
198
+ const i = find(id);
199
+ return { object: i.st, d: SKY.diameter(i), lit: i.lit, drawn: Number((new RegExp(`data-id="${id}"[\\s\\S]*?style="width:(\\d+)px`).exec(html) || [])[1]) || null };
200
+ };
201
+ const label = (id) => {
202
+ const i = find(id);
203
+ SKY.pick({ kind: 'idea', ref: i }, false);
204
+ SKY.showCard({ kind: 'idea', ref: i }, 10, 10);
205
+ const row = (new RegExp(`data-id="${id}"[\\s\\S]*?</button>`).exec(html) || [''])[0];
206
+ return [row, p.panel(), p.card()].join('\n');
207
+ };
208
+ return { html, rows, glyph, label, page: p };
203
209
  }
204
210
 
205
211
  // ---------------------------------------------------------------------------
@@ -245,9 +251,10 @@ await test('THE SCENARIO: a large dead thought draws at FULL SIZE and ZERO brigh
245
251
  assert.equal(dead.lit, 0, 'zero brightness: none of the work it caused is still alive');
246
252
  // FULL SIZE, measured rather than promised: the diameter is computed from a
247
253
  // `scale` the real size read produced AFTER the removal was confirmed.
248
- const expected = 9 + (32 - 9) * compressIdeaSize(TOTAL);
249
- assert.ok(Math.abs(dead.d - Math.round(expected)) <= 1,
254
+ const expected = 6 + 22 * compressIdeaSize(TOTAL);
255
+ assert.ok(Math.abs(dead.d - expected) < 0.001,
250
256
  `it keeps the diameter its 240 credits earn (${dead.d}px vs ~${expected.toFixed(1)}px)`);
257
+ assert.equal(dead.drawn, Math.round(expected * 1.1), 'and the drawer draws the mark at that width');
251
258
  });
252
259
 
253
260
  await test('SIZE NEVER FELL — the dead thought is exactly as big as its untouched twin', async () => {
@@ -260,6 +267,7 @@ await test('SIZE NEVER FELL — the dead thought is exactly as big as its untouc
260
267
  // The two were paid identically; only one had its work removed. This is the
261
268
  // claim hall_sky.mjs cannot make, because there the sizes were written by hand.
262
269
  assert.equal(dead.d, live.d, 'removal took the light and left the size alone');
270
+ assert.equal(dead.drawn, live.drawn, 'and the two marks in the drawer are the same width');
263
271
  assert.equal(r.rows[0].size.credits, TOTAL, 'and the credits behind it are untouched too');
264
272
  assert.equal(r.rows[0].size.credits, r.rows[1].size.credits);
265
273
  assert.equal(live.lit, 1, 'while the twin is still fully lit');
@@ -275,7 +283,7 @@ await test('THE DISTINCTION: dead and never-caused do not render alike on EITHER
275
283
  // Channel one — size. The whole reason there are two.
276
284
  assert.ok(dead.d > never.d,
277
285
  `the thought that mattered is visibly bigger (${dead.d}px vs ${never.d}px)`);
278
- assert.equal(never.d, 9, 'a thought that caused nothing sits at the floor diameter');
286
+ assert.equal(never.d, 6, 'a thought that caused nothing sits at the floor diameter');
279
287
  // Channel two — brightness, and it runs the OPPOSITE way, which is the part a
280
288
  // single number could never express: the dead one is dark, the never-caused
281
289
  // one is LIT, because nothing has been taken from it.
@@ -378,7 +386,7 @@ await test('AN UNREADABLE REMOVAL IS NOT A DEATH — it stays lit and full, and
378
386
  assert.notEqual(g.object, 'remnant', 'an unread removal must never be drawn as a confirmed one');
379
387
  assert.equal(g.lit, 1, 'the glyph stays FULL — the doubt goes in words, not in the picture');
380
388
  assert.match(r.label(601), /unknown/, 'and the words say the share is unknown');
381
- assert.ok(g.d > 9, 'the size, which never depended on the removal facts, is still drawn');
389
+ assert.ok(g.d > 6, 'the size, which never depended on the removal facts, is still drawn');
382
390
  } finally {
383
391
  removalsPort.confirmedRemovalsForTasks = savedFn;
384
392
  }