diff --git a/assets/deck-themes.json b/assets/deck-themes.json
index bbd1b38d..8e0a7f80 100644
--- a/assets/deck-themes.json
+++ b/assets/deck-themes.json
@@ -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"
}
]
}
diff --git a/public/components/admin.html b/public/components/admin.html
index 93bb2e88..0005576f 100644
--- a/public/components/admin.html
+++ b/public/components/admin.html
@@ -497,10 +497,6 @@
Clinical Assistant prompts
Loading clinical prompts...
-
- Learning prompts
- Loading Learning prompts...
-
diff --git a/public/js/admin.js b/public/js/admin.js
index 39ff6f47..72b57d34 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -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');
diff --git a/public/js/admin/imageSettings.js b/public/js/admin/imageSettings.js
index 7e1b53b2..07b82de7 100644
--- a/public/js/admin/imageSettings.js
+++ b/public/js/admin/imageSettings.js
@@ -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++) {
diff --git a/scripts/render_pptx.py b/scripts/render_pptx.py
index ee7f79a8..ba12fd7a 100644
--- a/scripts/render_pptx.py
+++ b/scripts/render_pptx.py
@@ -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 = ""
diff --git a/src/utils/promptCatalog.js b/src/utils/promptCatalog.js
index 26416475..14b3bfd2 100644
--- a/src/utils/promptCatalog.js
+++ b/src/utils/promptCatalog.js
@@ -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);
diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js
index 4c0f2015..44dd0197 100644
--- a/test/frontend-prompt-env.test.js
+++ b/test/frontend-prompt-env.test.js
@@ -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');
diff --git a/test/prompt-administration.test.js b/test/prompt-administration.test.js
index 46dbab0b..39e2b497 100644
--- a/test/prompt-administration.test.js
+++ b/test/prompt-administration.test.js
@@ -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/);
});