fix: the image settings card can save again; Learning Hub is out of the admin
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 20s

"Not all of it was saved: Workflow not found" on every press of Save
availability. The card sent image settings for three workflows, and the
server has only had two since Learning Hub was removed — the DB
constraint allows clinical_assistant and my_resources and nothing else.
One rejection failed the whole save, so the two settings that were valid
looked unsaved as well.

The frontend was the only place that still believed in it. Also gone:
the learning_hub.image_behavior prompt, its Learning prompts section in
the admin — which held that one prompt and nothing else — and the
theme's card tints, which never applied.

While there: My Resources had a Model dropdown labelled "set per
request", permanently disabled and permanently empty. A control that can
never do anything reads as broken rather than as not applicable, so it
is now a sentence saying where the model is actually chosen. Its
fallbacks stay — those apply to whichever model the request picked.

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-13 01:39:15 +02:00
parent 3a40ff9b6d
commit c14fa25c3f
8 changed files with 98 additions and 47 deletions

View file

@ -1,40 +1,75 @@
{
"_comment": "A theme is a palette and a typeface, nothing more. Every slide type already draws from these five names, so adding one here restyles the whole deck with no change to any slide builder. Colours are hex without a leading #, the form python-pptx wants.",
"_comment": "A theme is a palette and a typeface, nothing more. Every slide type draws from these names, so adding one here restyles the whole deck with no change to any slide builder. tint/tint_alt are the two card fills a comparison uses, and accent_alt is the second hue: the edge of the second card and of a callout. Before these existed both were hardcoded blue and amber, so those cards stayed the same colour under every theme. Colours are hex without a leading #, the form python-pptx wants.",
"themes": [
{
"id": "clinical-blue",
"name": "Clinical Blue",
"description": "The default. Calm, high-contrast, reads well on a projector.",
"accent": "2563EB", "ink": "1F2937", "muted": "4B5563",
"rule": "E5E7EB", "paper": "FFFFFF", "font": "Calibri"
"accent": "2563EB",
"ink": "1F2937",
"muted": "4B5563",
"rule": "E5E7EB",
"paper": "FFFFFF",
"font": "Calibri",
"tint": "EFF6FF",
"tint_alt": "FEF3C7",
"accent_alt": "D97706"
},
{
"id": "teaching-amber",
"name": "Teaching Amber",
"description": "Warmer and less clinical. Good for sessions with parents or students.",
"accent": "D97706", "ink": "1C1917", "muted": "57534E",
"rule": "EDE9E4", "paper": "FFFFFF", "font": "Calibri"
"accent": "D97706",
"ink": "1C1917",
"muted": "57534E",
"rule": "EDE9E4",
"paper": "FFFFFF",
"font": "Calibri",
"tint": "FEF3C7",
"tint_alt": "EFF6FF",
"accent_alt": "2563EB"
},
{
"id": "ward-teal",
"name": "Ward Teal",
"description": "Quieter than blue, still clearly clinical.",
"accent": "0F766E", "ink": "134E4A", "muted": "4B5563",
"rule": "E3EDEB", "paper": "FFFFFF", "font": "Calibri"
"accent": "0F766E",
"ink": "134E4A",
"muted": "4B5563",
"rule": "E3EDEB",
"paper": "FFFFFF",
"font": "Calibri",
"tint": "CCFBF1",
"tint_alt": "FEF3C7",
"accent_alt": "D97706"
},
{
"id": "slate",
"name": "Slate",
"description": "Almost monochrome. Lets figures and tables carry the colour.",
"accent": "475569", "ink": "0F172A", "muted": "64748B",
"rule": "E2E8F0", "paper": "FFFFFF", "font": "Calibri"
"accent": "475569",
"ink": "0F172A",
"muted": "64748B",
"rule": "E2E8F0",
"paper": "FFFFFF",
"font": "Calibri",
"tint": "F1F5F9",
"tint_alt": "E2E8F0",
"accent_alt": "64748B"
},
{
"id": "high-contrast",
"name": "High Contrast",
"description": "For a bright room or a poor projector. Heavier ink, stronger rules.",
"accent": "B91C1C", "ink": "000000", "muted": "27272A",
"rule": "A1A1AA", "paper": "FFFFFF", "font": "Calibri"
"accent": "B91C1C",
"ink": "000000",
"muted": "27272A",
"rule": "A1A1AA",
"paper": "FFFFFF",
"font": "Calibri",
"tint": "FEE2E2",
"tint_alt": "F3F4F6",
"accent_alt": "111827"
}
]
}

