diff --git a/src/components/dice-tray.js b/src/components/dice-tray.js
new file mode 100644
index 0000000..141ff22
--- /dev/null
+++ b/src/components/dice-tray.js
@@ -0,0 +1,57 @@
+import { rollPool } from '../dice/roller.js';
+import { getRulePack } from '../dice/rulepacks/index.js';
+
+const DICE = [4, 6, 8, 10, 12, 20, 100];
+
+export class DiceTray extends HTMLElement {
+ constructor() {
+ super();
+ this._sides = 8;
+ this._count = 3;
+ this._systemId = 'year-zero';
+ this.rng = Math.random;
+ this.last = null;
+ }
+ get sides() { return this._sides; }
+ set sides(v) { this._sides = Number(v); this.render(); }
+ get count() { return this._count; }
+ set count(v) { this._count = Number(v); }
+ set systemId(v) { this._systemId = v; }
+ get systemId() { return this._systemId; }
+
+ connectedCallback() { this.render(); }
+
+ roll() {
+ const { results, total } = rollPool([{ sides: this._sides, count: this._count }], this.rng);
+ const pack = getRulePack(this._systemId);
+ const verdict = pack ? pack.interpret(results) : null;
+ this.last = { results, total, verdict };
+ this.render();
+ this.dispatchEvent(new CustomEvent('rolled', { detail: this.last, bubbles: true }));
+ }
+
+ render() {
+ const picker = DICE.map((n) =>
+ ``).join('');
+ const felt = this.last
+ ? this.last.results.map((r) => `${r.value}`).join('')
+ : 'Tap Roll';
+ const verdict = this.last?.verdict
+ ? `${this.last.verdict.successes} success${this.last.verdict.successes === 1 ? '' : 'es'}` +
+ (this.last.verdict.ones ? ` ยท ${this.last.verdict.ones} stress` : '')
+ : '';
+
+ this.innerHTML = `
+
+
${picker}
+
${felt}
+
${verdict}
+
+
`;
+
+ this.querySelectorAll('[data-sides]').forEach((b) =>
+ b.addEventListener('click', () => { this.sides = Number(b.dataset.sides); }));
+ this.querySelector('[data-role=roll]').addEventListener('click', () => this.roll());
+ }
+}
+customElements.define('dice-tray', DiceTray);
diff --git a/tests/components/dice-tray.test.js b/tests/components/dice-tray.test.js
new file mode 100644
index 0000000..ad2c52a
--- /dev/null
+++ b/tests/components/dice-tray.test.js
@@ -0,0 +1,36 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import '../../src/components/dice-tray.js';
+
+function seq(values) { let i = 0; return () => values[i++ % values.length]; }
+
+describe('', () => {
+ let el;
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ el = document.createElement('dice-tray');
+ el.systemId = 'year-zero';
+ document.body.appendChild(el);
+ });
+
+ it('renders the polyhedral picker', () => {
+ expect(el.querySelector('[data-sides="20"]')).not.toBe(null);
+ expect(el.querySelector('[data-sides="4"]')).not.toBe(null);
+ });
+
+ it('rolls, interprets via the rule-pack, and reports successes', () => {
+ el.sides = 8; el.count = 3;
+ el.rng = seq([0.9, 0.0, 0.7]); // d8 -> 8, 1, 6 => 2 successes, 1 one
+ let detail = null;
+ el.addEventListener('rolled', (e) => { detail = e.detail; });
+ el.roll();
+ expect(el.last.verdict.successes).toBe(2);
+ expect(el.last.verdict.ones).toBe(1);
+ expect(detail.verdict.successes).toBe(2);
+ expect(el.querySelector('[data-role=verdict]').textContent).toContain('2 success');
+ });
+
+ it('changes die type when a picker button is clicked', () => {
+ el.querySelector('[data-sides="20"]').click();
+ expect(el.sides).toBe(20);
+ });
+});