@bongos/core 1.19.1011 → 1.19.1013
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +53 -43
- package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
- package/docs/copy-inventory.md +99 -56
- package/docs/copy-registry.json +830 -465
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/idea-objects.js +4 -1
- package/modules/hall-ui/public/sky-composer.js +155 -0
- package/modules/hall-ui/public/sky-data.js +287 -0
- package/modules/hall-ui/public/sky-draw.js +244 -0
- package/modules/hall-ui/public/sky-interaction.js +137 -0
- package/modules/hall-ui/public/sky-layout.js +81 -0
- package/modules/hall-ui/public/sky-panel.js +141 -0
- package/modules/hall-ui/public/sky-tour.js +80 -0
- package/modules/hall-ui/public/thinking.css +203 -267
- package/modules/hall-ui/public/thinking.html +119 -58
- package/modules/hall-ui/public/thinking.states.json +33 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/gen-file-map.js +29 -12
- package/src/bongos/routes/me.js +4 -1
- package/src/bongos/serve-internal.js +20 -6
- package/src/module-api.js +1 -1
- package/tests/file_map.mjs +35 -3
- package/tests/hall_audit.mjs +27 -0
- package/tests/hall_page_gate_map.mjs +11 -5
- package/tests/hall_sky.mjs +393 -405
- package/tests/hall_thinking_room.mjs +274 -296
- package/tests/helpers.mjs +185 -217
- package/tests/ideator_large_dead_thought_proof.mjs +33 -25
- package/tests/ideator_traceable_proof.mjs +61 -39
- package/modules/hall-ui/public/sky.css +0 -365
- package/modules/hall-ui/public/sky.html +0 -79
- package/modules/hall-ui/public/sky.js +0 -580
- package/modules/hall-ui/public/sky.states.json +0 -20
- 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
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
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
|
|
744
|
-
// the
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
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
|
-
//
|
|
752
|
-
// is
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
887
|
-
const
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
|
|
898
|
-
body: { dataset: { page: 'sky' } },
|
|
847
|
+
body: root, hidden: false,
|
|
899
848
|
documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
|
|
900
|
-
getElementById
|
|
901
|
-
querySelector
|
|
902
|
-
querySelectorAll() {
|
|
903
|
-
createElement()
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
},
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
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(
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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({
|
|
191
|
+
const p = bootSkyPage({ mine: page(rows) });
|
|
200
192
|
await drain();
|
|
201
|
-
|
|
202
|
-
|
|
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 =
|
|
249
|
-
assert.ok(Math.abs(dead.d -
|
|
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,
|
|
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 >
|
|
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
|
}
|