openvibers/openvibe.exchange Commits
Exchange: theme listings carry their preview palette (#30)
Changes
@@@ -2,6 +2,12 @@What changed in OpenVibe.Exchange, newest first.++## Unreleased++++- **Theme palettes.** A Design theme's listing keeps the preview palette from Design's public API (background,++ surface, text, accent and up to two more), in a new `palette` array on the API; the card and the listing page draw++ it as small swatches. Only `#rrggbb` values are stored and painted; a second refresh leaves the row untouched.++## 0.1.0 — 2026-10-28The first version: trade what people make, free and swap only.@@@ -16,6 +16,9 @@ money anywhere in v1 — no price, no Billing, no Vibes, no cash — and a passp- **Listings with passports.** A listing names its kind, mode (`free` or `swap`), licence (SPDX), version andmanifest. Its passport declares exactly what it can read, write, spend and contact. `spends` is always empty in v1,enforced by the form, the API and the table's check constraint.++- **Theme colours.** A first-party theme listing keeps the preview palette Design imported: up to six `#rrggbb`++ colours (background, surface, text, accent and two more), shown as small swatches on its card and page. Only a++ valid hex colour ever reaches the style attribute.- **Conformance tests for this version.** Submitting a listing runs four tests — `manifest`, `passport`, `licence`,`no_money` — and stores one row per version. Only the owner can publish a draft, and only when every test passed.- **Try before install.** A sandbox trial is a manifest and passport dry run. It records its checks and gives a@@@ -38,7 +41,7 @@ money anywhere in v1 — no price, no Billing, no Vibes, no cash — and a passp| Table | Holds | Migration ||---|---|---|--| `exchange_listings` | `exl_` + ULID, unique slug (`/listings/<slug>`), title, kind `agent\|mcp_tool\|template\|theme\|game_asset\|service`, mode `free\|swap`, summary/description/wants, SPDX licence + URL, version, public creator name, owner `usr_…`, status `draft\|listed\|hidden\|closed`, manifest jsonb, passport jsonb (spends must be `[]`), revision, listed/closed timestamps | `migrations/0001_initial.sql` |++| `exchange_listings` | `exl_` + ULID, unique slug (`/listings/<slug>`), title, kind `agent\|mcp_tool\|template\|theme\|game_asset\|service`, mode `free\|swap`, summary/description/wants, SPDX licence + URL, version, public creator name, owner `usr_…`, status `draft\|listed\|hidden\|closed`, manifest jsonb, passport jsonb (spends must be `[]`), palette jsonb (at most six `#rrggbb` preview colours), revision, listed/closed timestamps | `migrations/0001_initial.sql`, `0004_theme_palette.sql` || `exchange_listing_tests` | one conformance test row per (listing, version, name): `manifest\|passport\|licence\|no_money`, `passed\|failed`, detail, ran_at | `0001_initial.sql` || `exchange_listing_events` | every status change: from, to, reason, who, when | `0001_initial.sql` || `exchange_sandbox_trials` | `exs_` + ULID, listing, subject, version, `passed\|failed`, passport snapshot, checks jsonb, created/expires/consumed | `0001_initial.sql` |@@@ -90,7 +93,7 @@ JWKS. A write made with the session cookie must come from `https://openvibe.exch| Route | What it does ||---|---|--| `GET /listings?limit=&cursor=&mode=&kind=&q=` | public listings, interleaved by kind, newest first inside each kind; opaque keyset cursor; `Cache-Control: public, max-age=60` |++| `GET /listings?limit=&cursor=&mode=&kind=&q=` | public listings, interleaved by kind, newest first inside each kind; opaque keyset cursor; `Cache-Control: public, max-age=60`; each row carries its `palette` || `GET /listings/:slug` | one listing with passport, manifest, licence, tests and counters; a draft/hidden/closed one is 404 unless it is yours or you are staff || `GET /listings/:slug/passport` | the exact passport, manifest, licence, version and tests an installer accepts || `GET /listings/:slug/tests` | the conformance tests for the version the listing names |nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- A theme listing carries the preview palette it was imported with: at most six plain #rrggbb colours, drawn as++-- small swatches on the card and the listing page. It is public metadata, written only by the first-party refresh++-- (a person's listing has no palette); the store and the page accept a colour only when it matches ^#[0-9a-f]{6}$.++++ALTER TABLE exchange_listings ADD COLUMN palette jsonb NOT NULL DEFAULT '[]'::jsonb;++++ALTER TABLE exchange_listings ADD CONSTRAINT exchange_listings_palette++ CHECK (jsonb_typeof(palette) = 'array');@@@ -140,3 +140,7 @@ html[data-ov-motion=reduced] .ex-motif-arrows path, html[data-ov-motion=reduced].ex-first-list li { display: grid; gap: 4px; padding: 14px 16px; border-radius: 16px; border: 1px dashed color-mix(in srgb, var(--room-accent) 45%, var(--border, #2c3d5c)); }.ex-first-list span { color: var(--text-secondary, #a8b3c4); font-size: 14px; line-height: 1.45; }.ex-first-list a { justify-self: start; margin-top: 4px; font-weight: 700; color: var(--room-strong, var(--room-accent)); }++++/* A theme's preview palette: up to six small swatches, plain <i> elements; the session restyles them. */++.ex-palette { display: inline-flex; flex-wrap: wrap; gap: 4px; }++.ex-palette i { display: inline-block; width: 16px; height: 16px; border-radius: 5px; border: 1px solid var(--border, #1f2d47); }@@@ -26,6 +26,37 @@ const MAX_DESCRIPTION = 4000;const plain = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);const base = (url) => String(url || '').replace(/\/+$/, '');++/** A theme's preview palette: background, surface, text, accent, then up to two more the theme names. */++const HEX = /^#[0-9a-f]{6}$/i;++function paletteOf(theme) {++ const vars = theme && theme.variables && typeof theme.variables === 'object' && !Array.isArray(theme.variables) ? theme.variables : {};++ const preview = theme && theme.preview_colors;++ const list = Array.isArray(preview) ? preview : [];++ const named = preview && typeof preview === 'object' && !Array.isArray(preview) ? preview : {};++ const pick = (...values) => values.find((v) => typeof v === 'string' && HEX.test(v.trim()));++ const extras = Array.isArray(preview)++ ? list.slice(3)++ : Object.entries(named).filter(([key]) => !['bg', 'surface', 'text', 'accent'].includes(key)).map(([, value]) => value);++ const candidates = [++ pick(named.bg, list[0], vars['--bg-primary']),++ pick(named.surface, vars['--bg-card'], vars['--bg-secondary']),++ pick(named.text, list[2], vars['--text-primary']),++ pick(named.accent, list[1], vars['--accent']),++ ...extras.map((value) => pick(value)),++ pick(vars['--text-secondary']),++ pick(vars['--border']),++ ];++ const out = [];++ for (const candidate of candidates) {++ if (!candidate) continue;++ const colour = candidate.trim().toLowerCase();++ if (out.includes(colour)) continue;++ out.push(colour);++ if (out.length === 6) break;++ }++ return out;++}++function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, log = console, now = s.now }) {const stats = { last: null, running: false, started: false };let timer = null;@@@ -50,7 +81,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, loreturn { reads: [], writes: [], spends: [], contacts: [origin] };}-- function record({ source, sourceId, creator, body, homeUrl, runtime }) {++ function record({ source, sourceId, creator, body, homeUrl, runtime, palette = [] }) {const title = plain(body.title, MAX_TITLE);const summary = plain(body.summary, MAX_SUMMARY);if (!source || !sourceId || title.length < 3 || summary.length < 3 || !/^https:\/\//.test(homeUrl || '')) return null;@@@ -58,6 +89,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, losource,sourceId,creator: plain(creator, 80) || 'OpenVibe',++ palette,body: {title,kind: body.kind,@@@ -116,6 +148,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, locreator: 'OpenVibe',homeUrl: home,runtime: 'web',++ palette: paletteOf(theme),body: {title: `${name} theme`,kind: 'theme',@@@ -27,6 +27,9 @@ const LICENSE_RE = /^[A-Za-z0-9][A-Za-z0-9.+-]{1,63}$/;const SCOPE_RE = /^[a-z][a-z0-9_.:-]{1,63}$/;const CONTACT_RE = /^https:\/\/[^\s"'<>]{1,200}$/;const SECRET_VALUE_RE = /(?:secret|password|passwd|token|api[_-]?key|private[_-]?key|bearer)\s*[:=]|-----BEGIN [A-Z ]*PRIVATE KEY-----/i;++/** A theme's preview palette: at most six plain hex colours, the only form a page may paint. */++const HEX_RE = /^#[0-9a-f]{6}$/i;++const MAX_PALETTE = 6;const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });@@@ -65,6 +68,26 @@ function wireFiles(v) {.map((f) => ({ format: String(f.format).slice(0, 40), label: String(f.label || f.format).slice(0, 80), url: String(f.url).slice(0, 300) }));}++/**++ * The preview palette a listing paints: up to six #rrggbb values, in order, duplicates dropped. Anything the++ * source said that is not a plain hex colour (a name, a gradient, a url) never leaves this function.++ */++function wirePalette(v) {++ const list = json(v, []);++ if (!Array.isArray(list)) return [];++ const out = [];++ for (const entry of list) {++ if (typeof entry !== 'string') continue;++ const hex = entry.trim();++ if (!HEX_RE.test(hex)) continue;++ const colour = hex.toLowerCase();++ if (out.includes(colour)) continue;++ out.push(colour);++ if (out.length === MAX_PALETTE) break;++ }++ return out;++}++function wireTest(row) {if (!row) return null;return { id: Number(row.id), name: row.name, status: row.status, detail: row.detail == null ? null : row.detail, ran_at: iso(row.ran_at) };@@@ -90,6 +113,7 @@ function wireListing(row, { tests = null, includeOwner = false, summaryTests = fpassport: wirePassport(row.passport),manifest: wireManifest(row.manifest),files: wireFiles(row.files),++ palette: wirePalette(row.palette),home_url: row.home_url == null ? null : row.home_url,official: Boolean(row.official),listed_at: iso(row.listed_at),@@@ -301,7 +325,8 @@ function firstPartyChanged(row, v, creator) {|| manifest.entry !== v.manifest.entry|| (manifest.runtime || '') !== (v.manifest.runtime || '')|| JSON.stringify(passport) !== JSON.stringify(v.passport)-- || JSON.stringify(wireFiles(row.files)) !== JSON.stringify(v.files);++ || JSON.stringify(wireFiles(row.files)) !== JSON.stringify(v.files)++ || JSON.stringify(wirePalette(row.palette)) !== JSON.stringify(v.palette);}/** The four conformance tests run for every version. A listing is not public until every one passed. */@@@ -700,13 +725,13 @@ function createStore(s, { search, config }) {* can never stand in for a person; source/source_id is its idempotency key. A person's row never enters this* path, and a staff-hidden row is left hidden rather than resurrected.*/-- async function upsertFirstParty({ source, sourceId, body, creator = 'OpenVibe' }, t = null) {++ async function upsertFirstParty({ source, sourceId, body, creator = 'OpenVibe', palette = [] }, t = null) {const src = cleanText(source, 80);const sid = cleanText(sourceId, 160);if (!src || !sid) return refusal(400, 'exchange.invalid', 'a first-party listing needs a source and a source id');const checked = normalizeListing(body);if (!checked.ok) return checked;-- const v = checked.value;++ const v = { ...checked.value, palette: wirePalette(palette) };const run = async (tx) => {const nowIso = new Date(s.now()).toISOString();const creatorName = cleanText(creator, 80) || 'OpenVibe';@@@ -716,11 +741,11 @@ function createStore(s, { search, config }) {const slug = await uniqueSlug(slugify(v.title), tx);row = await tx.maybe(`INSERT INTO exchange_listings (id, slug, title, kind, mode, summary, description, wants, license, license_url, home_url, files, version,-- creator, owner, status, manifest, passport, revision, official, source, source_id, created_at, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, NULL, 'draft', $15::jsonb, $16::jsonb, 1, true, $17, $18, $19::timestamptz, $19::timestamptz)++ creator, owner, status, manifest, passport, revision, official, source, source_id, created_at, updated_at, palette)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, NULL, 'draft', $15::jsonb, $16::jsonb, 1, true, $17, $18, $19::timestamptz, $19::timestamptz, $20::jsonb)RETURNING *`,[id, slug, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.home_url,-- JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), src, sid, nowIso],++ JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), src, sid, nowIso, JSON.stringify(v.palette)],);await ensureTests(tx, row.id, v);const listed = await tx.maybe(@@@ -743,10 +768,10 @@ function createStore(s, { search, config }) {license = $8, license_url = $9, home_url = $10, files = $11::jsonb, version = $12, creator = $13,manifest = $14::jsonb, passport = $15::jsonb, official = true, status = 'listed',listed_at = COALESCE(listed_at, $16::timestamptz), closed_at = NULL, closed_reason = NULL,-- revision = revision + 1, updated_at = $16::timestamptz++ revision = revision + 1, updated_at = $16::timestamptz, palette = $17::jsonbWHERE id = $1 RETURNING *`,[row.id, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.home_url,-- JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), nowIso],++ JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), nowIso, JSON.stringify(v.palette)],);await ensureTests(tx, row.id, v);await listingEvent(tx, row.id, row.status, 'listed', 'first-party refresh', 'service:exchange', nowIso);@@@ -33,6 +33,17 @@ const kindLabel = (k) => esc(KIND_LABEL[k] || k);const modeBadge = (m) => `<span class="ex-mode ex-mode-${esc(m)}">${esc(MODE_LABEL[m] || m)}</span>`;const statusBadge = (s) => `<span class="ex-status ex-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;++/** A palette a page may paint: only plain #rrggbb, at most six, and nothing else ever reaches the style attribute. */++const HEX_RE = /^#[0-9a-f]{6}$/i;++function swatches(palette, { label = 'Preview palette' } = {}) {++ const colours = (Array.isArray(palette) ? palette : [])++ .filter((colour) => typeof colour === 'string' && HEX_RE.test(colour))++ .slice(0, 6)++ .map((colour) => colour.toLowerCase());++ if (!colours.length) return '';++ return `<span class="ex-palette" role="img" aria-label="${esc(label)}">${colours.map((colour) => `<i style="background:${colour}"></i>`).join('')}</span>`;++}++function verifiedBadge(listing) {if (!listing || !listing.tests) return '';if (listing.tests.verified) return `<span class="ex-verified" title="Every conformance test for this version passed">✓ tests pass</span>`;@@@ -45,6 +56,7 @@ function card(l) {<span class="ex-listing-top">${modeBadge(l.mode)}<span class="ex-kind">${kindLabel(l.kind)}</span>${verifiedBadge(l)}</span><span class="ex-listing-title">${esc(l.title)}</span><span class="ex-listing-summary">${esc(l.summary)}</span>++${swatches(l.palette, { label: `${l.title} preview palette` })}<span class="ex-listing-meta"><span class="ex-creator">${esc(l.creator)}</span> · <span class="ex-license">${esc(l.license)}</span> · <span class="ex-version">v${esc(l.version)}</span></span></a></li>`;}@@@ -234,6 +246,7 @@ ${offerList(offers, { owner: true })}<p class="ex-kicker">${kindLabel(l.kind)} · ${modeBadge(l.mode)}${statusBadge(l.status)}${staff ? ' · <span class="ex-staff">staff view</span>' : ''}</p><h1>${esc(l.title)}</h1><p class="ex-lede">${esc(l.summary)}</p>++${swatches(l.palette, { label: `${l.title} preview palette` })}<p class="ex-byline">by <span class="ex-creator">${esc(l.creator)}</span> · version <span class="ex-version">${esc(l.version)}</span> · <span class="ex-license">${esc(l.license)}</span>${l.license_url ? ` · <a href="${esc(l.license_url)}" rel="nofollow noopener">the licence</a>` : ''}${l.official ? ' · <span class="ex-official">listed by OpenVibe</span>' : ''}</p></header>@@@ -13,9 +13,17 @@ const THEMES = {{id: 'vibe', name: 'Vibe', slug: 'vibe', mode: 'dark',description: 'The default OpenVibe theme.', is_builtin: 1,-- variables: { '--bg-primary': '#0a0f1c' }, owner_email: '[email protected]', api_key: 'should_never_be_listed',++ variables: {++ '--bg-primary': '#0a0f1c', '--bg-secondary': '#101828', '--bg-card': '#131c2e',++ '--text-primary': '#e6edf7', '--text-secondary': '#96a7c2', '--accent': '#3b82f6', '--border': '#1f2d47',++ },++ preview_colors: { bg: '#0A0F1C', accent: '#3B82F6', text: '#E6EDF7' },++ owner_email: '[email protected]', api_key: 'should_never_be_listed',++ },++ {++ id: 'paper', name: 'Paper', slug: 'paper', mode: 'light', description: 'A quiet light theme.', is_builtin: true,++ variables: { '--bg-primary': '#fffbeb', '--bg-card': '#fffdf3', '--text-primary': '#1f1f1f', '--accent': '#644ac9', '--text-secondary': 'warm grey', '--border': '#e3dab8' },},-- { id: 'paper', name: 'Paper', slug: 'paper', mode: 'light', description: 'A quiet light theme.', is_builtin: true, variables: {} },{ id: 'made-by-a-person', name: 'Mine', slug: 'mine', mode: 'dark', description: 'A community theme.', is_builtin: 0, variables: {} },],};@@@ -92,6 +100,9 @@ function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGa{ format: 'css', label: 'CSS variables', url: 'https://openvibe.design/api/v1/tokens/vibe?format=css' },{ format: 'json', label: 'Design tokens', url: 'https://openvibe.design/api/v1/tokens/vibe?format=json' },], 'the theme carries its token files');++ assert.deepStrictEqual(theme.palette, ['#0a0f1c', '#131c2e', '#e6edf7', '#3b82f6', '#96a7c2', '#1f2d47'], 'six preview colours, in order');++ const paper = rows.find((r) => r.slug === 'paper-theme');++ assert.deepStrictEqual(paper.palette, ['#fffbeb', '#fffdf3', '#1f1f1f', '#644ac9', '#e3dab8'], 'only valid hex, up to six');assert.ok(!rows.some((r) => r.home_url.includes('?format=')), 'no token file is a listing of its own');assert.strictEqual(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'design.tokens'"), 0);@@@ -108,6 +119,20 @@ function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGaassert.ok(!rows.some((r) => r.home_url.includes('/mine')), 'a community theme is not relisted as first-party');});++ await check('the palette is in the API and drawn on the card and the listing page', async () => {++ const api = await t.get('/api/v1/listings?limit=50');++ const theme = api.json().listings.find((r) => r.slug === 'vibe-theme');++ assert.deepStrictEqual(theme.palette, ['#0a0f1c', '#131c2e', '#e6edf7', '#3b82f6', '#96a7c2', '#1f2d47']);++ const page = await t.get('/listings/vibe-theme');++ assert.strictEqual(page.status, 200, page.text);++ assert.match(page.text, /class="ex-palette"/);++ for (const colour of theme.palette) assert.ok(page.text.includes(`<i style="background:${colour}"></i>`), colour);++ const card = await t.get('/listings?kind=theme');++ assert.strictEqual(card.status, 200, card.text);++ assert.match(card.text, /<i style="background:#0a0f1c"><\/i>/);++ assert.match(card.text, /<i style="background:#3b82f6"><\/i>/);++ });++await check('a second pass changes nothing and creates no duplicate', async () => {const before = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings');const out = await t.ctx.firstParty.runOnce({ sources: ['design', 'obs', 'games'], fetchImpl: fetch });@@@ -222,6 +247,24 @@ function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGaassert.ok(!/owner_email/.test(dump));});++ await check('only validated hex colours reach the pages and the API', async () => {++ await t.ctx.s.db.exec('UPDATE exchange_listings SET palette = $1::jsonb WHERE id = $2', [++ JSON.stringify(['#0a0f1c', '#GGGGGG', 'javascript:alert(1)', '#AABBCC', '#123456', '#654321', '#999999', '#ABCDEF', '#000000']),++ themeId,++ ]);++ const page = await t.get('/listings/vibe-theme');++ assert.strictEqual(page.status, 200, page.text);++ assert.match(page.text, /<i style="background:#0a0f1c"><\/i>/);++ assert.match(page.text, /<i style="background:#aabbcc"><\/i>/);++ assert.ok(!page.text.includes('#GGGGGG'), 'a non-hex value is dropped');++ assert.ok(!page.text.includes('javascript:'), 'no style may carry a url or script');++ assert.ok(!page.text.includes('background:#000000'), 'a seventh colour is dropped');++ const api = (await t.get('/api/v1/listings/vibe-theme')).json();++ assert.deepStrictEqual(api.palette, ['#0a0f1c', '#aabbcc', '#123456', '#654321', '#999999', '#abcdef']);++ const cards = await t.get('/listings?kind=theme');++ assert.ok(!cards.text.includes('#GGGGGG') && !cards.text.includes('javascript:'), 'the card is safe too');++ });++await check('the listing page links to the real home and says who listed it', async () => {const paper = await t.ctx.s.db.maybe("SELECT * FROM exchange_listings WHERE slug = 'paper-theme'");assert.ok(paper, 'Paper is still listed');@@@ -23,6 +23,16 @@ const { boot, check, done } = require('./helpers/boot');for (const forbidden of ['price', 'price_vibes', 'currency', 'billing_intent_id', 'amount']) assert.ok(!cols.includes(forbidden), forbidden);assert.ok(cols.includes('passport'));assert.ok(cols.includes('manifest'));++ assert.ok(cols.includes('palette'), 'a listing carries its preview palette');++ });++++ await check('a palette must be an array', async () => {++ const db = t.ctx.s.db;++ const at = new Date().toISOString();++ const lid = `exl_${ids.ulid()}`;++ await db.exec(`INSERT INTO exchange_listings (id, slug, title, mode, summary, license, version, creator, kind, status, listed_at, manifest, passport)++ VALUES ($1, 'palette-shape', 'Palette shape', 'free', 'A summary', 'CC0-1.0', '1.0.0', 'A maker', 'theme', 'listed', $2, '{"name":"x","entry":"y"}'::jsonb, '{"reads":[],"writes":[],"spends":[],"contacts":[]}'::jsonb)`, [lid, at]);++ await assert.rejects(db.exec("UPDATE exchange_listings SET palette = '{}'::jsonb WHERE slug = 'palette-shape'"), /exchange_listings_palette/);});await check('a listing refuses an unknown kind and a spend in its passport', async () => {
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
What changed in OpenVibe.Exchange, newest first.
What changed in OpenVibe.Exchange, newest first.
+## Unreleased
+
+- **Theme palettes.** A Design theme's listing keeps the preview palette from Design's public API (background,
+ surface, text, accent and up to two more), in a new `palette` array on the API; the card and the listing page draw
+ it as small swatches. Only `#rrggbb` values are stored and painted; a second refresh leaves the row untouched.
+
## 0.1.0 — 2026-10-28
## 0.1.0 — 2026-10-28
The first version: trade what people make, free and swap only.
The first version: trade what people make, free and swap only.
@@ -16,6 +16,9 @@ money anywhere in v1 — no price, no Billing, no Vibes, no cash — and a passp
@@ -16,6 +16,9 @@ money anywhere in v1 — no price, no Billing, no Vibes, no cash — and a passp
- **Listings with passports.** A listing names its kind, mode (`free` or `swap`), licence (SPDX), version and
- **Listings with passports.** A listing names its kind, mode (`free` or `swap`), licence (SPDX), version and
manifest. Its passport declares exactly what it can read, write, spend and contact. `spends` is always empty in v1,
manifest. Its passport declares exactly what it can read, write, spend and contact. `spends` is always empty in v1,
enforced by the form, the API and the table's check constraint.
enforced by the form, the API and the table's check constraint.
+- **Theme colours.** A first-party theme listing keeps the preview palette Design imported: up to six `#rrggbb`
+ colours (background, surface, text, accent and two more), shown as small swatches on its card and page. Only a
+ valid hex colour ever reaches the style attribute.
- **Conformance tests for this version.** Submitting a listing runs four tests — `manifest`, `passport`, `licence`,
- **Conformance tests for this version.** Submitting a listing runs four tests — `manifest`, `passport`, `licence`,
`no_money` — and stores one row per version. Only the owner can publish a draft, and only when every test passed.
`no_money` — and stores one row per version. Only the owner can publish a draft, and only when every test passed.
- **Try before install.** A sandbox trial is a manifest and passport dry run. It records its checks and gives a
- **Try before install.** A sandbox trial is a manifest and passport dry run. It records its checks and gives a
@@ -38,7 +41,7 @@ money anywhere in v1 — no price, no Billing, no Vibes, no cash — and a passp
@@ -38,7 +41,7 @@ money anywhere in v1 — no price, no Billing, no Vibes, no cash — and a passp
| Table | Holds | Migration |
| Table | Holds | Migration |
|---|---|---|
|---|---|---|
-| `exchange_listings` | `exl_` + ULID, unique slug (`/listings/<slug>`), title, kind `agent\|mcp_tool\|template\|theme\|game_asset\|service`, mode `free\|swap`, summary/description/wants, SPDX licence + URL, version, public creator name, owner `usr_…`, status `draft\|listed\|hidden\|closed`, manifest jsonb, passport jsonb (spends must be `[]`), revision, listed/closed timestamps | `migrations/0001_initial.sql` |
+| `exchange_listings` | `exl_` + ULID, unique slug (`/listings/<slug>`), title, kind `agent\|mcp_tool\|template\|theme\|game_asset\|service`, mode `free\|swap`, summary/description/wants, SPDX licence + URL, version, public creator name, owner `usr_…`, status `draft\|listed\|hidden\|closed`, manifest jsonb, passport jsonb (spends must be `[]`), palette jsonb (at most six `#rrggbb` preview colours), revision, listed/closed timestamps | `migrations/0001_initial.sql`, `0004_theme_palette.sql` |
| `exchange_listing_tests` | one conformance test row per (listing, version, name): `manifest\|passport\|licence\|no_money`, `passed\|failed`, detail, ran_at | `0001_initial.sql` |
| `exchange_listing_tests` | one conformance test row per (listing, version, name): `manifest\|passport\|licence\|no_money`, `passed\|failed`, detail, ran_at | `0001_initial.sql` |
| `exchange_listing_events` | every status change: from, to, reason, who, when | `0001_initial.sql` |
| `exchange_listing_events` | every status change: from, to, reason, who, when | `0001_initial.sql` |
| `exchange_sandbox_trials` | `exs_` + ULID, listing, subject, version, `passed\|failed`, passport snapshot, checks jsonb, created/expires/consumed | `0001_initial.sql` |
| `exchange_sandbox_trials` | `exs_` + ULID, listing, subject, version, `passed\|failed`, passport snapshot, checks jsonb, created/expires/consumed | `0001_initial.sql` |
@@ -90,7 +93,7 @@ JWKS. A write made with the session cookie must come from `https://openvibe.exch
@@ -90,7 +93,7 @@ JWKS. A write made with the session cookie must come from `https://openvibe.exch
| Route | What it does |
| Route | What it does |
|---|---|
|---|---|
-| `GET /listings?limit=&cursor=&mode=&kind=&q=` | public listings, interleaved by kind, newest first inside each kind; opaque keyset cursor; `Cache-Control: public, max-age=60` |
+| `GET /listings?limit=&cursor=&mode=&kind=&q=` | public listings, interleaved by kind, newest first inside each kind; opaque keyset cursor; `Cache-Control: public, max-age=60`; each row carries its `palette` |
| `GET /listings/:slug` | one listing with passport, manifest, licence, tests and counters; a draft/hidden/closed one is 404 unless it is yours or you are staff |
| `GET /listings/:slug` | one listing with passport, manifest, licence, tests and counters; a draft/hidden/closed one is 404 unless it is yours or you are staff |
| `GET /listings/:slug/passport` | the exact passport, manifest, licence, version and tests an installer accepts |
| `GET /listings/:slug/passport` | the exact passport, manifest, licence, version and tests an installer accepts |
| `GET /listings/:slug/tests` | the conformance tests for the version the listing names |
| `GET /listings/:slug/tests` | the conformance tests for the version the listing names |
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- A theme listing carries the preview palette it was imported with: at most six plain #rrggbb colours, drawn as
+-- small swatches on the card and the listing page. It is public metadata, written only by the first-party refresh
+-- (a person's listing has no palette); the store and the page accept a colour only when it matches ^#[0-9a-f]{6}$.
+
+ALTER TABLE exchange_listings ADD COLUMN palette jsonb NOT NULL DEFAULT '[]'::jsonb;
+
+ALTER TABLE exchange_listings ADD CONSTRAINT exchange_listings_palette
+ CHECK (jsonb_typeof(palette) = 'array');
@@ -140,3 +140,7 @@ html[data-ov-motion=reduced] .ex-motif-arrows path, html[data-ov-motion=reduced]
@@ -140,3 +140,7 @@ html[data-ov-motion=reduced] .ex-motif-arrows path, html[data-ov-motion=reduced]
.ex-first-list li { display: grid; gap: 4px; padding: 14px 16px; border-radius: 16px; border: 1px dashed color-mix(in srgb, var(--room-accent) 45%, var(--border, #2c3d5c)); }
.ex-first-list li { display: grid; gap: 4px; padding: 14px 16px; border-radius: 16px; border: 1px dashed color-mix(in srgb, var(--room-accent) 45%, var(--border, #2c3d5c)); }
.ex-first-list span { color: var(--text-secondary, #a8b3c4); font-size: 14px; line-height: 1.45; }
.ex-first-list span { color: var(--text-secondary, #a8b3c4); font-size: 14px; line-height: 1.45; }
.ex-first-list a { justify-self: start; margin-top: 4px; font-weight: 700; color: var(--room-strong, var(--room-accent)); }
.ex-first-list a { justify-self: start; margin-top: 4px; font-weight: 700; color: var(--room-strong, var(--room-accent)); }
+
+/* A theme's preview palette: up to six small swatches, plain <i> elements; the session restyles them. */
+.ex-palette { display: inline-flex; flex-wrap: wrap; gap: 4px; }
+.ex-palette i { display: inline-block; width: 16px; height: 16px; border-radius: 5px; border: 1px solid var(--border, #1f2d47); }
@@ -26,6 +26,37 @@ const MAX_DESCRIPTION = 4000;
@@ -26,6 +26,37 @@ const MAX_DESCRIPTION = 4000;
const plain = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
const plain = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
const base = (url) => String(url || '').replace(/\/+$/, '');
const base = (url) => String(url || '').replace(/\/+$/, '');
+/** A theme's preview palette: background, surface, text, accent, then up to two more the theme names. */
+const HEX = /^#[0-9a-f]{6}$/i;
+function paletteOf(theme) {
+ const vars = theme && theme.variables && typeof theme.variables === 'object' && !Array.isArray(theme.variables) ? theme.variables : {};
+ const preview = theme && theme.preview_colors;
+ const list = Array.isArray(preview) ? preview : [];
+ const named = preview && typeof preview === 'object' && !Array.isArray(preview) ? preview : {};
+ const pick = (...values) => values.find((v) => typeof v === 'string' && HEX.test(v.trim()));
+ const extras = Array.isArray(preview)
+ ? list.slice(3)
+ : Object.entries(named).filter(([key]) => !['bg', 'surface', 'text', 'accent'].includes(key)).map(([, value]) => value);
+ const candidates = [
+ pick(named.bg, list[0], vars['--bg-primary']),
+ pick(named.surface, vars['--bg-card'], vars['--bg-secondary']),
+ pick(named.text, list[2], vars['--text-primary']),
+ pick(named.accent, list[1], vars['--accent']),
+ ...extras.map((value) => pick(value)),
+ pick(vars['--text-secondary']),
+ pick(vars['--border']),
+ ];
+ const out = [];
+ for (const candidate of candidates) {
+ if (!candidate) continue;
+ const colour = candidate.trim().toLowerCase();
+ if (out.includes(colour)) continue;
+ out.push(colour);
+ if (out.length === 6) break;
+ }
+ return out;
+}
+
function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, log = console, now = s.now }) {
function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, log = console, now = s.now }) {
const stats = { last: null, running: false, started: false };
const stats = { last: null, running: false, started: false };
let timer = null;
let timer = null;
@@ -50,7 +81,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, lo
@@ -50,7 +81,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, lo
return { reads: [], writes: [], spends: [], contacts: [origin] };
return { reads: [], writes: [], spends: [], contacts: [origin] };
}
}
- function record({ source, sourceId, creator, body, homeUrl, runtime }) {
+ function record({ source, sourceId, creator, body, homeUrl, runtime, palette = [] }) {
const title = plain(body.title, MAX_TITLE);
const title = plain(body.title, MAX_TITLE);
const summary = plain(body.summary, MAX_SUMMARY);
const summary = plain(body.summary, MAX_SUMMARY);
if (!source || !sourceId || title.length < 3 || summary.length < 3 || !/^https:\/\//.test(homeUrl || '')) return null;
if (!source || !sourceId || title.length < 3 || summary.length < 3 || !/^https:\/\//.test(homeUrl || '')) return null;
@@ -58,6 +89,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, lo
@@ -58,6 +89,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, lo
source,
source,
sourceId,
sourceId,
creator: plain(creator, 80) || 'OpenVibe',
creator: plain(creator, 80) || 'OpenVibe',
+ palette,
body: {
body: {
title,
title,
kind: body.kind,
kind: body.kind,
@@ -116,6 +148,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, lo
@@ -116,6 +148,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, lo
creator: 'OpenVibe',
creator: 'OpenVibe',
homeUrl: home,
homeUrl: home,
runtime: 'web',
runtime: 'web',
+ palette: paletteOf(theme),
body: {
body: {
title: `${name} theme`,
title: `${name} theme`,
kind: 'theme',
kind: 'theme',
@@ -27,6 +27,9 @@ const LICENSE_RE = /^[A-Za-z0-9][A-Za-z0-9.+-]{1,63}$/;
@@ -27,6 +27,9 @@ const LICENSE_RE = /^[A-Za-z0-9][A-Za-z0-9.+-]{1,63}$/;
const SCOPE_RE = /^[a-z][a-z0-9_.:-]{1,63}$/;
const SCOPE_RE = /^[a-z][a-z0-9_.:-]{1,63}$/;
const CONTACT_RE = /^https:\/\/[^\s"'<>]{1,200}$/;
const CONTACT_RE = /^https:\/\/[^\s"'<>]{1,200}$/;
const SECRET_VALUE_RE = /(?:secret|password|passwd|token|api[_-]?key|private[_-]?key|bearer)\s*[:=]|-----BEGIN [A-Z ]*PRIVATE KEY-----/i;
const SECRET_VALUE_RE = /(?:secret|password|passwd|token|api[_-]?key|private[_-]?key|bearer)\s*[:=]|-----BEGIN [A-Z ]*PRIVATE KEY-----/i;
+/** A theme's preview palette: at most six plain hex colours, the only form a page may paint. */
+const HEX_RE = /^#[0-9a-f]{6}$/i;
+const MAX_PALETTE = 6;
const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });
const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });
@@ -65,6 +68,26 @@ function wireFiles(v) {
@@ -65,6 +68,26 @@ function wireFiles(v) {
.map((f) => ({ format: String(f.format).slice(0, 40), label: String(f.label || f.format).slice(0, 80), url: String(f.url).slice(0, 300) }));
.map((f) => ({ format: String(f.format).slice(0, 40), label: String(f.label || f.format).slice(0, 80), url: String(f.url).slice(0, 300) }));
}
}
+/**
+ * The preview palette a listing paints: up to six #rrggbb values, in order, duplicates dropped. Anything the
+ * source said that is not a plain hex colour (a name, a gradient, a url) never leaves this function.
+ */
+function wirePalette(v) {
+ const list = json(v, []);
+ if (!Array.isArray(list)) return [];
+ const out = [];
+ for (const entry of list) {
+ if (typeof entry !== 'string') continue;
+ const hex = entry.trim();
+ if (!HEX_RE.test(hex)) continue;
+ const colour = hex.toLowerCase();
+ if (out.includes(colour)) continue;
+ out.push(colour);
+ if (out.length === MAX_PALETTE) break;
+ }
+ return out;
+}
+
function wireTest(row) {
function wireTest(row) {
if (!row) return null;
if (!row) return null;
return { id: Number(row.id), name: row.name, status: row.status, detail: row.detail == null ? null : row.detail, ran_at: iso(row.ran_at) };
return { id: Number(row.id), name: row.name, status: row.status, detail: row.detail == null ? null : row.detail, ran_at: iso(row.ran_at) };
@@ -90,6 +113,7 @@ function wireListing(row, { tests = null, includeOwner = false, summaryTests = f
@@ -90,6 +113,7 @@ function wireListing(row, { tests = null, includeOwner = false, summaryTests = f
passport: wirePassport(row.passport),
passport: wirePassport(row.passport),
manifest: wireManifest(row.manifest),
manifest: wireManifest(row.manifest),
files: wireFiles(row.files),
files: wireFiles(row.files),
+ palette: wirePalette(row.palette),
home_url: row.home_url == null ? null : row.home_url,
home_url: row.home_url == null ? null : row.home_url,
official: Boolean(row.official),
official: Boolean(row.official),
listed_at: iso(row.listed_at),
listed_at: iso(row.listed_at),
@@ -301,7 +325,8 @@ function firstPartyChanged(row, v, creator) {
@@ -301,7 +325,8 @@ function firstPartyChanged(row, v, creator) {
|| manifest.entry !== v.manifest.entry
|| manifest.entry !== v.manifest.entry
|| (manifest.runtime || '') !== (v.manifest.runtime || '')
|| (manifest.runtime || '') !== (v.manifest.runtime || '')
|| JSON.stringify(passport) !== JSON.stringify(v.passport)
|| JSON.stringify(passport) !== JSON.stringify(v.passport)
- || JSON.stringify(wireFiles(row.files)) !== JSON.stringify(v.files);
+ || JSON.stringify(wireFiles(row.files)) !== JSON.stringify(v.files)
+ || JSON.stringify(wirePalette(row.palette)) !== JSON.stringify(v.palette);
}
}
/** The four conformance tests run for every version. A listing is not public until every one passed. */
/** The four conformance tests run for every version. A listing is not public until every one passed. */
@@ -700,13 +725,13 @@ function createStore(s, { search, config }) {
@@ -700,13 +725,13 @@ function createStore(s, { search, config }) {
* can never stand in for a person; source/source_id is its idempotency key. A person's row never enters this
* can never stand in for a person; source/source_id is its idempotency key. A person's row never enters this
* path, and a staff-hidden row is left hidden rather than resurrected.
* path, and a staff-hidden row is left hidden rather than resurrected.
*/
*/
- async function upsertFirstParty({ source, sourceId, body, creator = 'OpenVibe' }, t = null) {
+ async function upsertFirstParty({ source, sourceId, body, creator = 'OpenVibe', palette = [] }, t = null) {
const src = cleanText(source, 80);
const src = cleanText(source, 80);
const sid = cleanText(sourceId, 160);
const sid = cleanText(sourceId, 160);
if (!src || !sid) return refusal(400, 'exchange.invalid', 'a first-party listing needs a source and a source id');
if (!src || !sid) return refusal(400, 'exchange.invalid', 'a first-party listing needs a source and a source id');
const checked = normalizeListing(body);
const checked = normalizeListing(body);
if (!checked.ok) return checked;
if (!checked.ok) return checked;
- const v = checked.value;
+ const v = { ...checked.value, palette: wirePalette(palette) };
const run = async (tx) => {
const run = async (tx) => {
const nowIso = new Date(s.now()).toISOString();
const nowIso = new Date(s.now()).toISOString();
const creatorName = cleanText(creator, 80) || 'OpenVibe';
const creatorName = cleanText(creator, 80) || 'OpenVibe';
@@ -716,11 +741,11 @@ function createStore(s, { search, config }) {
@@ -716,11 +741,11 @@ function createStore(s, { search, config }) {
const slug = await uniqueSlug(slugify(v.title), tx);
const slug = await uniqueSlug(slugify(v.title), tx);
row = await tx.maybe(
row = await tx.maybe(
`INSERT INTO exchange_listings (id, slug, title, kind, mode, summary, description, wants, license, license_url, home_url, files, version,
`INSERT INTO exchange_listings (id, slug, title, kind, mode, summary, description, wants, license, license_url, home_url, files, version,
- creator, owner, status, manifest, passport, revision, official, source, source_id, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, NULL, 'draft', $15::jsonb, $16::jsonb, 1, true, $17, $18, $19::timestamptz, $19::timestamptz)
+ creator, owner, status, manifest, passport, revision, official, source, source_id, created_at, updated_at, palette)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, NULL, 'draft', $15::jsonb, $16::jsonb, 1, true, $17, $18, $19::timestamptz, $19::timestamptz, $20::jsonb)
RETURNING *`,
RETURNING *`,
[id, slug, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.home_url,
[id, slug, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.home_url,
- JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), src, sid, nowIso],
+ JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), src, sid, nowIso, JSON.stringify(v.palette)],
);
);
await ensureTests(tx, row.id, v);
await ensureTests(tx, row.id, v);
const listed = await tx.maybe(
const listed = await tx.maybe(
@@ -743,10 +768,10 @@ function createStore(s, { search, config }) {
@@ -743,10 +768,10 @@ function createStore(s, { search, config }) {
license = $8, license_url = $9, home_url = $10, files = $11::jsonb, version = $12, creator = $13,
license = $8, license_url = $9, home_url = $10, files = $11::jsonb, version = $12, creator = $13,
manifest = $14::jsonb, passport = $15::jsonb, official = true, status = 'listed',
manifest = $14::jsonb, passport = $15::jsonb, official = true, status = 'listed',
listed_at = COALESCE(listed_at, $16::timestamptz), closed_at = NULL, closed_reason = NULL,
listed_at = COALESCE(listed_at, $16::timestamptz), closed_at = NULL, closed_reason = NULL,
- revision = revision + 1, updated_at = $16::timestamptz
+ revision = revision + 1, updated_at = $16::timestamptz, palette = $17::jsonb
WHERE id = $1 RETURNING *`,
WHERE id = $1 RETURNING *`,
[row.id, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.home_url,
[row.id, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.home_url,
- JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), nowIso],
+ JSON.stringify(v.files), v.version, creatorName, JSON.stringify(v.manifest), JSON.stringify(v.passport), nowIso, JSON.stringify(v.palette)],
);
);
await ensureTests(tx, row.id, v);
await ensureTests(tx, row.id, v);
await listingEvent(tx, row.id, row.status, 'listed', 'first-party refresh', 'service:exchange', nowIso);
await listingEvent(tx, row.id, row.status, 'listed', 'first-party refresh', 'service:exchange', nowIso);
@@ -33,6 +33,17 @@ const kindLabel = (k) => esc(KIND_LABEL[k] || k);
@@ -33,6 +33,17 @@ const kindLabel = (k) => esc(KIND_LABEL[k] || k);
const modeBadge = (m) => `<span class="ex-mode ex-mode-${esc(m)}">${esc(MODE_LABEL[m] || m)}</span>`;
const modeBadge = (m) => `<span class="ex-mode ex-mode-${esc(m)}">${esc(MODE_LABEL[m] || m)}</span>`;
const statusBadge = (s) => `<span class="ex-status ex-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;
const statusBadge = (s) => `<span class="ex-status ex-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;
+/** A palette a page may paint: only plain #rrggbb, at most six, and nothing else ever reaches the style attribute. */
+const HEX_RE = /^#[0-9a-f]{6}$/i;
+function swatches(palette, { label = 'Preview palette' } = {}) {
+ const colours = (Array.isArray(palette) ? palette : [])
+ .filter((colour) => typeof colour === 'string' && HEX_RE.test(colour))
+ .slice(0, 6)
+ .map((colour) => colour.toLowerCase());
+ if (!colours.length) return '';
+ return `<span class="ex-palette" role="img" aria-label="${esc(label)}">${colours.map((colour) => `<i style="background:${colour}"></i>`).join('')}</span>`;
+}
+
function verifiedBadge(listing) {
function verifiedBadge(listing) {
if (!listing || !listing.tests) return '';
if (!listing || !listing.tests) return '';
if (listing.tests.verified) return `<span class="ex-verified" title="Every conformance test for this version passed">✓ tests pass</span>`;
if (listing.tests.verified) return `<span class="ex-verified" title="Every conformance test for this version passed">✓ tests pass</span>`;
@@ -45,6 +56,7 @@ function card(l) {
@@ -45,6 +56,7 @@ function card(l) {
<span class="ex-listing-top">${modeBadge(l.mode)}<span class="ex-kind">${kindLabel(l.kind)}</span>${verifiedBadge(l)}</span>
<span class="ex-listing-top">${modeBadge(l.mode)}<span class="ex-kind">${kindLabel(l.kind)}</span>${verifiedBadge(l)}</span>
<span class="ex-listing-title">${esc(l.title)}</span>
<span class="ex-listing-title">${esc(l.title)}</span>
<span class="ex-listing-summary">${esc(l.summary)}</span>
<span class="ex-listing-summary">${esc(l.summary)}</span>
+${swatches(l.palette, { label: `${l.title} preview palette` })}
<span class="ex-listing-meta"><span class="ex-creator">${esc(l.creator)}</span> · <span class="ex-license">${esc(l.license)}</span> · <span class="ex-version">v${esc(l.version)}</span></span>
<span class="ex-listing-meta"><span class="ex-creator">${esc(l.creator)}</span> · <span class="ex-license">${esc(l.license)}</span> · <span class="ex-version">v${esc(l.version)}</span></span>
</a></li>`;
</a></li>`;
}
}
@@ -234,6 +246,7 @@ ${offerList(offers, { owner: true })}
@@ -234,6 +246,7 @@ ${offerList(offers, { owner: true })}
<p class="ex-kicker">${kindLabel(l.kind)} · ${modeBadge(l.mode)}${statusBadge(l.status)}${staff ? ' · <span class="ex-staff">staff view</span>' : ''}</p>
<p class="ex-kicker">${kindLabel(l.kind)} · ${modeBadge(l.mode)}${statusBadge(l.status)}${staff ? ' · <span class="ex-staff">staff view</span>' : ''}</p>
<h1>${esc(l.title)}</h1>
<h1>${esc(l.title)}</h1>
<p class="ex-lede">${esc(l.summary)}</p>
<p class="ex-lede">${esc(l.summary)}</p>
+${swatches(l.palette, { label: `${l.title} preview palette` })}
<p class="ex-byline">by <span class="ex-creator">${esc(l.creator)}</span> · version <span class="ex-version">${esc(l.version)}</span> · <span class="ex-license">${esc(l.license)}</span>${l.license_url ? ` · <a href="${esc(l.license_url)}" rel="nofollow noopener">the licence</a>` : ''}${l.official ? ' · <span class="ex-official">listed by OpenVibe</span>' : ''}</p>
<p class="ex-byline">by <span class="ex-creator">${esc(l.creator)}</span> · version <span class="ex-version">${esc(l.version)}</span> · <span class="ex-license">${esc(l.license)}</span>${l.license_url ? ` · <a href="${esc(l.license_url)}" rel="nofollow noopener">the licence</a>` : ''}${l.official ? ' · <span class="ex-official">listed by OpenVibe</span>' : ''}</p>
</header>
</header>
@@ -13,9 +13,17 @@ const THEMES = {
@@ -13,9 +13,17 @@ const THEMES = {
{
{
id: 'vibe', name: 'Vibe', slug: 'vibe', mode: 'dark',
id: 'vibe', name: 'Vibe', slug: 'vibe', mode: 'dark',
description: 'The default OpenVibe theme.', is_builtin: 1,
description: 'The default OpenVibe theme.', is_builtin: 1,
- variables: { '--bg-primary': '#0a0f1c' }, owner_email: '[email protected]', api_key: 'should_never_be_listed',
+ variables: {
+ '--bg-primary': '#0a0f1c', '--bg-secondary': '#101828', '--bg-card': '#131c2e',
+ '--text-primary': '#e6edf7', '--text-secondary': '#96a7c2', '--accent': '#3b82f6', '--border': '#1f2d47',
+ },
+ preview_colors: { bg: '#0A0F1C', accent: '#3B82F6', text: '#E6EDF7' },
+ owner_email: '[email protected]', api_key: 'should_never_be_listed',
+ },
+ {
+ id: 'paper', name: 'Paper', slug: 'paper', mode: 'light', description: 'A quiet light theme.', is_builtin: true,
+ variables: { '--bg-primary': '#fffbeb', '--bg-card': '#fffdf3', '--text-primary': '#1f1f1f', '--accent': '#644ac9', '--text-secondary': 'warm grey', '--border': '#e3dab8' },
},
},
- { id: 'paper', name: 'Paper', slug: 'paper', mode: 'light', description: 'A quiet light theme.', is_builtin: true, variables: {} },
{ id: 'made-by-a-person', name: 'Mine', slug: 'mine', mode: 'dark', description: 'A community theme.', is_builtin: 0, variables: {} },
{ id: 'made-by-a-person', name: 'Mine', slug: 'mine', mode: 'dark', description: 'A community theme.', is_builtin: 0, variables: {} },
],
],
};
};
@@ -92,6 +100,9 @@ function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGa
@@ -92,6 +100,9 @@ function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGa
{ format: 'css', label: 'CSS variables', url: 'https://openvibe.design/api/v1/tokens/vibe?format=css' },
{ format: 'css', label: 'CSS variables', url: 'https://openvibe.design/api/v1/tokens/vibe?format=css' },
{ format: 'json', label: 'Design tokens', url: 'https://openvibe.design/api/v1/tokens/vibe?format=json' },
{ format: 'json', label: 'Design tokens', url: 'https://openvibe.design/api/v1/tokens/vibe?format=json' },
], 'the theme carries its token files');
], 'the theme carries its token files');
+ assert.deepStrictEqual(theme.palette, ['#0a0f1c', '#131c2e', '#e6edf7', '#3b82f6', '#96a7c2', '#1f2d47'], 'six preview colours, in order');
+ const paper = rows.find((r) => r.slug === 'paper-theme');
+ assert.deepStrictEqual(paper.palette, ['#fffbeb', '#fffdf3', '#1f1f1f', '#644ac9', '#e3dab8'], 'only valid hex, up to six');
assert.ok(!rows.some((r) => r.home_url.includes('?format=')), 'no token file is a listing of its own');
assert.ok(!rows.some((r) => r.home_url.includes('?format=')), 'no token file is a listing of its own');
assert.strictEqual(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'design.tokens'"), 0);
assert.strictEqual(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_listings WHERE source = 'design.tokens'"), 0);
@@ -108,6 +119,20 @@ function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGa
@@ -108,6 +119,20 @@ function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGa
assert.ok(!rows.some((r) => r.home_url.includes('/mine')), 'a community theme is not relisted as first-party');
assert.ok(!rows.some((r) => r.home_url.includes('/mine')), 'a community theme is not relisted as first-party');
});
});
+ await check('the palette is in the API and drawn on the card and the listing page', async () => {
+ const api = await t.get('/api/v1/listings?limit=50');
+ const theme = api.json().listings.find((r) => r.slug === 'vibe-theme');
+ assert.deepStrictEqual(theme.palette, ['#0a0f1c', '#131c2e', '#e6edf7', '#3b82f6', '#96a7c2', '#1f2d47']);
+ const page = await t.get('/listings/vibe-theme');
+ assert.strictEqual(page.status, 200, page.text);
+ assert.match(page.text, /class="ex-palette"/);
+ for (const colour of theme.palette) assert.ok(page.text.includes(`<i style="background:${colour}"></i>`), colour);
+ const card = await t.get('/listings?kind=theme');
+ assert.strictEqual(card.status, 200, card.text);
+ assert.match(card.text, /<i style="background:#0a0f1c"><\/i>/);
+ assert.match(card.text, /<i style="background:#3b82f6"><\/i>/);
+ });
+
await check('a second pass changes nothing and creates no duplicate', async () => {
await check('a second pass changes nothing and creates no duplicate', async () => {
const before = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings');
const before = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings');
const out = await t.ctx.firstParty.runOnce({ sources: ['design', 'obs', 'games'], fetchImpl: fetch });
const out = await t.ctx.firstParty.runOnce({ sources: ['design', 'obs', 'games'], fetchImpl: fetch });
@@ -222,6 +247,24 @@ function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGa
@@ -222,6 +247,24 @@ function fakeFetch({ themes = THEMES, targets = TARGETS, worlds = WORLDS, failGa
assert.ok(!/owner_email/.test(dump));
assert.ok(!/owner_email/.test(dump));
});
});
+ await check('only validated hex colours reach the pages and the API', async () => {
+ await t.ctx.s.db.exec('UPDATE exchange_listings SET palette = $1::jsonb WHERE id = $2', [
+ JSON.stringify(['#0a0f1c', '#GGGGGG', 'javascript:alert(1)', '#AABBCC', '#123456', '#654321', '#999999', '#ABCDEF', '#000000']),
+ themeId,
+ ]);
+ const page = await t.get('/listings/vibe-theme');
+ assert.strictEqual(page.status, 200, page.text);
+ assert.match(page.text, /<i style="background:#0a0f1c"><\/i>/);
+ assert.match(page.text, /<i style="background:#aabbcc"><\/i>/);
+ assert.ok(!page.text.includes('#GGGGGG'), 'a non-hex value is dropped');
+ assert.ok(!page.text.includes('javascript:'), 'no style may carry a url or script');
+ assert.ok(!page.text.includes('background:#000000'), 'a seventh colour is dropped');
+ const api = (await t.get('/api/v1/listings/vibe-theme')).json();
+ assert.deepStrictEqual(api.palette, ['#0a0f1c', '#aabbcc', '#123456', '#654321', '#999999', '#abcdef']);
+ const cards = await t.get('/listings?kind=theme');
+ assert.ok(!cards.text.includes('#GGGGGG') && !cards.text.includes('javascript:'), 'the card is safe too');
+ });
+
await check('the listing page links to the real home and says who listed it', async () => {
await check('the listing page links to the real home and says who listed it', async () => {
const paper = await t.ctx.s.db.maybe("SELECT * FROM exchange_listings WHERE slug = 'paper-theme'");
const paper = await t.ctx.s.db.maybe("SELECT * FROM exchange_listings WHERE slug = 'paper-theme'");
assert.ok(paper, 'Paper is still listed');
assert.ok(paper, 'Paper is still listed');
@@ -23,6 +23,16 @@ const { boot, check, done } = require('./helpers/boot');
@@ -23,6 +23,16 @@ const { boot, check, done } = require('./helpers/boot');
for (const forbidden of ['price', 'price_vibes', 'currency', 'billing_intent_id', 'amount']) assert.ok(!cols.includes(forbidden), forbidden);
for (const forbidden of ['price', 'price_vibes', 'currency', 'billing_intent_id', 'amount']) assert.ok(!cols.includes(forbidden), forbidden);
assert.ok(cols.includes('passport'));
assert.ok(cols.includes('passport'));
assert.ok(cols.includes('manifest'));
assert.ok(cols.includes('manifest'));
+ assert.ok(cols.includes('palette'), 'a listing carries its preview palette');
+ });
+
+ await check('a palette must be an array', async () => {
+ const db = t.ctx.s.db;
+ const at = new Date().toISOString();
+ const lid = `exl_${ids.ulid()}`;
+ await db.exec(`INSERT INTO exchange_listings (id, slug, title, mode, summary, license, version, creator, kind, status, listed_at, manifest, passport)
+ VALUES ($1, 'palette-shape', 'Palette shape', 'free', 'A summary', 'CC0-1.0', '1.0.0', 'A maker', 'theme', 'listed', $2, '{"name":"x","entry":"y"}'::jsonb, '{"reads":[],"writes":[],"spends":[],"contacts":[]}'::jsonb)`, [lid, at]);
+ await assert.rejects(db.exec("UPDATE exchange_listings SET palette = '{}'::jsonb WHERE slug = 'palette-shape'"), /exchange_listings_palette/);
});
});
await check('a listing refuses an unknown kind and a spend in its passport', async () => {
await check('a listing refuses an unknown kind and a spend in its passport', async () => {