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
81 lines
4.3 KiB
JavaScript
81 lines
4.3 KiB
JavaScript
// 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;/);
|
|
});
|