From 028dcb5e4e0cd65695e8391ce8e9806683d510e5 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:48:05 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20online=20art=20Generate=20=E2=80=94=20c?= =?UTF-8?q?onfigured=20endpoint,=20cached=20into=20the=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 13 ++- .../plans/2026-07-20-director-slice5.md | 8 +- src/art/generate.js | 50 ++++++++ src/art/library.js | 29 +++++ src/components/art-tray.js | 107 ++++++++++++++++-- src/styles.css | 11 ++ tests/art/generate.test.js | 59 ++++++++++ tests/art/library.test.js | 35 ++++++ tests/components/art-tray-generate.test.js | 96 ++++++++++++++++ 9 files changed, 395 insertions(+), 13 deletions(-) create mode 100644 src/art/generate.js create mode 100644 src/art/library.js create mode 100644 tests/art/generate.test.js create mode 100644 tests/art/library.test.js create mode 100644 tests/components/art-tray-generate.test.js diff --git a/README.md b/README.md index 5bb93fc..e7f3fc5 100644 --- a/README.md +++ b/README.md @@ -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` | `` — 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 diff --git a/docs/superpowers/plans/2026-07-20-director-slice5.md b/docs/superpowers/plans/2026-07-20-director-slice5.md index db53879..8debc6d 100644 --- a/docs/superpowers/plans/2026-07-20-director-slice5.md +++ b/docs/superpowers/plans/2026-07-20-director-slice5.md @@ -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). diff --git a/src/art/generate.js b/src/art/generate.js new file mode 100644 index 0000000..43924fb --- /dev/null +++ b/src/art/generate.js @@ -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'])]; +} diff --git a/src/art/library.js b/src/art/library.js new file mode 100644 index 0000000..e5847b0 --- /dev/null +++ b/src/art/library.js @@ -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); +} diff --git a/src/components/art-tray.js b/src/components/art-tray.js index 54f96c7..e3900b8 100644 --- a/src/components/art-tray.js +++ b/src/components/art-tray.js @@ -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 ` - `; } 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 ? ` +
+ + +
` : ''; this.innerHTML = `
-
${grid || '
No matches
'}
+
+ + ${escapeHtml(statusText)} + +
+ ${config} +
${this.gridHtml()}
`; 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('') || '
No matches
'; + } + // 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('') || '
No matches
'; + 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 })); }); }); diff --git a/src/styles.css b/src/styles.css index 28a5ef2..8902c81 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/tests/art/generate.test.js b/tests/art/generate.test.js new file mode 100644 index 0000000..fdd5178 --- /dev/null +++ b/tests/art/generate.test.js @@ -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']); + }); +}); diff --git a/tests/art/library.test.js b/tests/art/library.test.js new file mode 100644 index 0000000..44aaf06 --- /dev/null +++ b/tests/art/library.test.js @@ -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 + }); +}); diff --git a/tests/components/art-tray-generate.test.js b/tests/components/art-tray-generate.test.js new file mode 100644 index 0000000..961dd2f --- /dev/null +++ b/tests/components/art-tray-generate.test.js @@ -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(' 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' }); + }); +});