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