View file

@ -497,10 +497,6 @@
<h4 id="clinical-prompts-heading">Clinical Assistant prompts</h4>
<div id="cms-clinical-prompts">Loading clinical prompts...</div>
</section>
<section aria-labelledby="learning-prompts-heading">
<h4 id="learning-prompts-heading">Learning prompts</h4>
<div id="cms-learning-prompts">Loading Learning prompts...</div>
</section>
</div>
</details>

View file

@ -651,8 +651,7 @@ function adminTabActive() {
promptsLoading = true;
var groups = [
['scribe', document.getElementById('cms-scribe-prompts'), ['scribe']],
['clinical', document.getElementById('cms-clinical-prompts'), ['clinical-text', 'clinical-image']],
['learning', document.getElementById('cms-learning-prompts'), ['learning-image']]
['clinical', document.getElementById('cms-clinical-prompts'), ['clinical-text', 'clinical-image']]
];
try {
var data = await promptRequest('/api/admin/config/prompts');

View file

@ -8,7 +8,6 @@ import { imageJson } from '../generatedImages.js';
const WORKFLOWS = [
{ key: 'clinical_assistant', label: 'Clinical Assistant' },
{ key: 'learning_hub', label: 'Learning Hub' },
{ key: 'my_resources', label: 'My Resources' }
];
@ -51,10 +50,22 @@ async function load() {
group.appendChild(legend);
// My Resources picks its model per request from the roster, so only the
// Learning Hub and the Assistant name a primary here.
// Only the Assistant names a primary here. My Resources takes its model
// from the request that asks for the picture, so a disabled, permanently
// empty dropdown labelled "set per request" was a control that could
// never do anything — it read as broken rather than as not applicable.
// Its fallbacks still belong here: those are what runs when the chosen
// model fails, whoever chose it.
const primary = makeSelect([saved.model], discovered);
primary.disabled = workflow.key === 'my_resources';
group.appendChild(row(workflow.key === 'my_resources' ? 'Model (set per request)' : 'Model', primary));
if (workflow.key === 'my_resources') {
const note = document.createElement('p');
note.style.cssText = 'margin:2px 0 8px;font-size:12px;color:var(--g500);';
note.textContent = 'The model is chosen per request, where the deck is generated. ' +
'The fallbacks below apply to whichever model that request picked.';
group.appendChild(note);
} else {
group.appendChild(row('Model', primary));
}
const fallbacks = [];
for (let i = 0; i < maxModels - 1; i++) {

View file

@ -63,6 +63,14 @@ MUTED = RGBColor(0x4B, 0x55, 0x63)
ACCENT = RGBColor(0x25, 0x63, 0xEB)
RULE = RGBColor(0xE5, 0xE7, 0xEB)
PAPER = RGBColor(0xFF, 0xFF, 0xFF)
# The two card fills a comparison uses, and the second hue that edges the
# second card and a callout. These were written inline at both call sites, so a
# comparison stayed blue-and-amber under every theme and only the headings
# moved. Defaults match what was hardcoded, so a deck with no theme is
# unchanged.
TINT = RGBColor(0xEF, 0xF6, 0xFF)
TINT_ALT = RGBColor(0xFE, 0xF3, 0xC7)
ACCENT_ALT = RGBColor(0xD9, 0x77, 0x06)
FONT = "Calibri"
@ -96,11 +104,16 @@ def apply_theme(theme_id):
for theme in load_themes():
if theme.get("id") != theme_id:
continue
global INK, MUTED, ACCENT, RULE, PAPER, FONT
global INK, MUTED, ACCENT, RULE, PAPER, FONT, TINT, TINT_ALT, ACCENT_ALT
try:
INK = _rgb(theme["ink"])
MUTED = _rgb(theme["muted"])
ACCENT = _rgb(theme["accent"])
# .get, not [...]: a theme written before these existed should
# still apply its other colours rather than fall back wholesale.
TINT = _rgb(theme.get("tint") or "EFF6FF")
TINT_ALT = _rgb(theme.get("tint_alt") or "FEF3C7")
ACCENT_ALT = _rgb(theme.get("accent_alt") or "D97706")
RULE = _rgb(theme["rule"])
PAPER = _rgb(theme["paper"])
except Exception:
@ -391,8 +404,8 @@ def slide_compare(prs, spec):
gutter = Emu(365760)
col_w = Emu(int((BODY_W - gutter) / 2))
columns = (spec.get("columns") or [])[:2]
tints = [RGBColor(0xEF, 0xF6, 0xFF), RGBColor(0xFE, 0xF3, 0xC7)]
edges = [ACCENT, RGBColor(0xD9, 0x77, 0x06)]
tints = [TINT, TINT_ALT]
edges = [ACCENT, ACCENT_ALT]
sizes = [_fit_size(c.get("bullets") or [], 0.44, int(BODY_H) - int(Emu(548640)))
for c in columns] or [BULLET_SIZES[0]]
size = min(sizes)
@ -424,8 +437,8 @@ def slide_callout(prs, spec):
_heading(slide, spec.get("heading") or "")
card = slide.shapes.add_shape(5, MARGIN, BODY_TOP, BODY_W, Emu(int(BODY_H * 0.62)))
card.fill.solid()
card.fill.fore_color.rgb = RGBColor(0xFE, 0xF3, 0xC7)
card.line.color.rgb = RGBColor(0xD9, 0x77, 0x06)
card.fill.fore_color.rgb = TINT_ALT
card.line.color.rgb = ACCENT_ALT
card.line.width = Pt(1.5)
card.shadow.inherit = False
card.text_frame.text = ""

View file

@ -40,10 +40,7 @@ const entries = PROMPTS.getAllPrompts().map(({ key }) => ({
usedBy: ['Clinical Assistant chat', 'Clinical Assistant streaming chat'], editable: true },
{ key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image',
purpose: 'Poster instruction appended to image input, before fixed portrait/landscape layout suffixes',
usedBy: ['Clinical Assistant image', 'Clinical Assistant image job', 'Clinical Assistant generate_image tool'], editable: true },
{ key: 'learning_hub.image_behavior', dbKey: 'learning_hub.image_behavior', family: 'learning-image',
purpose: 'Learning Hub authoring image instructions, separately versioned from Clinical Assistant',
usedBy: ['Learning Hub generate_image tool', 'Learning Hub authoring images'], editable: true }
usedBy: ['Clinical Assistant image', 'Clinical Assistant image job', 'Clinical Assistant generate_image tool'], editable: true }
]);
entries.forEach(entry => { Object.freeze(entry.usedBy); Object.freeze(entry); });
Object.freeze(entries);

View file

@ -13,7 +13,6 @@ const catalogue = [
...Array.from({ length: 29 }, (_, i) => ({ key: 'SCRIBE_' + i, dbKey: 'prompt.SCRIBE_' + i, family: 'scribe', revision: i ? 0 : 10 })),
{ key: 'clinical_assistant.system_behavior', dbKey: 'clinical_assistant.system_behavior', family: 'clinical-text', revision: 10 },
{ key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image', revision: 10 },
{ key: 'learning_hub.image_behavior', dbKey: 'learning_hub.image_behavior', family: 'learning-image', revision: 10 }
].map(p => ({ ...p, value: unsafe, purpose: unsafe, usedBy: ['Synthetic runtime operation', unsafe], editable: true }));
let moduleId = 0;
@ -54,8 +53,8 @@ async function browser(t, module, handler) {
await tick();
return { window, document: window.document, calls, toasts, context: values };
}
const sectionOf = (ui, name) => ui.document.getElementById(name === 'scribe' ? 'cms-scribe-prompts' : name === 'clinical' ? 'cms-clinical-prompts' : 'cms-learning-prompts');
const sectionForPrompt = (ui, p) => sectionOf(ui, p.family === 'scribe' ? 'scribe' : p.family === 'learning-image' ? 'learning' : 'clinical');
const sectionOf = (ui, name) => ui.document.getElementById(name === 'scribe' ? 'cms-scribe-prompts' : 'cms-clinical-prompts');
const sectionForPrompt = (ui, p) => sectionOf(ui, p.family === 'scribe' ? 'scribe' : 'clinical');
const familyOf = ui => ui.document.getElementById('cms-scribe-prompts');
const selectOf = family => family.querySelector('.prompt-select');
const draftOf = family => family.querySelector('.prompt-draft');
@ -75,10 +74,11 @@ test('three prompt sections keep Scribe, Clinical and Learning separate with ine
});
const scribe = sectionOf(ui, 'scribe');
const clinical = sectionOf(ui, 'clinical');
const learning = sectionOf(ui, 'learning');
assert.equal(selectOf(scribe).options.length, 29, 'Scribe stays as its own section');
// Two sections, not three. The Learning section held exactly one prompt —
// learning_hub.image_behavior — and that feature is gone.
assert.equal(selectOf(clinical).options.length, 2, 'Clinical TEXT and IMAGE share the Clinical section');
assert.equal(selectOf(learning).options.length, 1, 'Learning has its own section');
assert.equal(ui.document.getElementById('cms-learning-prompts'), null, 'the Learning section is gone');
assert.equal(ui.document.querySelector('script, img, [onerror]'), null);
assert.equal(selectOf(scribe).value, catalogue[0].dbKey, 'first prompt selected by default');
assert.equal(selectOf(scribe).options[0].textContent, 'SCRIBE_0');
@ -392,7 +392,6 @@ test('image-model dropdowns keep saved selections through discovery failures and
if (url === '/api/models') throw Error('chat discovery offline');
if (url === '/api/admin/image-settings') return json({ success: true, maxModels: 3, workflows: {
clinical_assistant: { model: 'saved-image', fallbacks: ['saved-backup'], budget: 32000 },
learning_hub: { model: 'saved-image', fallbacks: [], budget: 32000 },
my_resources: { model: '', fallbacks: [], budget: 32000 } } });
if (url.endsWith('/image-models/discover')) return new Promise((resolve, reject) => { pending.push({ resolve, reject }); });
});
@ -414,11 +413,14 @@ test('image-model dropdowns keep saved selections through discovery failures and
// One await per workflow, so the queue needs draining more than once.
for (let i = 0; i < 6; i++) await tick();
const puts = () => ui.calls.filter(c => c.options.method === 'PUT' && c.url.includes('/api/admin/image-settings/'));
assert.equal(puts().length, 3, 'every workflow is saved, My Resources included');
// Two, not three. A learning_hub entry used to be sent as well; the server
// answers "Workflow not found" for it, and that one rejection failed the
// whole save — the card reported "Not all of it was saved" every time.
assert.equal(puts().length, 2, 'every live workflow is saved, My Resources included');
assert.equal(puts().find(c => c.url.endsWith('learning_hub')), undefined,
'Learning Hub is gone; saving to it is what broke the card');
assert.deepEqual(puts().find(c => c.url.endsWith('clinical_assistant')).body,
{ model: 'saved-image', budget: 32000, fallbacks: ['saved-backup'] });
assert.deepEqual(puts().find(c => c.url.endsWith('learning_hub')).body,
{ model: 'saved-image', budget: 32000, fallbacks: [] });
// My Resources chooses its model per request, so the form must not send one.
assert.deepEqual(puts().find(c => c.url.endsWith('my_resources')).body,
{ budget: 32000, fallbacks: [] });
@ -480,7 +482,6 @@ test('switching prompts within each section keeps every unsaved draft in memory'
const ui = await browser(t, 'admin');
const scribe = sectionOf(ui, 'scribe');
const clinical = sectionOf(ui, 'clinical');
const learning = sectionOf(ui, 'learning');
await choosePrompt(scribe, catalogue[0].dbKey);
draftOf(scribe).value = 'Scribe draft A';
await choosePrompt(scribe, catalogue[1].dbKey);
@ -491,8 +492,6 @@ test('switching prompts within each section keeps every unsaved draft in memory'
await choosePrompt(clinical, 'clinical_assistant.image_behavior');
assert.equal(draftOf(clinical).value, unsafe);
draftOf(clinical).value = 'Image draft';
await choosePrompt(learning, 'learning_hub.image_behavior');
draftOf(learning).value = 'Learning draft';
await choosePrompt(scribe, catalogue[0].dbKey);
assert.equal(draftOf(scribe).value, 'Scribe draft A');
await choosePrompt(scribe, catalogue[1].dbKey);
@ -501,15 +500,13 @@ test('switching prompts within each section keeps every unsaved draft in memory'
assert.equal(draftOf(clinical).value, 'Text draft');
await choosePrompt(clinical, 'clinical_assistant.image_behavior');
assert.equal(draftOf(clinical).value, 'Image draft');
await choosePrompt(learning, 'learning_hub.image_behavior');
assert.equal(draftOf(learning).value, 'Learning draft');
});
test('catalogue loading failure always reaches a visible retry state, never an eternal spinner', async t => {
const ui = await browser(t, 'admin', url => {
if (url === '/api/admin/config/prompts') return json({ error: 'Catalogue offline' }, 503);
});
for (const name of ['scribe', 'clinical', 'learning']) {
for (const name of ['scribe', 'clinical']) {
const f = sectionOf(ui, name);
assert.match(f.textContent, /Catalogue offline/);
assert.ok(f.querySelector('button'), 'retry button rendered');

View file

@ -135,7 +135,8 @@ test('finite catalogue, actual admin gates, revision API, stale edit, cross-key
for (const [method, url, body] of routes) for (const user of [0, 2]) assert.equal((await app.request(method, url, body, user)).status, user ? 403 : 401);
assert.equal(svc.state.log.length, 0);
const list = (await app.request('GET', '/config/prompts')).body.prompts;
assert.equal(list.length, 32); assert.equal(list.filter(p => p.family === 'scribe').length, 29);
// 31, down from 32: learning_hub.image_behavior went with the Learning Hub.
assert.equal(list.length, 31); assert.equal(list.filter(p => p.family === 'scribe').length, 29);
assert.equal(list.filter(p => p.family === 'clinical-text').length, 1); assert.equal(list.filter(p => p.family === 'clinical-image').length, 1);
for (const prompt of list) { assert.equal(prompt.revision, 0); assert.equal(prompt.editable, true); assert.ok(prompt.purpose && prompt.usedBy.length && prompt.value); }
for (const key of ['prompt.unknown', 'prompt.loadFromDb', 'prompt.updatePrompt', 'prompt.getAllPrompts', 'prompt.getDefaultPrompt', 'prompt.__proto__', 'prompt.smtp.pass']) {
@ -305,7 +306,9 @@ test('migration owns finite append-only schema and emits reversible SQL without
}
const up = await dryRun('up'); const down = await dryRun('down');
const keys = [...up[0].matchAll(/'(prompt\.[^']+|clinical_assistant\.[^']+)'/g)].map(match => match[1]);
assert.deepEqual(keys.sort(), Array.from(services().catalog.entries.filter(entry => entry.dbKey !== 'learning_hub.image_behavior'), entry => entry.dbKey).sort());
// Every catalogue entry, with nothing filtered out: the one exception used
// to be learning_hub.image_behavior, which no longer exists.
assert.deepEqual(keys.sort(), Array.from(services().catalog.entries, entry => entry.dbKey).sort());
assert.match(up[0], /BEFORE UPDATE OR DELETE/); assert.match(up[0], /FOREIGN KEY \(prompt_key, restored_from\)/);
assert.match(down[0], /DROP TABLE prompt_revisions/);
});