feat: <clue-net> essential-gap tracker component

This commit is contained in:
2026-07-20 16:47:49 +01:00
parent f89a218eae
commit 77148bf0f8
2 changed files with 109 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
// src/components/clue-net.js
import { analyzeClues } from '../clues/safety-net.js';
import { escapeHtml } from '../core/escape-html.js';
export class ClueNet extends HTMLElement {
constructor() {
super();
this._clues = [];
this._revealed = [];
}
connectedCallback() { this.render(); }
set clues(v) { this._clues = Array.isArray(v) ? v : []; this.render(); }
get clues() { return this._clues; }
set revealed(v) { this._revealed = Array.isArray(v) ? v : []; this.render(); }
get revealed() { return this._revealed; }
render() {
if (this._clues.length === 0) {
this.innerHTML = `<div class="clue-net"><div class="clue-empty" data-role="empty">No clues configured for this scenario.</div></div>`;
return;
}
const a = analyzeClues(this._clues, this._revealed);
const seen = new Set(this._revealed);
const banner = a.solvable
? 'Solvable ✓'
: `${a.missingEssential.length} essential clue${a.missingEssential.length === 1 ? '' : 's'} still needed`;
const rows = this._clues.map((c) => {
const revealed = seen.has(c.id);
const showFallback = c.essential && !revealed && c.fallback;
return `
<div class="clue-row ${revealed ? 'revealed' : ''}" data-clue-id="${escapeHtml(c.id)}">
<button class="clue-toggle" data-role="toggle">${revealed ? '●' : '○'}</button>
<div class="clue-body">
<div class="clue-label">${escapeHtml(c.label)}${c.essential ? ' <span class="clue-ess">essential</span>' : ''}${c.act ? ` <span class="clue-act">${escapeHtml(c.act)}</span>` : ''}</div>
${showFallback ? `<div class="clue-fallback">Fallback: ${escapeHtml(c.fallback)}</div>` : ''}
</div>
</div>`;
}).join('');
this.innerHTML = `
<div class="clue-net">
<div class="clue-banner ${a.solvable ? 'ok' : 'warn'}" data-role="solvable">${banner}</div>
<div class="clue-list">${rows}</div>
</div>`;
this.querySelectorAll('[data-clue-id]').forEach((row) => {
row.querySelector('[data-role=toggle]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('toggle-clue', { detail: { id: row.dataset.clueId }, bubbles: true }));
});
});
}
}
customElements.define('clue-net', ClueNet);
+54
View File
@@ -0,0 +1,54 @@
// tests/components/clue-net.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/clue-net.js';
const CLUES = [
{ id: 'c1', label: 'Door-cam still', essential: true, fallback: 'Radio names the time' },
{ id: 'c2', label: 'Colour detail', essential: false },
];
describe('<clue-net>', () => {
let el;
beforeEach(() => {
document.body.innerHTML = '';
el = document.createElement('clue-net');
document.body.appendChild(el);
});
it('shows an empty state with no clues', () => {
expect(el.querySelector('[data-role=empty]')).not.toBe(null);
});
it('renders a row per clue and a not-yet-solvable banner', () => {
el.clues = CLUES;
el.revealed = [];
expect(el.querySelectorAll('[data-clue-id]').length).toBe(2);
expect(el.querySelector('[data-role=solvable]').textContent).toContain('1 essential');
// the missing essential shows its fallback
expect(el.querySelector('[data-clue-id="c1"] .clue-fallback').textContent).toContain('Radio names the time');
});
it('flips to solvable once the essential clue is revealed', () => {
el.clues = CLUES;
el.revealed = ['c1'];
expect(el.querySelector('[data-role=solvable]').textContent).toContain('Solvable');
// revealed essential no longer shows a fallback prompt
expect(el.querySelector('[data-clue-id="c1"] .clue-fallback')).toBe(null);
});
it('emits toggle-clue when a reveal toggle is clicked', () => {
el.clues = CLUES; el.revealed = [];
let detail = null;
el.addEventListener('toggle-clue', (e) => { detail = e.detail; });
el.querySelector('[data-clue-id="c1"] [data-role=toggle]').click();
expect(detail).toEqual({ id: 'c1' });
});
it('escapes author text', () => {
el.clues = [{ id: 'x', label: 'evil" <b>', essential: true, fallback: 'fb" <i>' }];
el.revealed = [];
const row = el.querySelector('[data-clue-id="x"]');
expect(row.innerHTML).not.toContain('<b>');
expect(row.querySelector('.clue-fallback').innerHTML).not.toContain('<i>');
});
});