openvibers/openvibe.exchange Commits

Exchange: theme listings carry their preview palette (#30)

OP OpenVibers committed 12 days ago · 3c018af648 · 9 files, +159 −13

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 62528b1..7320539 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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-28
The first version: trade what people make, free and swap only.
ddiff --git a/README.md b/README.md
iindex c7124a7..de0d82a 100644
---- a/README.md
++++ b/README.md
@@@ -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
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.
++- **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 |
ddiff --git a/migrations/0004_theme_palette.sql b/migrations/0004_theme_palette.sql
nnew file mode 100644
iindex 0000000..f7863e1
---- /dev/null
++++ b/migrations/0004_theme_palette.sql
@@@ -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');
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex ddc0456..264f5fb 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -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); }
ddiff --git a/server/jobs/first-party.js b/server/jobs/first-party.js
iindex f4622dc..949d5aa 100644
---- a/server/jobs/first-party.js
++++ b/server/jobs/first-party.js
@@@ -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, lo
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 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, lo
source,
sourceId,
creator: plain(creator, 80) || 'OpenVibe',
++ palette,
body: {
title,
kind: body.kind,
@@@ -116,6 +148,7 @@ function createFirstParty({ config, s, entries, fetchImpl = globalThis.fetch, lo
creator: 'OpenVibe',
homeUrl: home,
runtime: 'web',
++ palette: paletteOf(theme),
body: {
title: `${name} theme`,
kind: 'theme',
ddiff --git a/server/store.js b/server/store.js
iindex 3177483..d5508a4 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -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 = f
passport: 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::jsonb
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,
-- 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);
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 2163e5f..19f32e1 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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>
ddiff --git a/test/first-party.test.js b/test/first-party.test.js
iindex e2ba4c0..769bffa 100644
---- a/test/first-party.test.js
++++ b/test/first-party.test.js
@@@ -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, failGa
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 () => {
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, failGa
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 () => {
const paper = await t.ctx.s.db.maybe("SELECT * FROM exchange_listings WHERE slug = 'paper-theme'");
assert.ok(paper, 'Paper is still listed');
ddiff --git a/test/migration.test.js b/test/migration.test.js
iindex 4e92721..487bfe1 100644
---- a/test/migration.test.js
++++ b/test/migration.test.js
@@@ -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 () => {