feat: online art Generate — configured endpoint, cached into the library

Add the art tray's online Generate: prompt → a GM-configured image-gen
endpoint → new pencil image cached into a persisted, searchable library
(offline thereafter). No endpoint or key is hardcoded — the GM pastes
their gen URL (⚙ field, persisted). Degrades gracefully: unconfigured or
offline → Generate disabled, library search fully works; network is never
load-bearing.

- src/art/generate.js: createArtGenerator (injectable fetch/navigator).
- src/art/library.js: pure addGenerated (dedupe + cap 24).
- art-tray: generate row, endpoint config, generated cache (gmd.art.*).

Tests: generate.test.js, library.test.js, art-tray-generate.test.js;
existing art-tray tests unchanged. 206 tests pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-07-20 23:48:05 +01:00
co-authored by Claude Opus 4.8
parent 7c6ee3eecc
commit 028dcb5e4e
9 changed files with 395 additions and 13 deletions
+11 -2
View File
@@ -55,7 +55,7 @@ Components are dumb renderers that emit events; the shell wires them together.
| `src/dice/roller.js` | Seedable dice core (pools, modifiers) |
| `src/dice/rulepacks/` | System interpreters (Year-Zero, CoC d100, BRP d100, VANITY d6, Panic & Glory, Dee Sanction) + registry |
| `src/npc/` | NPC generator (`generator.js`) + genre table packs (`packs/noir.js`) |
| `src/art/` | Pencil-art library search (`search.js`) + tagged manifest |
| `src/art/` | Pencil-art library search (`search.js`) + tagged manifest; online `generate.js` (configurable endpoint) + `library.js` (persisted, capped generated-art cache) |
| `src/clues/safety-net.js` | Clue safety-net engine (essential-gap + solvability) |
| `src/con/schedule.js` | Pure convention-schedule analysis (live / up-next / done / upcoming) |
| `src/components/con-hub.js` | `<con-hub>` — the convention landing screen; deep-links into a scenario |
@@ -130,7 +130,16 @@ not parsed; you write the structured markdown. The `tools/` directory is dev-onl
3. **Slice 3** — dice rule-packs (CoC d100, VANITY d6-pool, Panic & Glory, Dee Sanction) + tray pack selector. ✅
4. **Slice 4** — the clue **safety-net** (essential-clue gap tracker + fallbacks). ✅
5. **Slice 5** — the convention **hub** (all slots, live "live now / up next / done", deep-links into each scenario). ✅ All **six** Continuum 2026 slots ported and scheduled with real times.
6. **Later** — online art "Generate"; native iPad wrapper. (All design §5 tray tools — dice, NPC, art, clue-net, cast, break timer, parking-lot, wake-lock — plus the markdown → scenario-data generator are built.)
6. **Later** — native iPad wrapper. (Everything else in the design is built: all §5 tray tools — dice, NPC, art with online Generate, clue-net, cast, break timer, parking-lot, wake-lock — plus the con hub, six ported scenarios, and the markdown → scenario-data generator.)
### Online art "Generate"
The art tray can generate new pencil art at the table and cache it into the searchable library (offline
thereafter). It needs a **GM-configured** image-gen endpoint — nothing is hardcoded and no key is baked
in. Paste your endpoint URL into the tray's ⚙ field (persisted). The endpoint receives `POST { prompt }`
and must reply JSON with an image under `image` | `src` | `url` | `dataUrl` (a `data:` URL makes the
result offline-cacheable), plus optional `label` / `tags`. With no endpoint or no network the Generate
button is disabled and the library stays fully searchable — the network is never load-bearing.
## Docs
@@ -91,6 +91,12 @@ jsdom. No new dependencies. Fully offline.
/ Clues / Cast lists) — not freeform-prose scraping. `tests/tools/scenario-md.test.js` (round-trip
+ validation); `tools/` is dev-only, never bundled.
- **Online art "Generate" built.** `src/art/generate.js` (configurable endpoint, injectable fetch,
graceful offline/unconfigured degradation) + `src/art/library.js` (pure add/dedupe/cap) + art-tray
integration: prompt → configured endpoint → image cached into a persisted, searchable library
(`gmd.art.generated`, capped 24). No endpoint/key hardcoded — the GM pastes their gen URL. Tests:
`generate.test.js`, `library.test.js`, `art-tray-generate.test.js`. Network never load-bearing.
## Still deferred (backlog)
- Online art "Generate" (needs wifi + a gen backend); native iPad wrapper.
- Native iPad app wrapper (the only remaining design item; a side project).
+50
View File
@@ -0,0 +1,50 @@
// Online art generator. Calls a GM-configured image-gen endpoint (never a
// hardcoded/baked-in URL or key) and normalises the reply into a library asset.
// Degrades gracefully: unconfigured or offline → not ready, and generate()
// throws a clear message instead of half-working. Network is never load-bearing.
//
// Endpoint contract (POST JSON { prompt }): reply JSON with an image under any of
// `image` | `src` | `url` | `dataUrl` (a data: URL makes it offline-cacheable),
// plus optional `label` and `tags`.
export function createArtGenerator({ endpoint = null, fetchImpl, navigatorRef } = {}) {
const nav = navigatorRef ?? (typeof navigator !== 'undefined' ? navigator : undefined);
const doFetch = fetchImpl ?? (typeof fetch !== 'undefined' ? fetch : undefined);
const configured = !!endpoint;
function online() { return !nav || nav.onLine !== false; }
return {
configured,
online,
ready: () => configured && online() && !!doFetch,
async generate(prompt) {
const p = String(prompt ?? '').trim();
if (!configured) throw new Error('no generate endpoint configured');
if (!doFetch) throw new Error('fetch unavailable');
if (!online()) throw new Error('offline — connect to generate');
if (!p) throw new Error('enter a prompt to generate');
let res;
try {
res = await doFetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt: p }),
});
} catch {
throw new Error('could not reach the generate endpoint');
}
if (!res.ok) throw new Error(`generate failed (${res.status})`);
const data = await res.json();
const image = data.image || data.src || data.url || data.dataUrl;
if (!image) throw new Error('the endpoint returned no image');
const tags = Array.isArray(data.tags) && data.tags.length ? data.tags : promptTags(p);
return { src: image, label: data.label || p, tags };
},
};
}
export function promptTags(prompt) {
const words = String(prompt).toLowerCase().split(/\s+/).filter((w) => w.length > 2);
return [...new Set([...words, 'generated'])];
}
+29
View File
@@ -0,0 +1,29 @@
// Pure helpers for the generated-art library layer. Generated assets are cached
// alongside the static manifest and become searchable/offline. Capped to bound
// localStorage (generated images can be large).
export function slugifyLabel(s) {
return String(s).normalize('NFKD').replace(/[̀-ͯ]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
export function makeAssetId(label, takenIds) {
const taken = new Set(takenIds);
const base = slugifyLabel(label) || 'generated';
let id = base, n = 2;
while (taken.has(id)) id = `${base}-${n++}`;
return id;
}
// Returns a new `generated` array with the asset prepended (newest first),
// a unique id assigned, and the list capped.
export function addGenerated(generated, base, asset, cap = 24) {
const takenIds = [...base, ...generated].map((a) => a.id);
const entry = {
id: makeAssetId(asset.label || 'generated', takenIds),
src: asset.src,
label: asset.label || 'Generated',
tags: Array.isArray(asset.tags) ? asset.tags : [],
generated: true,
};
return [entry, ...generated].slice(0, cap);
}
+97 -10
View File
@@ -1,50 +1,137 @@
import { searchArt } from '../art/search.js';
import artManifest from '../art/manifest.js';
import { escapeHtml } from '../core/escape-html.js';
import { createStore } from '../core/store.js';
import { createArtGenerator } from '../art/generate.js';
import { addGenerated } from '../art/library.js';
export class ArtTray extends HTMLElement {
constructor() {
super();
this.manifest = artManifest;
this.manifest = artManifest; // static library (base) — settable in tests
this._query = '';
this._generated = [];
this._endpoint = null;
this._status = '';
this._showConfig = false;
this.store = null;
this.generator = null; // injectable in tests; else built from endpoint
this.fetchImpl = undefined;
this.navigatorRef = undefined;
}
connectedCallback() {
if (typeof localStorage !== 'undefined' && localStorage) {
try { this.store = createStore('art'); } catch { this.store = null; }
}
this._generated = this.store?.get('generated', []) || [];
this._endpoint = this.store?.get('endpoint', null) || null;
if (!this._endpoint) this._showConfig = true;
this.render();
}
// base manifest + generated, searched together
library() { return [...this.manifest, ...this._generated]; }
gen() {
return this.generator || createArtGenerator({
endpoint: this._endpoint,
fetchImpl: this.fetchImpl,
navigatorRef: this.navigatorRef,
});
}
connectedCallback() { this.render(); }
renderThumb(a) {
return `
<button class="thumb" data-art-id="${escapeHtml(a.id)}" title="${escapeHtml(a.label)}">
<button class="thumb${a.generated ? ' thumb--gen' : ''}" data-art-id="${escapeHtml(a.id)}" title="${escapeHtml(a.label)}">
<img src="${escapeHtml(a.src)}" alt="${escapeHtml(a.label)}" loading="lazy" />
<span class="thumb-cap">${escapeHtml(a.label)}</span>
</button>`;
}
render() {
const results = searchArt(this.manifest, this._query);
const grid = results.map((a) => this.renderThumb(a)).join('');
const g = this.gen();
const ready = g.ready();
const statusText = this._status
|| (!g.configured ? 'Set an image-gen endpoint to enable Generate'
: !g.online() ? 'Offline — library search only'
: 'Generate ready');
const config = this._showConfig ? `
<div class="art-config" data-role="config">
<input class="art-endpoint" data-role="endpoint" placeholder="image-gen endpoint URL" value="${escapeHtml(this._endpoint || '')}" />
<button class="art-endpoint-save" data-role="save-endpoint">Save</button>
</div>` : '';
this.innerHTML = `
<div class="art-tray">
<input class="art-query" data-role="art-query" placeholder="search pencil art — e.g. rain, detective" value="${escapeHtml(this._query)}" />
<div class="art-results" data-role="art-results">${grid || '<div class="art-empty">No matches</div>'}</div>
<div class="art-gen-row">
<button class="generate" data-role="generate" ${ready ? '' : 'disabled'}>✦ Generate</button>
<span class="art-gen-status" data-role="gen-status">${escapeHtml(statusText)}</span>
<button class="art-gen-cog" data-role="gen-settings" aria-label="Endpoint settings">⚙</button>
</div>
${config}
<div class="art-results" data-role="art-results">${this.gridHtml()}</div>
</div>`;
const input = this.querySelector('[data-role=art-query]');
input.addEventListener('input', () => { this._query = input.value; this.renderResults(); });
this.querySelector('[data-role=generate]').addEventListener('click', () => this.onGenerate());
this.querySelector('[data-role=gen-settings]').addEventListener('click', () => {
this._showConfig = !this._showConfig; this._status = ''; this.render();
});
const save = this.querySelector('[data-role=save-endpoint]');
if (save) save.addEventListener('click', () => this.onSaveEndpoint());
this.bindThumbs();
}
gridHtml() {
const results = searchArt(this.library(), this._query);
return results.map((a) => this.renderThumb(a)).join('') || '<div class="art-empty">No matches</div>';
}
// Re-render only the results (keeps input focus/caret stable while typing).
renderResults() {
const results = searchArt(this.manifest, this._query);
const container = this.querySelector('[data-role=art-results]');
container.innerHTML = results.map((a) => this.renderThumb(a)).join('') || '<div class="art-empty">No matches</div>';
this.querySelector('[data-role=art-results]').innerHTML = this.gridHtml();
this.bindThumbs();
}
onSaveEndpoint() {
const url = this.querySelector('[data-role=endpoint]').value.trim();
this._endpoint = url || null;
this.store?.set('endpoint', this._endpoint);
this._showConfig = !this._endpoint;
this._status = '';
this.render();
}
async onGenerate() {
const prompt = this._query.trim();
this._status = 'Generating…';
this.updateStatus();
try {
const asset = await this.gen().generate(prompt);
this._generated = addGenerated(this._generated, this.manifest, asset);
this.store?.set('generated', this._generated);
this._status = 'Added to library';
this.render();
} catch (e) {
this._status = e.message || 'Generate failed';
this.updateStatus();
}
}
updateStatus() {
const s = this.querySelector('[data-role=gen-status]');
if (s) s.textContent = this._status;
}
bindThumbs() {
const lib = this.library();
this.querySelectorAll('[data-art-id]').forEach((btn) => {
btn.addEventListener('click', () => {
const asset = this.manifest.find((a) => a.id === btn.dataset.artId);
const asset = lib.find((a) => a.id === btn.dataset.artId);
if (asset) this.dispatchEvent(new CustomEvent('show-prop', { detail: { src: asset.src, label: asset.label }, bubbles: true }));
});
});
+11
View File
@@ -147,3 +147,14 @@ parking-lot[hidden] { display: none; }
.pl-del { flex: 0 0 auto; border: 1px solid #2a3542; background: #161d27; color: #8fa1b4; border-radius: 8px; width: 30px; height: 30px; font-size: 13px; }
.pl-empty { color: #6b7a8d; font-size: 12px; }
.tray-btn.on { background: #2a2013; color: #ffd479; box-shadow: inset 0 -2px 0 #ffd479; }
/* Art generate row + endpoint config */
.art-gen-row { display: flex; align-items: center; gap: 10px; }
.art-gen-row .generate { flex: 0 0 auto; }
.art-gen-row .generate[disabled] { opacity: .45; }
.art-gen-status { flex: 1; font-size: 11px; color: #8fa1b4; }
.art-gen-cog { flex: 0 0 auto; border: 1px solid #2a3542; background: #161d27; color: #cfe0f0; border-radius: 8px; width: 34px; height: 34px; font-size: 14px; }
.art-config { display: flex; gap: 8px; }
.art-endpoint { flex: 1; background: #161d27; border: 1px solid #2a3542; border-radius: 8px; padding: 9px 11px; color: #e7eef6; font-size: 12px; }
.art-endpoint-save { border: none; padding: 9px 16px; border-radius: 8px; background: #1e3a52; color: #dcefff; font-weight: 700; font-size: 12px; }
.thumb--gen { outline: 1px solid #3f7fb0; }
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { createArtGenerator, promptTags } from '../../src/art/generate.js';
const okFetch = (payload) => async (url, opts) => ({
ok: true, status: 200,
json: async () => payload,
_url: url, _body: opts.body,
});
describe('createArtGenerator', () => {
it('is not ready without an endpoint and throws on generate', async () => {
const g = createArtGenerator({ endpoint: null, fetchImpl: okFetch({}) });
expect(g.configured).toBe(false);
expect(g.ready()).toBe(false);
await expect(g.generate('rain')).rejects.toThrow(/no generate endpoint/);
});
it('is not ready and refuses when offline', async () => {
const g = createArtGenerator({ endpoint: 'https://gen', fetchImpl: okFetch({}), navigatorRef: { onLine: false } });
expect(g.ready()).toBe(false);
await expect(g.generate('rain')).rejects.toThrow(/offline/);
});
it('posts the prompt and normalises the reply into an asset', async () => {
const fetchImpl = okFetch({ image: 'data:image/png;base64,AAAA', label: 'Rainy pier', tags: ['rain', 'pier'] });
const g = createArtGenerator({ endpoint: 'https://gen', fetchImpl, navigatorRef: { onLine: true } });
expect(g.ready()).toBe(true);
const asset = await g.generate('rain over the pier');
expect(asset).toEqual({ src: 'data:image/png;base64,AAAA', label: 'Rainy pier', tags: ['rain', 'pier'] });
});
it('derives a label and tags from the prompt when the reply omits them', async () => {
const g = createArtGenerator({ endpoint: 'https://gen', fetchImpl: okFetch({ url: 'https://img/x.png' }), navigatorRef: { onLine: true } });
const asset = await g.generate('tired detective in the rain');
expect(asset.src).toBe('https://img/x.png');
expect(asset.label).toBe('tired detective in the rain');
expect(asset.tags).toContain('generated');
expect(asset.tags).toContain('detective');
});
it('surfaces a clear error on a non-ok response', async () => {
const g = createArtGenerator({ endpoint: 'https://gen', fetchImpl: async () => ({ ok: false, status: 503 }), navigatorRef: { onLine: true } });
await expect(g.generate('x')).rejects.toThrow(/generate failed \(503\)/);
});
it('errors when the reply has no image', async () => {
const g = createArtGenerator({ endpoint: 'https://gen', fetchImpl: okFetch({ label: 'nope' }), navigatorRef: { onLine: true } });
await expect(g.generate('x')).rejects.toThrow(/no image/);
});
it('requires a non-empty prompt', async () => {
const g = createArtGenerator({ endpoint: 'https://gen', fetchImpl: okFetch({ image: 'd' }), navigatorRef: { onLine: true } });
await expect(g.generate(' ')).rejects.toThrow(/prompt/);
});
it('promptTags dedupes and drops short words', () => {
expect(promptTags('a rain rain PIER of')).toEqual(['rain', 'pier', 'generated']);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { addGenerated, makeAssetId, slugifyLabel } from '../../src/art/library.js';
const BASE = [{ id: 'rain-pier', src: '/a.png', label: 'Pier in the rain', tags: ['rain'] }];
describe('art library helpers', () => {
it('slugifies labels', () => {
expect(slugifyLabel('Tired Detective — in the Rain')).toBe('tired-detective-in-the-rain');
});
it('makes ids unique against taken ids', () => {
expect(makeAssetId('Rainy Pier', [])).toBe('rainy-pier');
expect(makeAssetId('Rainy Pier', ['rainy-pier'])).toBe('rainy-pier-2');
});
it('prepends a generated asset with a unique id and generated flag', () => {
const out = addGenerated([], BASE, { src: 'data:x', label: 'Rainy Pier', tags: ['rain', 'pier'] });
expect(out).toHaveLength(1);
expect(out[0]).toEqual({ id: 'rainy-pier', src: 'data:x', label: 'Rainy Pier', tags: ['rain', 'pier'], generated: true });
});
it('avoids colliding with a base manifest id', () => {
const out = addGenerated([], BASE, { src: 'data:y', label: 'Pier in the rain' });
expect(out[0].id).toBe('pier-in-the-rain'); // differs from base 'rain-pier', so no suffix needed
const out2 = addGenerated(out, BASE, { src: 'data:z', label: 'Pier in the rain' });
expect(out2[0].id).toBe('pier-in-the-rain-2');
});
it('caps the generated list to bound storage (newest kept)', () => {
let gen = [];
for (let i = 0; i < 30; i++) gen = addGenerated(gen, BASE, { src: 'd' + i, label: 'img ' + i }, 24);
expect(gen).toHaveLength(24);
expect(gen[0].label).toBe('img 29'); // newest first
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/art-tray.js';
const M = [{ id: 'a', src: '/art/a.png', label: 'Detective in alley', tags: ['detective'] }];
function mockGenerator() {
return {
configured: true,
online: () => true,
ready: () => true,
generate: async (prompt) => ({ src: 'data:image/png;base64,ZZ', label: prompt, tags: ['rain', 'pier'] }),
};
}
describe('<art-tray> generate', () => {
beforeEach(() => { localStorage.clear(); document.body.innerHTML = ''; });
function mount({ generator } = {}) {
const el = document.createElement('art-tray');
el.manifest = M;
if (generator) el.generator = generator;
document.body.appendChild(el);
return el;
}
it('degrades gracefully with no endpoint: Generate disabled, config shown', () => {
const el = mount();
expect(el.querySelector('[data-role=generate]').disabled).toBe(true);
expect(el.querySelector('[data-role=config]')).not.toBe(null);
expect(el.querySelector('[data-role=gen-status]').textContent).toMatch(/endpoint/i);
// search still works fully offline
expect(el.querySelectorAll('[data-art-id]').length).toBe(1);
});
it('saving an endpoint persists it and hides the config row', () => {
const el = mount();
el.querySelector('[data-role=endpoint]').value = 'https://my-gen.example/art';
el.querySelector('[data-role=save-endpoint]').click();
expect(JSON.parse(localStorage.getItem('gmd.art.endpoint'))).toBe('https://my-gen.example/art');
expect(el.querySelector('[data-role=config]')).toBe(null);
});
it('generate adds the image to the library, searchable, and persists it', async () => {
const el = mount({ generator: mockGenerator() });
expect(el.querySelector('[data-role=generate]').disabled).toBe(false);
el._query = 'rainy pier';
await el.onGenerate();
// new thumb present and flagged as generated
const ids = [...el.querySelectorAll('[data-art-id]')].map((n) => n.dataset.artId);
expect(ids).toContain('rainy-pier');
expect(el.querySelector('[data-art-id="rainy-pier"]').classList.contains('thumb--gen')).toBe(true);
expect(el.querySelector('[data-role=gen-status]').textContent).toMatch(/added/i);
// searchable by its derived tags
el._query = 'pier';
el.renderResults();
expect([...el.querySelectorAll('[data-art-id]')].map((n) => n.dataset.artId)).toContain('rainy-pier');
// persisted
expect(JSON.parse(localStorage.getItem('gmd.art.generated'))[0].id).toBe('rainy-pier');
});
it('a generated image survives a reload (offline library)', async () => {
const el = mount({ generator: mockGenerator() });
el._query = 'rainy pier';
await el.onGenerate();
document.body.innerHTML = '';
const el2 = document.createElement('art-tray');
el2.manifest = M;
document.body.appendChild(el2);
expect([...el2.querySelectorAll('[data-art-id]')].map((n) => n.dataset.artId)).toContain('rainy-pier');
});
it('shows the generate error in the status line on failure', async () => {
const failing = { configured: true, online: () => true, ready: () => true, generate: async () => { throw new Error('could not reach the generate endpoint'); } };
const el = mount({ generator: failing });
el._query = 'x';
await el.onGenerate();
expect(el.querySelector('[data-role=gen-status]').textContent).toMatch(/could not reach/);
// nothing added
expect(el.querySelectorAll('[data-art-id]').length).toBe(1);
});
it('clicking a generated thumb shows it fullscreen', async () => {
const el = mount({ generator: mockGenerator() });
el._query = 'rainy pier';
await el.onGenerate();
let detail = null;
el.addEventListener('show-prop', (e) => { detail = e.detail; });
el.querySelector('[data-art-id="rainy-pier"]').click();
expect(detail).toEqual({ src: 'data:image/png;base64,ZZ', label: 'rainy pier' });
});
});