diff --git a/public/components/my-resources.html b/public/components/my-resources.html
index 92c498ed..088f4826 100644
--- a/public/components/my-resources.html
+++ b/public/components/my-resources.html
@@ -49,6 +49,9 @@
diff --git a/public/js/myResources.js b/public/js/myResources.js
index 0d071b36..ac8d77ce 100644
--- a/public/js/myResources.js
+++ b/public/js/myResources.js
@@ -101,6 +101,7 @@
// row would leak listeners and miss anything added later.
var list = document.getElementById('mr-list');
if (list) list.addEventListener('click', onRowClick);
+ if (list) list.addEventListener('change', onRowChange);
// Filtering is local: the whole library is already in hand, so searching it
// is instant and costs no request.
@@ -150,6 +151,7 @@
var themeSelect = document.getElementById('mr-theme');
var themeRow = document.getElementById('mr-theme-row');
var themes = Array.isArray(data.themes) ? data.themes : [];
+ themeCatalogue = themes;
if (themeSelect && themes.length) {
var chosenTheme = themeSelect.value;
themeSelect.textContent = '';
@@ -165,6 +167,7 @@
}
describeTheme(themes);
themeSelect.onchange = function () { describeTheme(themes); };
+ showThemePreview();
}
if (themeRow) themeRow.hidden = themes.length < 2;
@@ -208,6 +211,22 @@
// when the tab was reopened, looking like output for a topic nobody had
// typed. It cleared on a full page refresh and only then, which is why it
// read as a leak.
+ // A rendered slide in the chosen palette. The renderer draws it, so it cannot
+ // promise a look the export does not deliver.
+ function showThemePreview() {
+ var select = document.getElementById('mr-theme');
+ var box = document.getElementById('mr-theme-preview');
+ if (!select || !box) return;
+ var img = box.querySelector('img') || document.createElement('img');
+ img.alt = 'A slide rendered in the ' + (select.options[select.selectedIndex] || {}).text + ' theme';
+ img.loading = 'lazy';
+ img.style.cssText = 'max-width:min(100%,340px);border:1px solid var(--g200);border-radius:8px;display:block;';
+ img.onerror = function () { box.hidden = true; };
+ img.onload = function () { box.hidden = false; };
+ img.src = '/api/my-resources/theme-preview/' + encodeURIComponent(select.value);
+ if (!img.parentNode) box.appendChild(img);
+ }
+
// Says what the chosen theme is for, which is the part a name cannot carry.
function describeTheme(themes) {
var select = document.getElementById('mr-theme');
@@ -215,6 +234,7 @@
if (!select || !hint) return;
var chosen = themes.filter(function (t) { return t.id === select.value; })[0];
hint.textContent = chosen ? (chosen.description || '') : '';
+ showThemePreview();
}
function clearResults() {
@@ -338,6 +358,8 @@
// Thumbnails, not originals. The server stores a 256px preview beside each
// asset, so a grid of thirty costs a few kB each instead of thirty full-size
// renders — data-image-thumb is what asks hydrateImage for the small copy.
+ // The catalogue, once, shared by the generate form and every library row.
+ var themeCatalogue = [];
var imagesLoaded = false;
var nextImagesBefore = null;
@@ -767,6 +789,25 @@
wrap.appendChild(btn);
});
+ // Re-skinning is a column write, not a regeneration: the next download
+ // renders from the same deck in different colours. Only for a row that has
+ // a deck — flat markdown has no palette to change.
+ if (row.kind !== 'article' && row.has_deck !== false && themeCatalogue.length > 1) {
+ var theme = document.createElement('select');
+ theme.className = 'btn-sm';
+ theme.dataset.theme = String(row.id);
+ theme.title = 'Theme — changes the colours of the next download';
+ theme.style.cssText = 'font-size:11px;padding:2px 4px;border:1px solid var(--g200);border-radius:6px;background:var(--white,#fff);max-width:130px;';
+ themeCatalogue.forEach(function (t) {
+ var option = document.createElement('option');
+ option.value = t.id;
+ option.textContent = t.name;
+ theme.appendChild(option);
+ });
+ theme.value = row.theme || themeCatalogue[0].id;
+ wrap.appendChild(theme);
+ }
+
var del = document.createElement('button');
del.className = 'btn-sm btn-ghost';
del.type = 'button';
@@ -781,6 +822,33 @@
return wrap;
}
+ // Delegated like the download and delete buttons, so rows re-rendered by a
+ // refresh do not need rebinding.
+ function onRowChange(event) {
+ var select = event.target.closest && event.target.closest('[data-theme]');
+ if (!select) return;
+ var previous = select.dataset.previous || '';
+ select.disabled = true;
+ fetch('/api/my-resources/' + encodeURIComponent(select.dataset.theme) + '/theme', {
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ theme: select.value })
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ if (!data.success) throw new Error(data.error || 'Could not change the theme');
+ select.dataset.previous = select.value;
+ if (typeof showToast === 'function') showToast('Theme changed — download it again to see it.', 'success');
+ })
+ .catch(function (err) {
+ // Put the control back where it was: a select showing a theme the
+ // resource does not have is worse than no feedback.
+ if (previous) select.value = previous;
+ if (typeof showToast === 'function') showToast(err.message, 'error');
+ })
+ .finally(function () { select.disabled = false; });
+ }
+
function onRowClick(event) {
var download = event.target.closest && event.target.closest('[data-download]');
if (download) return downloadResource(download.dataset.download, download.dataset.format, download);
diff --git a/src/routes/myResources.js b/src/routes/myResources.js
index 8337a573..f7417724 100644
--- a/src/routes/myResources.js
+++ b/src/routes/myResources.js
@@ -485,6 +485,66 @@ router.post('/my-resources/generate', async function (req, res) {
}
});
+// ── Theme previews ──────────────────────────────────────────
+// One representative slide per theme, rendered by the renderer itself and
+// cached. Drawn rather than mocked up: a hand-made swatch drifts the moment a
+// palette or a layout changes, and the point of a preview is to be true.
+//
+// Cached on disk because it costs a pptx render, a Gotenberg round trip and a
+// pdftoppm — far too much to repeat per page view, and the output only changes
+// when the catalogue or the renderer does.
+var THEME_PREVIEW_DIR = require('path').join(require('os').tmpdir(), 'deck-theme-previews');
+
+function previewDeck(theme) {
+ return {
+ theme: theme.id,
+ slides: [{
+ type: 'compare',
+ heading: theme.name,
+ columns: [
+ { label: 'MILD', bullets: [{ text: 'Barking cough', level: 0 }, { text: 'No stridor at rest', level: 0 }] },
+ { label: 'SEVERE', bullets: [{ text: 'Stridor at rest', level: 0 }, { text: 'Marked recession', level: 0 }] }
+ ]
+ }]
+ };
+}
+
+router.get('/my-resources/theme-preview/:id', async function (req, res) {
+ var fsp = require('fs/promises');
+ var pathMod = require('path');
+ try {
+ var id = deckSchema.themeId(req.params.id);
+ if (!id) return res.status(404).json({ error: 'No such theme' });
+
+ var cached = pathMod.join(THEME_PREVIEW_DIR, id + '.png');
+ try {
+ var hit = await fsp.readFile(cached);
+ res.setHeader('Content-Type', 'image/png');
+ res.setHeader('Cache-Control', 'private, max-age=86400');
+ return res.send(hit);
+ } catch (e) { /* not rendered yet */ }
+
+ var theme = deckSchema.themes().filter(function (t) { return t.id === id; })[0];
+ var pptx = await documentExport.renderDeck(previewDeck(theme), [], []);
+ var images = await deckReview.slideImages(pptx, documentExport.GOTENBERG,
+ documentExport.FORMATS.pptx.mime);
+ if (!images.length) return res.status(503).json({ error: 'Preview rendering unavailable' });
+
+ var png = Buffer.from(images[0].dataBase64, 'base64');
+ // Best effort: a preview that cannot be cached is still a preview.
+ try {
+ await fsp.mkdir(THEME_PREVIEW_DIR, { recursive: true });
+ await fsp.writeFile(cached, png);
+ } catch (e) {}
+ res.setHeader('Content-Type', 'image/png');
+ res.setHeader('Cache-Control', 'private, max-age=86400');
+ res.send(png);
+ } catch (err) {
+ logger.warn('[my-resources] theme preview', { theme: req.params.id, error: err.message });
+ res.status(503).json({ error: 'Preview rendering unavailable' });
+ }
+});
+
// ── The owner's library ─────────────────────────────────────
router.get('/my-resources', async function (req, res) {
try {
@@ -494,7 +554,8 @@ router.get('/my-resources', async function (req, res) {
// weaker modification path — that the owner should be able to see which
// kind they have.
'SELECT id, title, kind, topic, grounded_count, created_at, updated_at, ' +
- "(deck IS NOT NULL AND jsonb_array_length(COALESCE(deck->'slides', '[]'::jsonb)) > 0) AS has_deck " +
+ "(deck IS NOT NULL AND jsonb_array_length(COALESCE(deck->'slides', '[]'::jsonb)) > 0) AS has_deck, " +
+ "COALESCE(deck->>'theme', '') AS theme " +
'FROM user_resources WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
[req.user.id, MAX_PER_USER]
);
diff --git a/test/deck-themes.test.js b/test/deck-themes.test.js
new file mode 100644
index 00000000..3e998c04
--- /dev/null
+++ b/test/deck-themes.test.js
@@ -0,0 +1,81 @@
+// A deck's look is a theme, not a colour the model writes into a field. The
+// slide vocabulary is structural — bullets, compare, table, callout — and none
+// of it carries a colour, so this is the whole of a deck's styling.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
+const deckSchema = require('../src/utils/deckSchema');
+const catalogue = JSON.parse(read('assets/deck-themes.json')).themes;
+
+test('every theme is complete, and every colour is a real colour', () => {
+ assert.ok(catalogue.length >= 4, 'a picker of one is not a choice');
+ for (const theme of catalogue) {
+ for (const key of ['id', 'name', 'accent', 'ink', 'muted', 'rule', 'paper', 'font']) {
+ assert.ok(theme[key], theme.id + ' is missing ' + key);
+ }
+ for (const key of ['accent', 'ink', 'muted', 'rule', 'paper']) {
+ assert.match(theme[key], /^[0-9A-Fa-f]{6}$/, theme.id + '.' + key + ' is not six hex digits');
+ }
+ }
+ const ids = catalogue.map(t => t.id);
+ assert.equal(ids.length, new Set(ids).size, 'theme ids must be unique');
+});
+
+test('the app and the renderer read one catalogue, so they cannot disagree', () => {
+ const py = read('scripts/render_pptx.py');
+ assert.match(py, /assets", "deck-themes\.json/);
+ const js = read('src/utils/deckSchema.js');
+ assert.match(js, /deck-themes\.json/);
+ assert.deepEqual(deckSchema.themes().map(t => t.id), catalogue.map(t => t.id));
+});
+
+test('an unknown theme is dropped rather than stored', () => {
+ // A stored deck must never claim a theme the renderer will silently ignore.
+ const slides = [{ type: 'bullets', heading: 'x', bullets: ['a'] }];
+ assert.equal(deckSchema.normalise({ theme: 'teaching-amber', slides }).theme, 'teaching-amber');
+ assert.equal(deckSchema.normalise({ theme: 'not-a-theme', slides }).theme, '');
+ assert.equal(deckSchema.normalise({ theme: '../../etc/passwd', slides }).theme, '');
+ assert.equal(deckSchema.normalise({ slides }).theme, '');
+});
+
+test('the renderer rebinds its palette instead of hardcoding one', () => {
+ // Every builder reads these names, which is what makes a theme a rebinding
+ // rather than a change to ten slide builders.
+ const py = read('scripts/render_pptx.py');
+ assert.match(py, /def apply_theme\(theme_id\)/);
+ assert.match(py, /global INK, MUTED, ACCENT, RULE, PAPER, FONT/);
+ assert.match(py, /apply_theme\(spec\.get\("theme"\)\)/);
+ // An unusable theme must not cost the deck its render.
+ assert.match(py, /a deck rendering in the wrong colours beats a deck not rendering/);
+});
+
+test('re-skinning is a column write, never a regeneration', () => {
+ const route = read('src/routes/myResources.js');
+ const handler = route.slice(route.indexOf("router.put('/my-resources/:id/theme'"));
+ assert.match(handler.slice(0, 1400), /UPDATE user_resources SET deck = \?::jsonb/);
+ assert.doesNotMatch(handler.slice(0, 1400), /callAI/, 'no model call: nothing can reword a slide');
+ assert.match(handler.slice(0, 1400), /AND user_id = \?/, 'scoped to the owner');
+ assert.match(handler.slice(0, 1400), /has no slide layout/, 'flat markdown has no theme');
+});
+
+test('the preview is rendered by the renderer, not mocked up', () => {
+ // A hand-drawn swatch drifts the moment a palette or a layout changes.
+ const route = read('src/routes/myResources.js');
+ const preview = route.slice(route.indexOf("router.get('/my-resources/theme-preview/:id'"));
+ assert.match(preview.slice(0, 1800), /documentExport\.renderDeck\(previewDeck\(theme\)/);
+ assert.match(preview.slice(0, 1800), /deckReview\.slideImages/);
+ assert.match(preview.slice(0, 1800), /deckSchema\.themeId\(req\.params\.id\)/, 'the id is validated, not used as a path');
+ assert.match(route, /THEME_PREVIEW_DIR/, 'cached: it costs a render, a Gotenberg trip and a rasterise');
+});
+
+test('the library offers a theme only where there is a deck to re-skin', () => {
+ const ui = read('public/js/myResources.js');
+ assert.match(ui, /row\.kind !== 'article' && row\.has_deck !== false/);
+ assert.match(ui, /data-theme|dataset\.theme/);
+ // A failed change puts the control back rather than showing a theme the
+ // resource does not have.
+ assert.match(ui, /if \(previous\) select\.value = previous;/);
+});