Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
5864562dc2 fix: trap tutorial focus for #57
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 7s
Smoke Test / smoke (pull_request) Failing after 13s
2026-04-17 03:12:23 -04:00
Alexander Whitestone
21edc4e424 test: define tutorial focus trap for #57 2026-04-17 03:10:43 -04:00
7 changed files with 127 additions and 238 deletions

View File

@@ -262,7 +262,6 @@ The light is on. The room is empty."
<script src="js/data.js"></script> <script src="js/data.js"></script>
<script src="js/utils.js"></script> <script src="js/utils.js"></script>
<script src="js/state-export.js"></script>
<script src="js/combat.js"></script> <script src="js/combat.js"></script>
<script src="js/strategy.js"></script> <script src="js/strategy.js"></script>
<script src="js/sound.js"></script> <script src="js/sound.js"></script>

View File

@@ -230,10 +230,6 @@ function tick() {
G.lastEventAt = G.tick; G.lastEventAt = G.tick;
} }
if (typeof StateExport !== 'undefined' && StateExport && typeof StateExport.onTickBoundary === 'function') {
StateExport.onTickBoundary(G);
}
// Emergent mechanics: track resource state and check for emergent events // Emergent mechanics: track resource state and check for emergent events
if (typeof EmergentMechanics !== 'undefined' && window._emergent) { if (typeof EmergentMechanics !== 'undefined' && window._emergent) {
if (Math.floor(G.tick * 10) % 100 === 0) { // every ~10 seconds if (Math.floor(G.tick * 10) % 100 === 0) { // every ~10 seconds

View File

@@ -1,118 +0,0 @@
(function (global) {
const STORE_KEY = 'compounding-intelligence:beacon-state';
const MAX_SNAPSHOTS = 300;
function _safeNumber(value, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function _tickKey(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) return '0';
return value.toFixed(3);
}
function _resolveSink(explicitSink) {
if (explicitSink) return explicitSink;
return global.CompoundingIntelligence || null;
}
function _resolveStorage(explicitStorage) {
if (explicitStorage) return explicitStorage;
return typeof global.localStorage !== 'undefined' ? global.localStorage : null;
}
function buildSnapshot(gameState = {}) {
return {
source: 'the-beacon',
kind: 'idle_game_state',
timestamp: new Date().toISOString(),
tick: _safeNumber(gameState.tick, 0),
phase: _safeNumber(gameState.phase, 1),
trust: _safeNumber(gameState.trust, 0),
resources: {
code: _safeNumber(gameState.code, 0),
compute: _safeNumber(gameState.compute, 0),
knowledge: _safeNumber(gameState.knowledge, 0),
users: _safeNumber(gameState.users, 0),
impact: _safeNumber(gameState.impact, 0),
ops: _safeNumber(gameState.ops, 0),
},
project_progress: {
active: Array.isArray(gameState.activeProjects) ? [...gameState.activeProjects] : [],
completed: Array.isArray(gameState.completedProjects) ? [...gameState.completedProjects] : [],
active_count: Array.isArray(gameState.activeProjects) ? gameState.activeProjects.length : 0,
completed_count: Array.isArray(gameState.completedProjects) ? gameState.completedProjects.length : 0,
},
};
}
function readStore({ storage, storeKey = STORE_KEY } = {}) {
const resolved = _resolveStorage(storage);
if (!resolved) return [];
try {
const raw = resolved.getItem(storeKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
function writeStore(entries, { storage, storeKey = STORE_KEY } = {}) {
const resolved = _resolveStorage(storage);
if (!resolved) return false;
resolved.setItem(storeKey, JSON.stringify(entries));
return true;
}
function writeSnapshot(snapshot, { storage, storeKey = STORE_KEY, sink } = {}) {
const entries = readStore({ storage, storeKey });
entries.push(snapshot);
while (entries.length > MAX_SNAPSHOTS) entries.shift();
writeStore(entries, { storage, storeKey });
const resolvedSink = _resolveSink(sink);
if (resolvedSink && typeof resolvedSink.ingestSnapshot === 'function') {
resolvedSink.ingestSnapshot(snapshot);
}
if (typeof global.dispatchEvent === 'function' && typeof global.CustomEvent === 'function') {
global.dispatchEvent(new global.CustomEvent('compounding-intelligence:state-export', { detail: snapshot }));
}
return snapshot;
}
function onTickBoundary(gameState, options = {}) {
const snapshot = buildSnapshot(gameState);
const key = _tickKey(snapshot.tick);
if (onTickBoundary._lastTickKey === key) return null;
onTickBoundary._lastTickKey = key;
return writeSnapshot(snapshot, options);
}
function resetTickBoundary() {
onTickBoundary._lastTickKey = null;
}
resetTickBoundary();
const api = {
STORE_KEY,
MAX_SNAPSHOTS,
buildSnapshot,
readStore,
writeStore,
writeSnapshot,
onTickBoundary,
resetTickBoundary,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
global.StateExport = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -217,21 +217,56 @@ function nextTutorialStep() {
renderTutorialStep(_tutorialStep); renderTutorialStep(_tutorialStep);
} }
// Keyboard support: Enter/Right to advance, Escape to close function getTutorialFocusableElements(root = document) {
document.addEventListener('keydown', function tutorialKeyHandler(e) { const overlay = root && typeof root.getElementById === 'function'
if (!document.getElementById('tutorial-overlay')) return; ? root.getElementById('tutorial-overlay')
if (e.key === 'Enter' || e.key === 'ArrowRight') { : null;
if (!overlay || typeof overlay.querySelectorAll !== 'function') return [];
return Array.from(
overlay.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
).filter(el => !el.disabled && !el.hidden && el.offsetParent !== null);
}
function trapTutorialFocus(e, root = document) {
if (!e || e.key !== 'Tab') return false;
const focusable = getTutorialFocusableElements(root);
if (focusable.length < 2) return false;
const active = root.activeElement;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && (active === first || !focusable.includes(active))) {
e.preventDefault(); e.preventDefault();
if (_tutorialStep >= TUTORIAL_STEPS.length - 1) { last.focus();
closeTutorial(); return true;
} else {
nextTutorialStep();
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeTutorial();
} }
}); if (!e.shiftKey && (active === last || !focusable.includes(active))) {
e.preventDefault();
first.focus();
return true;
}
return false;
}
// Keyboard support: Enter/Right to advance, Escape to close
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('keydown', function tutorialKeyHandler(e) {
if (!document.getElementById('tutorial-overlay')) return;
if (trapTutorialFocus(e, document)) return;
if (e.key === 'Enter' || e.key === 'ArrowRight') {
e.preventDefault();
if (_tutorialStep >= TUTORIAL_STEPS.length - 1) {
closeTutorial();
} else {
nextTutorialStep();
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeTutorial();
}
});
}
function closeTutorial() { function closeTutorial() {
const overlay = document.getElementById('tutorial-overlay'); const overlay = document.getElementById('tutorial-overlay');
@@ -249,3 +284,19 @@ function startTutorial() {
// Small delay so the page renders first // Small delay so the page renders first
setTimeout(() => renderTutorialStep(0), 300); setTimeout(() => renderTutorialStep(0), 300);
} }
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
TUTORIAL_KEY,
TUTORIAL_STEPS,
isTutorialDone,
markTutorialDone,
createTutorialStyles,
renderTutorialStep,
nextTutorialStep,
getTutorialFocusableElements,
trapTutorialFocus,
closeTutorial,
startTutorial,
};
}

View File

@@ -1,17 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
test('index bootstraps state export runtime module', () => {
const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
assert.match(html, /js\/state-export\.js/);
});
test('engine tick writes state snapshots through the state export hook', () => {
const source = fs.readFileSync(path.join(ROOT, 'js', 'engine.js'), 'utf8');
assert.match(source, /StateExport/);
assert.match(source, /onTickBoundary\(G\)/);
});

View File

@@ -1,85 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
STORE_KEY,
MAX_SNAPSHOTS,
buildSnapshot,
writeSnapshot,
onTickBoundary,
} = require('../js/state-export.js');
function createStorage() {
const store = new Map();
return {
getItem: (key) => store.has(key) ? store.get(key) : null,
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
clear: () => store.clear(),
};
}
test('buildSnapshot captures resources, project progress, trust, and phase', () => {
const snapshot = buildSnapshot({
tick: 42,
phase: 3,
trust: 17,
code: 100,
compute: 200,
knowledge: 300,
users: 12,
impact: 9,
ops: 5,
activeProjects: ['p_hermes_deploy'],
completedProjects: ['p_train_small_model'],
});
assert.equal(snapshot.tick, 42);
assert.equal(snapshot.phase, 3);
assert.equal(snapshot.trust, 17);
assert.deepEqual(snapshot.resources, {
code: 100,
compute: 200,
knowledge: 300,
users: 12,
impact: 9,
ops: 5,
});
assert.deepEqual(snapshot.project_progress.active, ['p_hermes_deploy']);
assert.deepEqual(snapshot.project_progress.completed, ['p_train_small_model']);
});
test('writeSnapshot appends to the compounding-intelligence knowledge store', () => {
const storage = createStorage();
writeSnapshot({ tick: 1 }, { storage });
writeSnapshot({ tick: 2 }, { storage });
const saved = JSON.parse(storage.getItem(STORE_KEY));
assert.equal(saved.length, 2);
assert.equal(saved[1].tick, 2);
});
test('onTickBoundary writes each new tick and calls optional compounding-intelligence sink', () => {
const storage = createStorage();
const received = [];
const sink = { ingestSnapshot: (snapshot) => received.push(snapshot) };
onTickBoundary({ tick: 10, phase: 2, trust: 8 }, { storage, sink });
onTickBoundary({ tick: 10, phase: 2, trust: 8 }, { storage, sink });
onTickBoundary({ tick: 11, phase: 2, trust: 8 }, { storage, sink });
const saved = JSON.parse(storage.getItem(STORE_KEY));
assert.equal(saved.length, 2);
assert.equal(received.length, 2);
assert.equal(saved[0].tick, 10);
assert.equal(saved[1].tick, 11);
});
test('writeSnapshot enforces bounded history', () => {
const storage = createStorage();
for (let i = 0; i < MAX_SNAPSHOTS + 5; i++) {
writeSnapshot({ tick: i }, { storage });
}
const saved = JSON.parse(storage.getItem(STORE_KEY));
assert.equal(saved.length, MAX_SNAPSHOTS);
assert.equal(saved[0].tick, 5);
});

View File

@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
function createButton(id) {
return {
id,
disabled: false,
hidden: false,
offsetParent: {},
focusCalled: 0,
focus() { this.focusCalled += 1; },
};
}
test('tutorial focus trap wraps Tab from last button to first', () => {
const skip = createButton('tutorial-skip-btn');
const next = createButton('tutorial-next-btn');
const overlay = {
querySelectorAll() {
return [skip, next];
},
};
global.document = {
activeElement: next,
getElementById(id) {
return id === 'tutorial-overlay' ? overlay : null;
},
addEventListener() {},
};
const { trapTutorialFocus } = require('../js/tutorial.js');
let prevented = false;
const handled = trapTutorialFocus({ key: 'Tab', shiftKey: false, preventDefault() { prevented = true; } }, global.document);
assert.equal(handled, true);
assert.equal(prevented, true);
assert.equal(skip.focusCalled, 1);
});
test('tutorial focus trap wraps Shift+Tab from first button to last', () => {
const skip = createButton('tutorial-skip-btn');
const next = createButton('tutorial-next-btn');
const overlay = {
querySelectorAll() {
return [skip, next];
},
};
global.document = {
activeElement: skip,
getElementById(id) {
return id === 'tutorial-overlay' ? overlay : null;
},
addEventListener() {},
};
const { trapTutorialFocus } = require('../js/tutorial.js');
let prevented = false;
const handled = trapTutorialFocus({ key: 'Tab', shiftKey: true, preventDefault() { prevented = true; } }, global.document);
assert.equal(handled, true);
assert.equal(prevented, true);
assert.equal(next.focusCalled, 1);
});