diff --git a/src/components/npc-tray.js b/src/components/npc-tray.js
new file mode 100644
index 0000000..403716c
--- /dev/null
+++ b/src/components/npc-tray.js
@@ -0,0 +1,60 @@
+import { generateNpc } from '../npc/generator.js';
+import { getGenrePack, listGenrePacks } from '../npc/packs/index.js';
+
+export class NpcTray extends HTMLElement {
+ constructor() {
+ super();
+ this.rng = Math.random;
+ this.genreId = 'noir';
+ this.last = null;
+ this._seedValue = '';
+ }
+ connectedCallback() { this.render(); }
+
+ generate() {
+ const input = this.querySelector('[data-role=seed]');
+ this._seedValue = input ? input.value : '';
+ const pack = getGenrePack(this.genreId);
+ this.last = generateNpc(pack, this._seedValue, this.rng);
+ this.render();
+ }
+
+ render() {
+ const pills = listGenrePacks().map((p) =>
+ ``).join('');
+ const n = this.last;
+ const card = n ? `
+
+
${n.name}
+
${n.seed}
+
+ - Look
- ${n.look}
+ - Manner
- ${n.manner}
+ - Wants
- ${n.wants}
+ - Secret
- ${n.secret}
+ - Voice
- ${n.voice}
+
+
+
+
+
+
` : '';
+
+ this.innerHTML = `
+
+
${pills}
+
+
+ ${card}
+
`;
+
+ this.querySelectorAll('[data-genre]').forEach((b) =>
+ b.addEventListener('click', () => { this.genreId = b.dataset.genre; this.render(); }));
+ this.querySelector('[data-role=generate]').addEventListener('click', () => this.generate());
+ this.querySelector('[data-role=reroll]')?.addEventListener('click', () => this.generate());
+ this.querySelector('[data-role=keep]')?.addEventListener('click', () => {
+ if (this.last) this.dispatchEvent(new CustomEvent('keep-npc', { detail: this.last, bubbles: true }));
+ });
+ }
+}
+customElements.define('npc-tray', NpcTray);
diff --git a/tests/components/npc-tray.test.js b/tests/components/npc-tray.test.js
new file mode 100644
index 0000000..bcd8701
--- /dev/null
+++ b/tests/components/npc-tray.test.js
@@ -0,0 +1,40 @@
+// tests/components/npc-tray.test.js
+import { describe, it, expect, beforeEach } from 'vitest';
+import '../../src/components/npc-tray.js';
+
+function seq(values) { let i = 0; return () => values[i++ % values.length]; }
+
+describe('', () => {
+ let el;
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ el = document.createElement('npc-tray');
+ document.body.appendChild(el);
+ });
+
+ it('renders a seed input and a generate button', () => {
+ expect(el.querySelector('[data-role=seed]')).not.toBe(null);
+ expect(el.querySelector('[data-role=generate]')).not.toBe(null);
+ });
+
+ it('generates an NPC card from the seed using the noir pack', () => {
+ el.rng = seq([0.0]); // every pick -> index 0
+ el.querySelector('[data-role=seed]').value = 'dock foreman';
+ el.querySelector('[data-role=generate]').click();
+ expect(el.last.seed).toBe('dock foreman');
+ expect(el.last.genre).toMatch(/noir/i);
+ expect(el.querySelector('[data-role=card]')).not.toBe(null);
+ expect(el.querySelector('[data-role=npc-name]').textContent.length).toBeGreaterThan(0);
+ });
+
+ it('emits keep-npc with the generated npc when Keep is clicked', () => {
+ el.rng = seq([0.0]);
+ el.querySelector('[data-role=seed]').value = 'x';
+ el.querySelector('[data-role=generate]').click();
+ let detail = null;
+ el.addEventListener('keep-npc', (e) => { detail = e.detail; });
+ el.querySelector('[data-role=keep]').click();
+ expect(detail).not.toBe(null);
+ expect(detail.name).toBe(el.last.name);
+ });
+});