diff --git a/docs/human-gates.md b/docs/human-gates.md
index 65abc70..fbbbf7a 100644
--- a/docs/human-gates.md
+++ b/docs/human-gates.md
@@ -2,6 +2,8 @@
Human Gates is an account-bound release-candidate inbox. The canonical mobile route is `#/my-work/human-gates`. Reads may use the last account-scoped browser cache, but Release/Hold decisions require a live authenticated identity and an online server round trip.
+A cold open of the canonical route confirms the account and opens Human Gates from the core browser runtime while optional Today and Planning bundles continue hydrating or recovering. The full workspace adopts that controller and fixed review snapshot without a second queue request or duplicate decision handlers.
+
## Producer intake
Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as:
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 96d2fe4..1c51474 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -3,6 +3,7 @@
await workspaceLifecycle.optionalReady;
const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();
const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();
+ const progressiveHumanGatesHandoff = window.stackchainProgressiveHumanGates?.handoff?.();
window.stackchainProgressiveMyWork?.stop();
const qs = (s, el=document) => el.querySelector(s);
const announceWork = message => qs('#my-work-action-status').textContent = message;
@@ -636,53 +637,58 @@
return payload;
}
- const humanGates = createHumanGates({
+ const humanGatesOnChange = (snapshot, state)=>{
+ queueCounts.gate = snapshot.pending_count;
+ queueCounts.gateUnavailable = state.available === false;
+ preparationItems.gate = snapshot.items;
+ mobileTaskDock.updateQueues(queueCounts);
+ if (state.authoritative) mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']});
+ mobileStartDay.render();
+ };
+ const humanGates = progressiveHumanGatesHandoff?.controller || createHumanGates({
storage:localStorage,
getLogin:()=>planningOwnerLogin,
getAccountKey:()=>planningOwnerAccountKey,
isOnline:()=>navigator.onLine,
location:window.location,
fetchJson:fetchReviewJson,
- onChange:(snapshot, state)=>{
- queueCounts.gate = snapshot.pending_count;
- queueCounts.gateUnavailable = state.available === false;
- preparationItems.gate = snapshot.items;
- mobileTaskDock.updateQueues(queueCounts);
- if (state.authoritative) mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']});
- mobileStartDay.render();
- },
+ onChange:humanGatesOnChange,
nodes:{
count:qs('#human-gates-count'), list:qs('#human-gates-list'),
status:qs('#human-gates-status'), panel:qs('#human-gates'),
detail:qs('#human-gate-detail'),
},
});
+ progressiveHumanGatesHandoff?.adoptIdentity(planningOwnerLogin, planningOwnerAccountKey);
+ humanGates.setOnChange?.(humanGatesOnChange);
const openHumanGates = () => humanGates.open().catch(error => {
qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.';
});
- qs('#open-human-gates').addEventListener('click', openHumanGates);
- qs('#close-human-gates').addEventListener('click', () => {
- qs('#human-gates').hidden = true;
- if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work');
- });
- qs('#human-gates-list').addEventListener('click', event => {
- const card = event.target.closest('[data-human-gate-id]');
- if (!card) return;
- humanGates.select(card.dataset.humanGateId);
- });
- qs('#human-gate-detail').addEventListener('click', event => {
- const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
- if (!decision) return;
- const detail = qs('#human-gate-detail');
- const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
- humanGates.decideAndNext(decision, {
- checklist,
- reason:detail.querySelector('[data-gate-reason]')?.value || '',
- override_reason:detail.querySelector('[data-gate-override]')?.value || '',
- }).catch(error => { qs('#human-gates-status').textContent = error.message; });
- });
- humanGates.load().catch(() => {});
- if (window.location.hash === '#/my-work/human-gates') openHumanGates();
+ if (!progressiveHumanGatesHandoff) {
+ qs('#open-human-gates').addEventListener('click', openHumanGates);
+ qs('#close-human-gates').addEventListener('click', () => {
+ qs('#human-gates').hidden = true;
+ if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work');
+ });
+ qs('#human-gates-list').addEventListener('click', event => {
+ const card = event.target.closest('[data-human-gate-id]');
+ if (!card) return;
+ humanGates.select(card.dataset.humanGateId);
+ });
+ qs('#human-gate-detail').addEventListener('click', event => {
+ const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
+ if (!decision) return;
+ const detail = qs('#human-gate-detail');
+ const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
+ humanGates.decideAndNext(decision, {
+ checklist,
+ reason:detail.querySelector('[data-gate-reason]')?.value || '',
+ override_reason:detail.querySelector('[data-gate-override]')?.value || '',
+ }).catch(error => { qs('#human-gates-status').textContent = error.message; });
+ });
+ }
+ if (!progressiveHumanGatesHandoff?.started) humanGates.load().catch(() => {});
+ if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates();
function syncCompletedFiledReviews() {
if (!planningOwnerLogin) return Promise.resolve(false);
diff --git a/frontend/human-gates.js b/frontend/human-gates.js
index 785b3ae..09ad515 100644
--- a/frontend/human-gates.js
+++ b/frontend/human-gates.js
@@ -13,6 +13,7 @@ function createHumanGates(options = {}) {
let loadEpoch = 0;
const decisionKeys = new Map();
let decisionFlight = null;
+ let onChange = options.onChange;
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
@@ -20,7 +21,7 @@ function createHumanGates(options = {}) {
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
const setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
- const publish = state => options.onChange?.(JSON.parse(JSON.stringify(queue)), state);
+ const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
function validSnapshot(value) {
return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null;
@@ -207,6 +208,7 @@ function createHumanGates(options = {}) {
return {
load, open, reviewNext, select, decideAndNext, current,
+ setOnChange(callback) { onChange = callback; },
restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)),
route: () => location.hash,
diff --git a/frontend/index.html b/frontend/index.html
index 02db075..2d8d05a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2412,6 +2412,7 @@
+