feat: deck themes — a palette you pick, previewed by the renderer itself
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

A deck had exactly one look. The slide vocabulary is structural — bullets,
compare, table, callout, figure — and none of it carries a colour, so "make it
yellow" had nowhere to land but the image prompts, and produced yellow figures
on a blue deck.

A theme is a palette and a typeface in assets/deck-themes.json. render_pptx.py
rebinds INK, MUTED, ACCENT, RULE and PAPER from it in one place, so every slide
builder follows without a line changing in any of them — five themes restyle ten
slide types for free. An unusable theme leaves the default standing, because a
deck in the wrong colours beats a deck that will not render.

The theme rides on the deck, which is already the renderer's spec, so nothing
has to thread it through. It is validated against the same catalogue the
renderer reads: an id the renderer would ignore is never stored, so a deck
cannot claim a look it does not have.

PUT /my-resources/:id/theme re-skins a stored deck — a column write, no model
call, nothing that can reword a slide — and the next download is in the new
colours. Offered in the library only on rows that have a deck; flat markdown has
no palette.

Previews are rendered by the renderer, one representative compare slide per
theme, cached because each costs a pptx render, a Gotenberg round trip and a
rasterise. Drawn rather than mocked up: a hand-made swatch drifts the moment a
palette or a layout changes, and a preview that is not true is worse than none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-12 19:40:19 +02:00
parent bf4f895f2c
commit 59226f2109
4 changed files with 214 additions and 1 deletions

View file

@ -49,6 +49,9 @@
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
<select id="mr-theme" class="admin-control" style="max-width:320px;"></select>
<p id="mr-theme-hint" style="margin:0;font-size:12px;color:var(--g500);"></p>
<!-- A slide drawn by the renderer itself, so the preview cannot promise
a look the download does not deliver. Hidden if it cannot render. -->
<div id="mr-theme-preview" hidden style="margin-top:4px;"></div>
</div>
</div>

View file

@ -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);

View file

@ -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]
);

81
test/deck-themes.test.js Normal file
View file

@ -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;/);
});