@bongos/core 1.17.2 → 1.18.0
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 +426 -96
- package/.devcontainer/healthcheck.sh +0 -0
- package/.devcontainer/init-firewall.sh +0 -0
- package/.devcontainer/post-create.sh +0 -0
- package/GOVERNANCE.md +1 -1
- package/README.md +26 -22
- package/bin/bongos.js +0 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docker-entrypoint.sh +0 -0
- package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +94 -0
- package/docs/adr/0140-cloudbongos-standalone-reprovision.md +43 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +68 -370
- package/docs/api-reference.md +7 -18
- package/docs/file-map.md +7 -25
- package/docs/onboarding/diagrams/05-versioning.mmd +4 -6
- package/docs/onboarding/diagrams/assertions.json +5 -9
- package/modules/hall-ui/public/builders.js +132 -645
- package/modules/hall-ui/public/goal-inbox.js +67 -26
- package/modules/hall-ui/public/goals-page.js +102 -177
- package/modules/hall-ui/public/goals.css +183 -0
- package/modules/hall-ui/public/goals.html +35 -253
- package/modules/hall-ui/public/goals.js +7 -2
- package/modules/hall-ui/public/hall-kit.js +474 -0
- package/modules/hall-ui/public/hall-widgets.js +24 -20
- package/modules/hall-ui/public/instances-panel.js +24 -3
- package/modules/hall-ui/public/new-project-wizard.js +6 -5
- package/modules/hall-ui/public/settings.css +355 -0
- package/modules/hall-ui/public/settings.html +86 -414
- package/modules/hall-ui/public/settings.js +96 -35
- package/modules/hall-ui/public/style.css +156 -0
- package/modules/hall-ui/public/watch.css +184 -0
- package/modules/hall-ui/public/watch.html +111 -285
- package/modules/hall-ui/public/watch.js +59 -21
- package/modules/hall-ui/public/work.css +194 -0
- package/modules/hall-ui/public/work.html +46 -374
- package/modules/hall-ui/public/work.js +137 -102
- package/modules/lifecycle/criterion-suggest.js +80 -0
- package/modules/lifecycle/db.js +42 -5
- package/modules/lifecycle/routes/goals.js +26 -0
- package/modules/lifecycle/routes/tasks.js +21 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +83 -7
- package/public-landing/index.html +11 -76
- package/scripts/cron/style_guide_audit.sh +0 -0
- package/scripts/gds/api.js +0 -0
- package/scripts/gds/architect-audit.js +0 -0
- package/scripts/gds/audit-authorship.sh +0 -0
- package/scripts/gds/audit-replay.sh +0 -0
- package/scripts/gds/box.js +0 -0
- package/scripts/gds/capture.js +0 -0
- package/scripts/gds/conductor-dispatch.js +0 -0
- package/scripts/gds/coreB-carve-proof.sh +0 -0
- package/scripts/gds/db-backup-nightly.sh +0 -0
- package/scripts/gds/db-backup-offsite.sh +0 -0
- package/scripts/gds/doctor.js +0 -0
- package/scripts/gds/grade-smoke.js +0 -0
- package/scripts/gds/hooks/pre-push.js +0 -0
- package/scripts/gds/install-git-hooks.js +0 -0
- package/scripts/gds/main-audit.js +0 -0
- package/scripts/gds/oauth-secret.js +0 -0
- package/scripts/gds/onboarding-analyze.js +0 -0
- package/scripts/gds/onboarding-ship.js +0 -0
- package/scripts/gds/paste-token.js +0 -0
- package/scripts/gds/portability-smoke-art.sh +0 -0
- package/scripts/gds/portability-smoke-memory.sh +0 -0
- package/scripts/gds/provision.js +125 -4
- package/scripts/gds/push-main.js +0 -0
- package/scripts/gds/render-ideas.js +0 -0
- package/scripts/gds/security-baseline-check.sh +0 -0
- package/scripts/gds/session.js +0 -0
- package/scripts/gds/ship.js +11 -1
- package/scripts/gds/smoke-batch-claim.sh +0 -0
- package/scripts/gds/smoke-batch-release.sh +0 -0
- package/scripts/gds/smoke-box.sh +0 -0
- package/scripts/gds/smoke-claim-consistency.sh +0 -0
- package/scripts/gds/smoke-dependencies.sh +0 -0
- package/scripts/gds/smoke-write.sh +0 -0
- package/scripts/hall-preview/README.md +103 -0
- package/scripts/hall-preview/fixtures/access-requests.json +20 -0
- package/scripts/hall-preview/fixtures/achievements.json +76 -0
- package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
- package/scripts/hall-preview/fixtures/audit-log.json +68 -0
- package/scripts/hall-preview/fixtures/blockers__detail.json +12 -0
- package/scripts/hall-preview/fixtures/box__me.json +5 -0
- package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +69 -0
- package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
- package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
- package/scripts/hall-preview/fixtures/builders__roster.json +48 -0
- package/scripts/hall-preview/fixtures/goals.json +502 -0
- package/scripts/hall-preview/fixtures/goals__25.json +96 -0
- package/scripts/hall-preview/fixtures/goals__26.json +67 -0
- package/scripts/hall-preview/fixtures/goals__3.json +103 -0
- package/scripts/hall-preview/fixtures/goals__35.json +49 -0
- package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
- package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
- package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
- package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
- package/scripts/hall-preview/fixtures/grades__by-builder.json +32 -0
- package/scripts/hall-preview/fixtures/inbox.json +330 -0
- package/scripts/hall-preview/fixtures/inbox__detail.json +15 -0
- package/scripts/hall-preview/fixtures/instance.json +124 -0
- package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
- package/scripts/hall-preview/fixtures/me.json +476 -0
- package/scripts/hall-preview/fixtures/me__art-key__own.json +6 -0
- package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +209 -0
- package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
- package/scripts/hall-preview/fixtures/me__render.json +5 -0
- package/scripts/hall-preview/fixtures/me__skill-prefs.json +143 -0
- package/scripts/hall-preview/fixtures/me__sound-prefs.json +67 -0
- package/scripts/hall-preview/fixtures/me__wandering.json +43 -0
- package/scripts/hall-preview/fixtures/override-requests.json +15 -0
- package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
- package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
- package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
- package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
- package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
- package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
- package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
- package/scripts/hall-preview/fixtures/public__grades.json +50 -0
- package/scripts/hall-preview/fixtures/public__progress.json +124 -0
- package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
- package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
- package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
- package/scripts/hall-preview/fixtures/security__docs.json +10 -0
- package/scripts/hall-preview/fixtures/security__reports.json +31 -0
- package/scripts/hall-preview/fixtures/sessions__search.json +9 -0
- package/scripts/hall-preview/fixtures/tasks.json +2420 -0
- package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
- package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
- package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
- package/scripts/hall-preview/fixtures/versions.json +89 -0
- package/scripts/hall-preview/refresh-fixtures.js +77 -0
- package/scripts/hall-preview/server.js +195 -0
- package/scripts/migrate.sh +29 -4
- package/scripts/migrate_cost_ledger.sh +0 -0
- package/scripts/render-diagrams.sh +0 -0
- package/scripts/security/dep-audit.js +0 -0
- package/scripts/setup-builder.js +0 -0
- package/scripts/setup-builder.sh +0 -0
- package/scripts/snapshots/maybe-render-on-deploy.sh +0 -0
- package/src/bongos/optimizer.js +37 -2
- package/src/module-api.js +20 -1
- package/tests/criterion_suggest.mjs +106 -0
- package/tests/migrate_root_resolution.mjs +97 -0
- package/tests/provision.mjs +69 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// modules/hall-ui/public/hall-kit.js — the hall's shared UI component kit
|
|
2
|
+
// (task 2166, the hall UX-coherence program's foundation).
|
|
3
|
+
//
|
|
4
|
+
// One dependency-free IIFE exposing `window.OTBKit`: the SIX primitives every
|
|
5
|
+
// page redesign (tasks 2169–2176) adopts instead of re-implementing — pager,
|
|
6
|
+
// ledger, filter bar, tabs, section rail, empty/skeleton/collapsible. Before
|
|
7
|
+
// this file, the ledger+pager block existed in ~10 drifted copies across
|
|
8
|
+
// work.js / goals-page.js / builders.js; the section rail existed twice with
|
|
9
|
+
// different mechanics (Standing scrollspy vs Settings panel-switch).
|
|
10
|
+
//
|
|
11
|
+
// Contract:
|
|
12
|
+
// - Dependency-free like shell.js (own esc(), no window.OTB use) so load order
|
|
13
|
+
// only needs "before the page script". Node-require-safe: no eval-time DOM.
|
|
14
|
+
// - String-template + innerHTML factories returning { el, render(data) } — the
|
|
15
|
+
// exact idiom the pages already use. No state manager, no render loop.
|
|
16
|
+
// - Pager markup/ids and localStorage keys are caller-supplied so existing
|
|
17
|
+
// per-page prefs (otb.work.*PerPage, …) survive migration verbatim.
|
|
18
|
+
// - Tabs/rails toggle [hidden] — the window stays the hall's ONLY scroller.
|
|
19
|
+
// - The kit NEVER touches location hashes in the deep-link namespace ("#/…"
|
|
20
|
+
// — task/idea/blocker overlays, goal sub-routes). Guarded in makeTabs.
|
|
21
|
+
// - CSS lives in style.css §"hall-kit components" (same task); the kit reuses
|
|
22
|
+
// .ledger/.pager/.filters/.setup__tab/.rail-link classes that already exist.
|
|
23
|
+
(() => {
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
// HTML-escape for text interpolated into innerHTML.
|
|
27
|
+
function esc(s) {
|
|
28
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
29
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
30
|
+
}[c]));
|
|
31
|
+
}
|
|
32
|
+
const $id = (id) => document.getElementById(id);
|
|
33
|
+
|
|
34
|
+
// ---- pager ----------------------------------------------------------------
|
|
35
|
+
// Canonized from work.js (task 1931/1955): page + per-page persisted per list,
|
|
36
|
+
// clamping against shrunken lists, poll-safe page retention, bar hidden when
|
|
37
|
+
// the list fits one minimum page.
|
|
38
|
+
|
|
39
|
+
const PAGER_DEFAULTS = { perPageOptions: [10, 25, 50, 100], defaultPerPage: 25 };
|
|
40
|
+
|
|
41
|
+
function loadPerPage(key, options, fallback) {
|
|
42
|
+
try {
|
|
43
|
+
const v = Number(window.localStorage.getItem(key));
|
|
44
|
+
return options.includes(v) ? v : fallback;
|
|
45
|
+
} catch (_) { return fallback; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// makePager(prefix, storageKey, opts?) → pager state. `prefix` names the DOM
|
|
49
|
+
// ids the bar paints (#<prefix>-{pager,range,page,prev,next,per-page});
|
|
50
|
+
// `storageKey` is the caller's localStorage key (kept stable across
|
|
51
|
+
// migrations so builders' per-page prefs survive).
|
|
52
|
+
function makePager(prefix, storageKey, opts = {}) {
|
|
53
|
+
const perPageOptions = opts.perPageOptions || PAGER_DEFAULTS.perPageOptions;
|
|
54
|
+
const defaultPerPage = opts.defaultPerPage || PAGER_DEFAULTS.defaultPerPage;
|
|
55
|
+
return {
|
|
56
|
+
prefix,
|
|
57
|
+
key: storageKey,
|
|
58
|
+
page: 1,
|
|
59
|
+
perPage: loadPerPage(storageKey, perPageOptions, defaultPerPage),
|
|
60
|
+
perPageOptions,
|
|
61
|
+
// ≤ this fits one page — hide the bar entirely.
|
|
62
|
+
minPerPage: opts.minPerPage ?? perPageOptions[0],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The standard pager-bar markup. Pages stop hand-copying this block; the ids
|
|
67
|
+
// match what renderPagerBar/wirePager address.
|
|
68
|
+
function pagerBarHtml(prefix, { label = 'items' } = {}) {
|
|
69
|
+
const p = esc(prefix);
|
|
70
|
+
const l = esc(label);
|
|
71
|
+
return `
|
|
72
|
+
<div class="pager pager--paged" id="${p}-pager" hidden>
|
|
73
|
+
<span class="pager__range" id="${p}-range"></span>
|
|
74
|
+
<div class="pager__nav">
|
|
75
|
+
<label class="pager__perpage">
|
|
76
|
+
<span>Per page</span>
|
|
77
|
+
<select id="${p}-per-page" aria-label="${l} per page"></select>
|
|
78
|
+
</label>
|
|
79
|
+
<button class="btn-ghost pager__btn" type="button" id="${p}-prev" aria-label="Previous page">‹ Prev</button>
|
|
80
|
+
<span class="pager__page" id="${p}-page" aria-live="polite"></span>
|
|
81
|
+
<button class="btn-ghost pager__btn" type="button" id="${p}-next" aria-label="Next page">Next ›</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Slice `list` to the pager's current page (clamped — a filtered list may
|
|
87
|
+
// have shrunk under a background poll), paint the bar, return the slice.
|
|
88
|
+
function paginate(list, p) {
|
|
89
|
+
const total = list.length;
|
|
90
|
+
const totalPages = Math.max(1, Math.ceil(total / p.perPage));
|
|
91
|
+
if (p.page > totalPages) p.page = totalPages;
|
|
92
|
+
if (p.page < 1) p.page = 1;
|
|
93
|
+
const start = (p.page - 1) * p.perPage;
|
|
94
|
+
const slice = list.slice(start, start + p.perPage);
|
|
95
|
+
renderPagerBar(p, total, totalPages, start, slice.length);
|
|
96
|
+
return slice;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderPagerBar(p, total, totalPages, start, shownCount) {
|
|
100
|
+
const pager = $id(`${p.prefix}-pager`);
|
|
101
|
+
if (!pager) return;
|
|
102
|
+
if (total <= p.minPerPage) { pager.hidden = true; return; }
|
|
103
|
+
pager.hidden = false;
|
|
104
|
+
const range = $id(`${p.prefix}-range`);
|
|
105
|
+
if (range) range.textContent = total
|
|
106
|
+
? `Showing ${start + 1}–${start + shownCount} of ${total}`
|
|
107
|
+
: 'Showing 0 of 0';
|
|
108
|
+
const ind = $id(`${p.prefix}-page`);
|
|
109
|
+
if (ind) ind.textContent = `Page ${p.page} of ${totalPages}`;
|
|
110
|
+
const prev = $id(`${p.prefix}-prev`);
|
|
111
|
+
if (prev) prev.disabled = p.page <= 1;
|
|
112
|
+
const next = $id(`${p.prefix}-next`);
|
|
113
|
+
if (next) next.disabled = p.page >= totalPages;
|
|
114
|
+
const sel = $id(`${p.prefix}-per-page`);
|
|
115
|
+
if (sel && sel.value !== String(p.perPage)) sel.value = String(p.perPage);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Wire a pager's controls once. `rerender` re-renders that list; a per-page
|
|
119
|
+
// change resets to page 1 and persists. Fills the per-page <select> from the
|
|
120
|
+
// pager's own options so pagerBarHtml stays option-agnostic.
|
|
121
|
+
function wirePager(p, rerender) {
|
|
122
|
+
const sel = $id(`${p.prefix}-per-page`);
|
|
123
|
+
if (sel && !sel.options.length) {
|
|
124
|
+
sel.innerHTML = p.perPageOptions.map((v) => `<option value="${v}">${v}</option>`).join('');
|
|
125
|
+
sel.value = String(p.perPage);
|
|
126
|
+
}
|
|
127
|
+
sel?.addEventListener('change', (e) => {
|
|
128
|
+
const v = Number(e.target.value);
|
|
129
|
+
if (!p.perPageOptions.includes(v)) return;
|
|
130
|
+
p.perPage = v; p.page = 1;
|
|
131
|
+
try { window.localStorage.setItem(p.key, String(v)); } catch (_) { /* private mode */ }
|
|
132
|
+
rerender();
|
|
133
|
+
});
|
|
134
|
+
$id(`${p.prefix}-prev`)?.addEventListener('click', () => { if (p.page > 1) { p.page -= 1; rerender(); } });
|
|
135
|
+
$id(`${p.prefix}-next`)?.addEventListener('click', () => { p.page += 1; rerender(); });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- ledger ----------------------------------------------------------------
|
|
139
|
+
// makeLedger({ mount, columns, rowHtml, emptyMsg, emptyAction?, pager? })
|
|
140
|
+
// Stamps the standard table.ledger (+ pager bar when a pager is given) into
|
|
141
|
+
// `mount` and returns { el, tbody, render(rows) }. `columns` entries are
|
|
142
|
+
// strings or { label, class }. `rowHtml(row, i)` returns a <tr> string —
|
|
143
|
+
// callers keep full control of cells (and must esc() their own data).
|
|
144
|
+
function makeLedger({ mount, columns = [], rowHtml, emptyMsg = 'Nothing here yet.', emptyAction = null, pager = null }) {
|
|
145
|
+
if (!mount || typeof rowHtml !== 'function') {
|
|
146
|
+
throw new Error('makeLedger({ mount, columns, rowHtml, … }) requires mount + rowHtml()');
|
|
147
|
+
}
|
|
148
|
+
const head = columns.map((c) => {
|
|
149
|
+
const col = typeof c === 'string' ? { label: c } : c;
|
|
150
|
+
return `<th${col.class ? ` class="${esc(col.class)}"` : ''}>${esc(col.label)}</th>`;
|
|
151
|
+
}).join('');
|
|
152
|
+
mount.innerHTML = `
|
|
153
|
+
<table class="ledger">
|
|
154
|
+
<thead><tr>${head}</tr></thead>
|
|
155
|
+
<tbody></tbody>
|
|
156
|
+
</table>
|
|
157
|
+
${pager ? pagerBarHtml(pager.prefix, { label: 'rows' }) : ''}`;
|
|
158
|
+
const tbody = mount.querySelector('tbody');
|
|
159
|
+
let last = [];
|
|
160
|
+
function render(rows) {
|
|
161
|
+
if (Array.isArray(rows)) last = rows;
|
|
162
|
+
const slice = pager ? paginate(last, pager) : last;
|
|
163
|
+
tbody.innerHTML = slice.length
|
|
164
|
+
? slice.map((r, i) => rowHtml(r, i)).join('')
|
|
165
|
+
: `<tr><td class="ledger__empty" colspan="${columns.length || 1}">${emptyStateHtml(emptyMsg, emptyAction)}</td></tr>`;
|
|
166
|
+
}
|
|
167
|
+
if (pager) wirePager(pager, () => render());
|
|
168
|
+
return { el: mount, tbody, render };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- filter bar ------------------------------------------------------------
|
|
172
|
+
// makeFilterBar({ mount, search?, primary?, overflow?, url?, onChange })
|
|
173
|
+
// Primary controls render inline; `overflow` controls sit behind a
|
|
174
|
+
// "More filters" disclosure with an active-count badge, plus removable chips
|
|
175
|
+
// when the overflow row is closed. Control spec:
|
|
176
|
+
// { id, label, type?: 'select'|'search', options?: [{value,label}|string],
|
|
177
|
+
// value?, default? } (default falls back to the first option / '')
|
|
178
|
+
// `url: true` round-trips non-default values through the query string
|
|
179
|
+
// (history.replaceState — shareable filtered views, hash untouched).
|
|
180
|
+
function makeFilterBar({ mount, search = null, primary = [], overflow = [], url = false, onChange = null }) {
|
|
181
|
+
if (!mount) throw new Error('makeFilterBar requires mount');
|
|
182
|
+
const controls = [...(search ? [{ type: 'search', ...search }] : []), ...primary, ...overflow]
|
|
183
|
+
.map((c) => ({ type: 'select', ...c, default: c.default ?? (c.type === 'search' || !c.options?.length ? '' : optVal(c.options[0])) }));
|
|
184
|
+
const byId = new Map(controls.map((c) => [c.id, c]));
|
|
185
|
+
const overflowIds = new Set(overflow.map((c) => c.id));
|
|
186
|
+
|
|
187
|
+
function optVal(o) { return typeof o === 'string' ? o : o.value; }
|
|
188
|
+
function optLabel(o) { return typeof o === 'string' ? o : o.label; }
|
|
189
|
+
|
|
190
|
+
if (url) {
|
|
191
|
+
const q = new URLSearchParams(window.location.search);
|
|
192
|
+
controls.forEach((c) => { if (q.has(c.id)) c.value = q.get(c.id); });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function controlHtml(c) {
|
|
196
|
+
const cur = c.value ?? c.default;
|
|
197
|
+
if (c.type === 'search') {
|
|
198
|
+
return `<label class="filter">${esc(c.label || 'Search')}
|
|
199
|
+
<input type="search" id="${esc(c.id)}" placeholder="${esc(c.placeholder || '')}" value="${esc(cur)}"></label>`;
|
|
200
|
+
}
|
|
201
|
+
const opts = (c.options || []).map((o) =>
|
|
202
|
+
`<option value="${esc(optVal(o))}"${String(optVal(o)) === String(cur) ? ' selected' : ''}>${esc(optLabel(o))}</option>`).join('');
|
|
203
|
+
return `<label class="filter">${esc(c.label)}<select id="${esc(c.id)}">${opts}</select></label>`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const activeOverflow = () => overflow.filter((c) => String(get(c.id)) !== String(byId.get(c.id).default));
|
|
207
|
+
|
|
208
|
+
mount.innerHTML = `
|
|
209
|
+
<div class="filters">
|
|
210
|
+
${(search ? [controls[0]] : []).map(controlHtml).join('')}
|
|
211
|
+
${primary.map((c) => controlHtml(byId.get(c.id))).join('')}
|
|
212
|
+
${overflow.length ? `<button type="button" class="btn-ghost filters__more" aria-expanded="false">Filters<span class="count-pill filters__more-count" hidden></span></button>` : ''}
|
|
213
|
+
</div>
|
|
214
|
+
${overflow.length ? `<div class="filters filters--overflow" hidden>
|
|
215
|
+
${overflow.map((c) => controlHtml(byId.get(c.id))).join('')}
|
|
216
|
+
<button type="button" class="btn-ghost filters__clear">Clear all</button>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="filter-chips" hidden></div>` : ''}`;
|
|
219
|
+
|
|
220
|
+
const moreBtn = mount.querySelector('.filters__more');
|
|
221
|
+
const overflowRow = mount.querySelector('.filters--overflow');
|
|
222
|
+
const chipsRow = mount.querySelector('.filter-chips');
|
|
223
|
+
|
|
224
|
+
function get(id) {
|
|
225
|
+
const el = mount.querySelector(`#${CSS.escape(id)}`);
|
|
226
|
+
return el ? el.value : (byId.get(id)?.value ?? byId.get(id)?.default);
|
|
227
|
+
}
|
|
228
|
+
function set(id, value, { fire = true } = {}) {
|
|
229
|
+
const el = mount.querySelector(`#${CSS.escape(id)}`);
|
|
230
|
+
if (el) el.value = value;
|
|
231
|
+
if (fire) changed();
|
|
232
|
+
}
|
|
233
|
+
function values() {
|
|
234
|
+
const out = {};
|
|
235
|
+
controls.forEach((c) => { out[c.id] = get(c.id); });
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function paintBadges() {
|
|
240
|
+
const active = activeOverflow();
|
|
241
|
+
const count = mount.querySelector('.filters__more-count');
|
|
242
|
+
if (count) { count.hidden = !active.length; count.textContent = String(active.length); }
|
|
243
|
+
if (chipsRow) {
|
|
244
|
+
const open = overflowRow && !overflowRow.hidden;
|
|
245
|
+
chipsRow.hidden = open || !active.length;
|
|
246
|
+
chipsRow.innerHTML = active.map((c) => {
|
|
247
|
+
const cur = get(c.id);
|
|
248
|
+
const lbl = (c.options || []).find((o) => String(optVal(o)) === String(cur));
|
|
249
|
+
return `<button type="button" class="chip" data-chip="${esc(c.id)}" title="Remove filter">
|
|
250
|
+
${esc(c.label)}: ${esc(lbl ? optLabel(lbl) : cur)} <span aria-hidden="true">×</span></button>`;
|
|
251
|
+
}).join('');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function syncUrl() {
|
|
256
|
+
if (!url) return;
|
|
257
|
+
const q = new URLSearchParams(window.location.search);
|
|
258
|
+
controls.forEach((c) => {
|
|
259
|
+
const v = get(c.id);
|
|
260
|
+
if (String(v) === String(c.default) || v === '') q.delete(c.id); else q.set(c.id, v);
|
|
261
|
+
});
|
|
262
|
+
const qs = q.toString();
|
|
263
|
+
history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function changed() {
|
|
267
|
+
paintBadges();
|
|
268
|
+
syncUrl();
|
|
269
|
+
if (onChange) onChange(values());
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
mount.addEventListener('change', (e) => { if (e.target.matches('select, input[type="search"]')) changed(); });
|
|
273
|
+
mount.addEventListener('input', (e) => { if (e.target.matches('input[type="search"]')) changed(); });
|
|
274
|
+
moreBtn?.addEventListener('click', () => {
|
|
275
|
+
const open = overflowRow.hidden;
|
|
276
|
+
overflowRow.hidden = !open;
|
|
277
|
+
moreBtn.setAttribute('aria-expanded', String(open));
|
|
278
|
+
paintBadges();
|
|
279
|
+
});
|
|
280
|
+
mount.querySelector('.filters__clear')?.addEventListener('click', () => {
|
|
281
|
+
overflow.forEach((c) => set(c.id, byId.get(c.id).default, { fire: false }));
|
|
282
|
+
changed();
|
|
283
|
+
});
|
|
284
|
+
chipsRow?.addEventListener('click', (e) => {
|
|
285
|
+
const chip = e.target.closest('[data-chip]');
|
|
286
|
+
if (chip) set(chip.dataset.chip, byId.get(chip.dataset.chip).default);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
paintBadges();
|
|
290
|
+
return { el: mount, values, get, set };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- tabs ------------------------------------------------------------------
|
|
294
|
+
// makeTabs({ mount, tabs: [{ id, label, panel }], initial?, hash?, onChange })
|
|
295
|
+
// The promoted .setup__tab idiom: a tablist whose buttons toggle [hidden] on
|
|
296
|
+
// their panel elements — exactly like the index hash views, so the window
|
|
297
|
+
// stays the only scroller. `hash: true` syncs "#<id>" for deep links and
|
|
298
|
+
// back/forward, but NEVER claims the "#/…" deep-link namespace (task/idea/
|
|
299
|
+
// blocker overlays, goal sub-routes) — those hashes pass through untouched.
|
|
300
|
+
function makeTabs({ mount, tabs = [], initial = null, hash = false, onChange = null }) {
|
|
301
|
+
if (!mount || !tabs.length) throw new Error('makeTabs requires mount + tabs');
|
|
302
|
+
const ids = new Set(tabs.map((t) => t.id));
|
|
303
|
+
mount.innerHTML = `<div class="kit-tabs" role="tablist">${tabs.map((t) => `
|
|
304
|
+
<button class="setup__tab" type="button" role="tab" id="tab-${esc(t.id)}" aria-selected="false"
|
|
305
|
+
data-tab="${esc(t.id)}">${esc(t.label)}<span class="count-pill" data-tab-count="${esc(t.id)}" hidden></span></button>`).join('')}
|
|
306
|
+
</div>`;
|
|
307
|
+
|
|
308
|
+
let current = null;
|
|
309
|
+
function activate(id, { push = true } = {}) {
|
|
310
|
+
if (!ids.has(id) || id === current) return;
|
|
311
|
+
current = id;
|
|
312
|
+
tabs.forEach((t) => {
|
|
313
|
+
mount.querySelector(`[data-tab="${CSS.escape(t.id)}"]`)?.setAttribute('aria-selected', String(t.id === id));
|
|
314
|
+
const panel = $id(t.panel);
|
|
315
|
+
if (panel) panel.hidden = t.id !== id;
|
|
316
|
+
});
|
|
317
|
+
if (hash && push && window.location.hash !== `#${id}`) {
|
|
318
|
+
history.replaceState(null, '', window.location.pathname + window.location.search + `#${id}`);
|
|
319
|
+
}
|
|
320
|
+
if (onChange) onChange(id);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
mount.addEventListener('click', (e) => {
|
|
324
|
+
const btn = e.target.closest('[data-tab]');
|
|
325
|
+
if (btn) activate(btn.dataset.tab);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
function fromHash() {
|
|
329
|
+
const h = window.location.hash.slice(1);
|
|
330
|
+
// "#/…" is the overlay/sub-route namespace — never ours.
|
|
331
|
+
if (!h || h.startsWith('/')) return false;
|
|
332
|
+
if (ids.has(h)) { activate(h, { push: false }); return true; }
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
if (hash) window.addEventListener('hashchange', fromHash);
|
|
336
|
+
|
|
337
|
+
if (!(hash && fromHash())) activate(initial || tabs[0].id, { push: false });
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
el: mount,
|
|
341
|
+
activate,
|
|
342
|
+
active: () => current,
|
|
343
|
+
setCount(id, n) {
|
|
344
|
+
const pill = mount.querySelector(`[data-tab-count="${CSS.escape(id)}"]`);
|
|
345
|
+
if (pill) { pill.hidden = n == null; pill.textContent = n == null ? '' : String(n); }
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- section rail ------------------------------------------------------------
|
|
351
|
+
// makeSectionRail({ mount, items: [{ id, label }], mode, onSwitch })
|
|
352
|
+
// ONE rail, two modes — replaces the two divergent implementations:
|
|
353
|
+
// - 'scrollspy' (the Standing idiom): links are #anchors; an
|
|
354
|
+
// IntersectionObserver flips --active as section headlines cross a band a
|
|
355
|
+
// third of the way down the viewport; window scrolling only.
|
|
356
|
+
// - 'switch' (the Settings idiom): links carry data-key; clicking activates
|
|
357
|
+
// a key and hands it to onSwitch(key) — the caller toggles its panels.
|
|
358
|
+
function makeSectionRail({ mount, items = [], mode = 'scrollspy', onSwitch = null }) {
|
|
359
|
+
if (!mount) throw new Error('makeSectionRail requires mount');
|
|
360
|
+
let observer = null;
|
|
361
|
+
let current = null;
|
|
362
|
+
|
|
363
|
+
function renderLinks(list) {
|
|
364
|
+
mount.classList.add('section-rail');
|
|
365
|
+
mount.innerHTML = list.map((s) => mode === 'switch'
|
|
366
|
+
? `<a class="rail-link" href="#${esc(s.id)}" data-key="${esc(s.id)}">${esc(s.label)}</a>`
|
|
367
|
+
: `<a class="rail-link" href="#${esc(s.id)}" data-section="${esc(s.id)}">${esc(s.label)}</a>`).join('');
|
|
368
|
+
mount.hidden = list.length < 2;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function markActive(id) {
|
|
372
|
+
current = id;
|
|
373
|
+
mount.querySelectorAll('.rail-link').forEach((a) => {
|
|
374
|
+
a.classList.toggle('rail-link--active', (a.dataset.key || a.dataset.section) === id);
|
|
375
|
+
});
|
|
376
|
+
const active = mount.querySelector('.rail-link--active');
|
|
377
|
+
if (active && window.matchMedia('(max-width: 640px)').matches) {
|
|
378
|
+
active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function observe(list) {
|
|
383
|
+
observer?.disconnect();
|
|
384
|
+
if (mode !== 'scrollspy') return;
|
|
385
|
+
// The active flip happens when a section crosses a ~5% horizontal band
|
|
386
|
+
// about a third of the way down the viewport (the Standing tuning).
|
|
387
|
+
observer = new IntersectionObserver((entries) => {
|
|
388
|
+
for (const entry of entries) {
|
|
389
|
+
if (entry.isIntersecting) { markActive(entry.target.id); break; }
|
|
390
|
+
}
|
|
391
|
+
}, { rootMargin: '-40% 0px -55% 0px', threshold: 0 });
|
|
392
|
+
list.forEach((s) => { const el = $id(s.id); if (el) observer.observe(el); });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function refresh(list) {
|
|
396
|
+
renderLinks(list);
|
|
397
|
+
observe(list);
|
|
398
|
+
}
|
|
399
|
+
refresh(items);
|
|
400
|
+
|
|
401
|
+
if (mode === 'switch') {
|
|
402
|
+
mount.addEventListener('click', (e) => {
|
|
403
|
+
const a = e.target.closest('.rail-link');
|
|
404
|
+
if (!a || !a.dataset.key) return;
|
|
405
|
+
e.preventDefault();
|
|
406
|
+
markActive(a.dataset.key);
|
|
407
|
+
if (onSwitch) onSwitch(a.dataset.key);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
el: mount,
|
|
413
|
+
refresh,
|
|
414
|
+
activate: markActive,
|
|
415
|
+
active: () => current,
|
|
416
|
+
dispose: () => observer?.disconnect(),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---- empty state / skeleton / collapsible ------------------------------------
|
|
421
|
+
|
|
422
|
+
// One standard empty pattern: a short line + at most one action link. An
|
|
423
|
+
// empty card should earn its height or hide — this is the "earn" form.
|
|
424
|
+
function emptyStateHtml(msg, action = null) {
|
|
425
|
+
return `<div class="empty-state"><p>${esc(msg)}</p>${action
|
|
426
|
+
? `<a class="empty-state__action" href="${esc(action.href)}">${esc(action.label)}</a>` : ''}</div>`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// CSS-only shimmer placeholder rows (style.css §hall-kit), standard heights
|
|
430
|
+
// so cards don't jump when data lands. For use inside a <tbody>.
|
|
431
|
+
function skeletonRows(n = 3, cols = 1) {
|
|
432
|
+
const cells = Array.from({ length: cols }, () => '<td><span class="kit-skel"></span></td>').join('');
|
|
433
|
+
return Array.from({ length: n }, () => `<tr class="kit-skel-row" aria-hidden="true">${cells}</tr>`).join('');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// collapsibleCard({ mount, title, open? }) — the Work-backlog <details> idiom,
|
|
437
|
+
// generalized: a card section whose body collapses behind its heading.
|
|
438
|
+
// Returns { el, body, setCount(n) }. Secondary content should default closed.
|
|
439
|
+
function collapsibleCard({ mount, title, open = false }) {
|
|
440
|
+
if (!mount) throw new Error('collapsibleCard requires mount');
|
|
441
|
+
mount.innerHTML = `
|
|
442
|
+
<details class="kit-collapse"${open ? ' open' : ''}>
|
|
443
|
+
<summary class="kit-collapse__summary">
|
|
444
|
+
<span class="scroll__h">${esc(title)}</span>
|
|
445
|
+
<span class="count-pill" hidden></span>
|
|
446
|
+
</summary>
|
|
447
|
+
<div class="kit-collapse__body"></div>
|
|
448
|
+
</details>`;
|
|
449
|
+
const details = mount.querySelector('details');
|
|
450
|
+
const pill = mount.querySelector('.count-pill');
|
|
451
|
+
return {
|
|
452
|
+
el: details,
|
|
453
|
+
body: mount.querySelector('.kit-collapse__body'),
|
|
454
|
+
setCount(n) {
|
|
455
|
+
if (pill) { pill.hidden = n == null; pill.textContent = n == null ? '' : String(n); }
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const api = {
|
|
461
|
+
esc,
|
|
462
|
+
// pager (low-level exports so pages can adopt incrementally)
|
|
463
|
+
makePager, pagerBarHtml, paginate, renderPagerBar, wirePager,
|
|
464
|
+
// composed primitives
|
|
465
|
+
makeLedger, makeFilterBar, makeTabs, makeSectionRail,
|
|
466
|
+
// states
|
|
467
|
+
emptyStateHtml, skeletonRows, collapsibleCard,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Node-require-safe (the dom-utils.js pattern): assignment is the only
|
|
471
|
+
// eval-time side effect, and only when a window exists.
|
|
472
|
+
if (typeof window !== 'undefined') window.OTBKit = api;
|
|
473
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
474
|
+
})();
|
|
@@ -53,13 +53,16 @@
|
|
|
53
53
|
// 0c. Quick stats — headline numbers for the Home dashboard (task 1723).
|
|
54
54
|
// Filled by builders.js: credits + karma + rank from /me (renderProfile),
|
|
55
55
|
// shipped count once the ships feed settles (renderRecentShips).
|
|
56
|
+
// task 2169: each tile is a LINK — the stat row doubles as the fastest
|
|
57
|
+
// navigation on the page (Credits → the economy doc, Shipped/Karma → the
|
|
58
|
+
// Standing sections that explain them, Rank → the ladder).
|
|
56
59
|
w('home-stats', 'Overview', 15,
|
|
57
60
|
'<h2 id="home-stats-h" class="sr-only">Overview</h2>' +
|
|
58
61
|
'<div class="stat-row" id="home-stats-row">' +
|
|
59
|
-
'<
|
|
60
|
-
'<
|
|
61
|
-
'<
|
|
62
|
-
'<
|
|
62
|
+
'<a class="stat-tile" href="/drachmae"><span class="stat-tile__label" data-currency-label="caps">Credits</span><span class="stat-tile__num" id="stat-credits">—</span></a>' +
|
|
63
|
+
'<a class="stat-tile" href="#standing"><span class="stat-tile__label">Shipped</span><span class="stat-tile__num" id="stat-shipped">—</span><span class="stat-tile__sub" id="stat-shipped-sub"></span></a>' +
|
|
64
|
+
'<a class="stat-tile" href="#standing"><span class="stat-tile__label">Karma</span><span class="stat-tile__num" id="stat-karma">—</span><span class="stat-tile__sub" id="stat-karma-sub"></span></a>' +
|
|
65
|
+
'<a class="stat-tile" href="/ranks"><span class="stat-tile__label">Rank</span><span class="stat-tile__num stat-tile__num--badge" id="stat-rank">—</span><span class="stat-tile__sub" id="stat-rank-sub"></span></a>' +
|
|
63
66
|
'</div>',
|
|
64
67
|
{ view: 'home', zone: 'home-stats', sectionClass: 'scroll scroll--bare', labelledby: 'home-stats-h', hiddenByDefault: true });
|
|
65
68
|
|
|
@@ -73,22 +76,23 @@
|
|
|
73
76
|
|
|
74
77
|
// (Goals — goals.js registers itself into the other half of the Home band.)
|
|
75
78
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
'
|
|
79
|
+
// (task 2169: the idea-triage cue — task 1471 — no longer registers its own
|
|
80
|
+
// card; its rows render into the "Needs your attention" card that
|
|
81
|
+
// goal-inbox.js owns (#attention-ideas), the single landing place for every
|
|
82
|
+
// actionable item. builders.js still gates the /inbox fetch to Metic+.)
|
|
83
|
+
|
|
84
|
+
// 0e. Your work — "what's my next action" + the compact recent table
|
|
85
|
+
// (task 1723; grown by task 2184). The Next-up slice surfaces the top of the
|
|
86
|
+
// claimable queue (builders.js loadNextUp fills it; hidden when the feed is
|
|
87
|
+
// gated or empty), then the 5-row recent-ships table below it.
|
|
88
|
+
w('home-recent', 'Your work', 28,
|
|
89
|
+
'<h2 id="home-recent-h" class="scroll__h">Your work</h2>' +
|
|
90
|
+
'<div id="home-next" hidden>' +
|
|
91
|
+
'<h3 class="ginbox__sec-h">Next up — ready to claim</h3>' +
|
|
92
|
+
'<ul class="next-up" id="home-next-list"></ul>' +
|
|
93
|
+
'<p class="next-up__foot"><a href="/work">See all ready work →</a></p>' +
|
|
94
|
+
'</div>' +
|
|
95
|
+
'<h3 class="ginbox__sec-h" id="home-recent-subh" hidden>Recently shipped</h3>' +
|
|
92
96
|
'<table class="ledger" id="home-recent-table"><thead><tr><th>№</th><th>Work</th><th data-currency-label="caps">Credits</th><th>Shipped</th></tr></thead>' +
|
|
93
97
|
'<tbody><tr><td colspan="4" class="ledger__empty">…</td></tr></tbody></table>' +
|
|
94
98
|
'<p class="card-foot"><a href="#standing">All your work →</a></p>',
|
|
@@ -148,9 +148,17 @@
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// task 2169: this is the "Projects" card — instances state + the
|
|
152
|
+
// Start-a-project wizard merged. The wizard (an idle multi-step form, the
|
|
153
|
+
// page's worst length offender as its own card) renders ON DEMAND behind the
|
|
154
|
+
// header button, via window.OTBNewProject (new-project-wizard.js).
|
|
151
155
|
const SKELETON =
|
|
152
|
-
'<
|
|
153
|
-
|
|
156
|
+
'<div class="proj-head">' +
|
|
157
|
+
'<h2 id="instances-h" class="scroll__h">Projects</h2>' +
|
|
158
|
+
'<button type="button" class="btn-ghost" id="new-project-toggle" aria-expanded="false" aria-controls="new-project-mount">+ Start a project</button>' +
|
|
159
|
+
'</div>' +
|
|
160
|
+
'<p class="scroll__lede">Hosting you’ve provisioned through Cloud Bongos — each instance’s status and estimated monthly cost. Provision one right here, or with <code>bongos init</code>.</p>' +
|
|
161
|
+
'<div id="new-project-mount" hidden></div>' +
|
|
154
162
|
'<div id="instances-own-body"><div class="profile__placeholder">Loading your instances…</div></div>' +
|
|
155
163
|
'<div id="instances-fleet" hidden>' +
|
|
156
164
|
'<h3 class="scroll__h" style="font-size:1rem;margin-top:1rem">Fleet</h3>' +
|
|
@@ -161,11 +169,24 @@
|
|
|
161
169
|
function mount(hostEl) {
|
|
162
170
|
host = hostEl;
|
|
163
171
|
host.innerHTML = SKELETON;
|
|
172
|
+
const toggle = host.querySelector('#new-project-toggle');
|
|
173
|
+
const wizardMount = host.querySelector('#new-project-mount');
|
|
174
|
+
let wizardRendered = false;
|
|
175
|
+
toggle?.addEventListener('click', () => {
|
|
176
|
+
if (!wizardRendered && window.OTBNewProject) {
|
|
177
|
+
window.OTBNewProject.renderInto(wizardMount);
|
|
178
|
+
wizardRendered = true;
|
|
179
|
+
}
|
|
180
|
+
const open = wizardMount.hidden;
|
|
181
|
+
wizardMount.hidden = !open;
|
|
182
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
183
|
+
toggle.textContent = open ? '− Start a project' : '+ Start a project';
|
|
184
|
+
});
|
|
164
185
|
load();
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
window.OTB.contributeHallWidget({
|
|
168
|
-
id: 'instances-scroll', label: '
|
|
189
|
+
id: 'instances-scroll', label: 'Projects', order: 33,
|
|
169
190
|
module: 'provisioning', view: 'home', zone: 'home-main',
|
|
170
191
|
labelledby: 'instances-h', mount,
|
|
171
192
|
});
|
|
@@ -180,9 +180,10 @@
|
|
|
180
180
|
if (btn) btn.addEventListener('click', submit);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
183
|
+
// task 2169: the wizard no longer registers its own always-open Home card —
|
|
184
|
+
// an idle multi-step form was the page's worst length offender. It renders
|
|
185
|
+
// on demand into the Projects card (instances-panel.js) behind its
|
|
186
|
+
// "Start a project" disclosure button. Module gating is inherited: only the
|
|
187
|
+
// provisioning-gated Projects card ever calls this.
|
|
188
|
+
window.OTBNewProject = { renderInto: mount };
|
|
188
189
|
})();
|