From 4776a194454e9207a8d7f8ae430ae33cbdf607aa Mon Sep 17 00:00:00 2001 From: Kyle Poineal Date: Fri, 26 Jun 2026 06:33:42 -0600 Subject: [PATCH 1/3] feat(gui3): remove backend preset rail; move assignment to global Presets view (#2432) Backend view no longer behaves like a model: the preset rail, visual preset picker, and model-like cell-selection affordance are removed. A backend has no preset by default; an assigned backend preset is now shown read-only on its cell only when present. Backend-preset assignment moves to the global Presets slideover, mirroring the model-assignment flow. The new 'Apply to a backend' control communicates that the binding is GLOBAL ('applies globally to all models using this backend') and that backend presets are optional runtime defaults that MERGE with model presets rather than replace them. A new 'Applied to backends' zone lists global bindings with a Detach action. Backend-preset binding stays client-local (localStorage, invariant #11). Tests: removed obsolete A51-A54/A58 (cell-select + rail notice), added A167-A173 covering rail/picker removal, axe scan, accessible backend-assignment control with global copy, keyboard operability + role=status announcement, and read-only display. Full suite: 183 passed, 7 skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/BackendManager.tsx | 155 ++----------- .../src/components/PresetManager.tsx | 186 +++++++++++++++- prototype/ui-redesign/src/styles/styles.css | 2 + prototype/ui-redesign/tests/a11y.spec.ts | 206 ++++++++++++++---- 4 files changed, 372 insertions(+), 177 deletions(-) diff --git a/prototype/ui-redesign/src/components/BackendManager.tsx b/prototype/ui-redesign/src/components/BackendManager.tsx index 21fa2a94fd..aef4ea4589 100644 --- a/prototype/ui-redesign/src/components/BackendManager.tsx +++ b/prototype/ui-redesign/src/components/BackendManager.tsx @@ -8,9 +8,7 @@ import { STARTERS, loadBackendApplied, loadUserPresets, - presetParamPreviewLines, presetSupportsCapability, - saveBackendApplied, } from '../presetStore'; import { Icon, PresetIcon } from './Icon'; import { DownloadListItem, downloadStore, isDownloadActive } from '../features/downloadManager/downloadStore'; @@ -172,11 +170,6 @@ function presetCompatibleWithBackend(preset: Preset, key: string): boolean { } -function backendLabelFromKey(key: string): string { - const [recipe, backend] = key.split(':'); - return `${RECIPE_LABELS[recipe] || recipe} · ${backend || 'auto'}`; -} - /* ── Helpers ─────────────────────────────────────────────── */ function stateBadge(state: BackendInfo['state']): { label: string; cls: string } { @@ -276,12 +269,6 @@ const BackendManager: React.FC = () => { const [toastMsg, setToastMsg] = useState(null); const [userPresets, setUserPresets] = useState(loadUserPresets); const [backendPresets, setBackendPresets] = useState>(loadBackendApplied); - const [presetRailCollapsed, setPresetRailCollapsed] = useState(false); - const [selectedBackendKey, setSelectedBackendKey] = useState(''); - const [selectedRailPresetId, setSelectedRailPresetId] = useState(DEFAULT_PRESET.id); - const [presetRailHovered, setPresetRailHovered] = useState(false); - const [hoveredRailPresetId, setHoveredRailPresetId] = useState(null); - const [presetNotice, setPresetNotice] = useState(null); const [downloadItems, setDownloadItems] = useState(() => downloadStore.snapshot()); const terminalBackendRefreshRef = useRef>(new Set()); @@ -502,50 +489,19 @@ const BackendManager: React.FC = () => { const allPresets = useMemo(() => [DEFAULT_PRESET, ...STARTERS, ...userPresets], [userPresets]); - const activePresetForBackendKey = useCallback((key: string): Preset => { - const presetId = backendPresets[key] || DEFAULT_PRESET.id; - const preset = allPresets.find(p => p.id === presetId) || DEFAULT_PRESET; - return presetCompatibleWithBackend(preset, key) ? preset : DEFAULT_PRESET; + // #2432: Backend presets are OPTIONAL global runtime-argument defaults. A backend + // has NO preset by default — that is the normal state. The Backend view only + // DISPLAYS an assigned preset read-only; *assignment* lives in the global Presets + // view. Returns null when no compatible preset is assigned, so a backend never + // looks like it owns/requires a preset. + const assignedPresetForBackendKey = useCallback((key: string): Preset | null => { + const presetId = backendPresets[key]; + if (!presetId || presetId === DEFAULT_PRESET.id) return null; + const preset = allPresets.find(p => p.id === presetId); + if (!preset) return null; + return presetCompatibleWithBackend(preset, key) ? preset : null; }, [allPresets, backendPresets]); - const selectedRailPreset = allPresets.find(p => p.id === selectedRailPresetId) || DEFAULT_PRESET; - const selectedBackendPreset = selectedBackendKey ? activePresetForBackendKey(selectedBackendKey) : null; - const railSummaryPreset = selectedBackendPreset || selectedRailPreset; - const highlightedPresetId = presetRailHovered ? (hoveredRailPresetId || railSummaryPreset.id) : null; - - const allBackendKeys = useMemo(() => { - if (!sysInfo?.recipes) return [] as string[]; - const keys: string[] = []; - for (const [recipe, recipeInfo] of Object.entries(sysInfo.recipes)) { - for (const backend of Object.keys(recipeInfo.backends)) keys.push(backendKey(recipe, backend)); - } - return keys.sort((a, b) => backendLabelFromKey(a).localeCompare(backendLabelFromKey(b))); - }, [sysInfo]); - - const backendsUsingSelectedPreset = useMemo( - () => allBackendKeys.filter(key => activePresetForBackendKey(key).id === railSummaryPreset.id), - [allBackendKeys, activePresetForBackendKey, railSummaryPreset.id], - ); - - const handlePresetRailPick = useCallback((preset: Preset) => { - setSelectedRailPresetId(preset.id); - if (!selectedBackendKey) return; - if (!presetCompatibleWithBackend(preset, selectedBackendKey)) { - setPresetNotice(`“${preset.name}” does not apply to ${backendLabelFromKey(selectedBackendKey)}.`); - window.setTimeout(() => setPresetNotice(null), 2800); - return; - } - setBackendPresets(prev => { - const next = { ...prev }; - if (preset.id === DEFAULT_PRESET.id) delete next[selectedBackendKey]; - else next[selectedBackendKey] = preset.id; - saveBackendApplied(next); - return next; - }); - setPresetNotice(`${backendLabelFromKey(selectedBackendKey)} → ${preset.name}`); - window.setTimeout(() => setPresetNotice(null), 2200); - }, [selectedBackendKey]); - /* ── Device detail row ────────────────────────────────── */ const deviceDetail = useCallback((deviceKey: DeviceKey): string => { @@ -560,70 +516,6 @@ const BackendManager: React.FC = () => { } }, [sysInfo]); - const renderPresetRail = () => ( - - ); - /* ── Render ───────────────────────────────────────────── */ @@ -641,8 +533,7 @@ const BackendManager: React.FC = () => { } return ( -
- {renderPresetRail()} +
@@ -734,27 +625,23 @@ const BackendManager: React.FC = () => { const isInstalling = installing === cellKey; const backendDownload = downloadItems.find(download => backendDownloadMatches(download, recipe, backend)); const showBackendProgress = Boolean(backendDownload && (isDownloadActive(backendDownload) || backendDownload.status === 'paused')); - const activePreset = activePresetForBackendKey(cellKey); - const isSelectedBackend = selectedBackendKey === cellKey; - const isPresetHighlighted = Boolean(highlightedPresetId && activePreset.id === highlightedPresetId); + const assignedPreset = assignedPresetForBackendKey(cellKey); return ( -
- {/* #2343: overlay button provides keyboard + SR selection; covers the full cell area */} -
)} + + {appliedBackendKeys.length > 0 && ( +
+
+ + Applied to backends + {appliedBackendKeys.length} + +
+

+ Backend presets are global runtime defaults. Each one applies to all models that use that backend and merges with the model’s own preset. +

+
+ {appliedBackendKeys.map(key => { + const preset = lookupPreset(backendPresets[key]); + const backendLabel = backendLabelFromKey(key); + return ( +
+
+ + {backendLabel} +
+
+ + {preset?.name || 'Missing preset'} + Global · applies to all models on this backend +
+
+ {preset && } + +
+
+ ); + })} +
+
+ )}
@@ -679,6 +808,11 @@ const PresetManager: React.FC = ({ loadedModels }) => { onApplyTargetChange={setApplyTarget} onApply={handleApply} applySuccess={applySuccess} + backendOptions={backendOptions} + applyBackendTarget={applyBackendTarget} + onApplyBackendTargetChange={setApplyBackendTarget} + onApplyBackend={handleApplyBackend} + applyBackendSuccess={applyBackendSuccess} onSave={handleSave} onClone={handleClone} onExport={handleExport} @@ -758,13 +892,18 @@ const SlideoverContent: React.FC<{ onApplyTargetChange: (v: string) => void; onApply: (presetId: string, model: ModelInfo) => void; applySuccess: string | null; + backendOptions: BackendOption[]; + applyBackendTarget: string; + onApplyBackendTargetChange: (v: string) => void; + onApplyBackend: (presetId: string, backendKey: string) => void; + applyBackendSuccess: string | null; onSave: (updated: Preset) => void; onClone: (preset: Preset) => void; onExport: (preset: Preset) => void; onDelete: (preset: Preset) => void; onClose: () => void; autoRuns: AutoOptRun[]; -}> = ({ preset, models, applyTarget, onApplyTargetChange, onApply, applySuccess, onSave, onClone, onExport, onDelete, onClose, autoRuns }) => { +}> = ({ preset, models, applyTarget, onApplyTargetChange, onApply, applySuccess, backendOptions, applyBackendTarget, onApplyBackendTargetChange, onApplyBackend, applyBackendSuccess, onSave, onClone, onExport, onDelete, onClose, autoRuns }) => { const isReadOnly = preset.starter; const ro = preset.recipe_options || {}; const sp = preset.sampling || {}; @@ -860,6 +999,8 @@ const ctxSliderMax = selectedModelContextLimit || DEFAULT_CONTEXT_LIMIT; const ctxOptions = useMemo(() => contextSizeOptions(ctxSliderMax), [ctxSliderMax]); const ctxSliderIndex = useMemo(() => contextSizeIndex(ctxSize, ctxOptions), [ctxSize, ctxOptions]); const canApply = !!selectedModel && isCompatible(currentPreset, selectedModel); +const selectedBackendOption = backendOptions.find(b => b.key === applyBackendTarget) || null; +const canApplyBackend = !!selectedBackendOption && presetCompatibleWithBackend(currentPreset, selectedBackendOption.key); useEffect(() => { const nextCtxSize = nearestContextSize( @@ -1101,6 +1242,49 @@ const canApply = !!selectedModel && isCompatible(currentPreset, selectedModel); {selectedModel && !canApply &&

Incompatible preset for this model.

} {applySuccess &&

✓ {applySuccess}

} + + {/* #2432: assign this preset to a BACKEND. Global backend/runtime default + that merges with model presets — it does NOT replace them. */} +
+

Apply to a backend

+

+ This preset applies globally to all models using this backend. Backend presets are optional + runtime defaults that merge with each model’s own preset — they do not replace it. +

+
+ + +
+ {backendOptions.length === 0 &&

No backends reported by this server yet.

} + {selectedBackendOption && !canApplyBackend &&

Incompatible preset for this backend.

} +

+ {applyBackendSuccess ? `✓ ${applyBackendSuccess}` : ''} +

+
diff --git a/prototype/ui-redesign/src/styles/styles.css b/prototype/ui-redesign/src/styles/styles.css index 40d2829554..b2a5dc5e52 100644 --- a/prototype/ui-redesign/src/styles/styles.css +++ b/prototype/ui-redesign/src/styles/styles.css @@ -3301,6 +3301,8 @@ input, textarea { } .preset-error { color: var(--danger); font-size: 12px; margin: var(--space-2) 0 0; } .preset-success { color: var(--success); font-size: 12px; margin: var(--space-2) 0 0; } +.preset-backend-success { color: var(--success); font-size: 12px; margin: var(--space-2) 0 0; } +.preset-backend-success:empty { margin: 0; } .preset-status-chip { padding: 3px 8px; border-radius: 999px; diff --git a/prototype/ui-redesign/tests/a11y.spec.ts b/prototype/ui-redesign/tests/a11y.spec.ts index bed6c6d4fa..c47e7d0cbc 100644 --- a/prototype/ui-redesign/tests/a11y.spec.ts +++ b/prototype/ui-redesign/tests/a11y.spec.ts @@ -870,42 +870,9 @@ test.describe('Accessibility — Backend Manager (#2343 #2344 #2351)', () => { await page.waitForSelector('[data-backends-matrix]', { timeout: 5000 }); }); - // ── #2343 — matrix cells are keyboard-focusable with selected state ──────── - - test('A51 — each matrix cell contains a button with aria-pressed (selected state)', async ({ page }) => { - const cellBtn = page.locator('.cell__select-btn').first(); - await expect(cellBtn).toBeVisible(); - const pressed = await cellBtn.getAttribute('aria-pressed'); - expect(['true', 'false']).toContain(pressed); - }); - - test('A52 — clicking the cell select button toggles aria-pressed between "true" and "false"', async ({ page }) => { - const cellBtn = page.locator('[data-cell="llamacpp:vulkan"] .cell__select-btn'); - await expect(cellBtn).toHaveAttribute('aria-pressed', 'false'); - await cellBtn.click(); - await expect(cellBtn).toHaveAttribute('aria-pressed', 'true'); - await cellBtn.click(); - await expect(cellBtn).toHaveAttribute('aria-pressed', 'false'); - }); - - test('A53 — cell select button is keyboard-focusable (tabIndex >= 0, is a
{backendOptions.length === 0 &&

No backends reported by this server yet.

} - {!isDefaultBackendPreset && selectedBackendOption && !canApplyBackend &&

Incompatible preset for this backend.

} + {!isDefaultBackendPreset && selectedBackendOption && !selectedBackendOption.supported && ( +

+ {selectedBackendOption.label} is unsupported on this server’s hardware, so a preset cannot be assigned to it. +

+ )} + {!isDefaultBackendPreset && selectedBackendOption && selectedBackendOption.supported && !canApplyBackend &&

Incompatible preset for this backend.

}

{applyBackendSuccess ? `✓ ${applyBackendSuccess}` : ''}

diff --git a/prototype/ui-redesign/src/presetStore.ts b/prototype/ui-redesign/src/presetStore.ts index b80fe63980..4a8ed351e9 100644 --- a/prototype/ui-redesign/src/presetStore.ts +++ b/prototype/ui-redesign/src/presetStore.ts @@ -498,34 +498,94 @@ export function activePresetForBackend(key: string): Preset { } /** - * #2432: resolve the GLOBAL backend preset that applies to a model, if any. + * Per-recipe field in RecipeOptions that names the concrete backend the runtime + * will bind at load time (e.g. `llamacpp_backend: 'vulkan'`). Used to resolve the + * EXACT backend a load will use so we only merge the matching `recipe:backend` + * binding (#2432 round-3). + */ +const BACKEND_FIELD_BY_RECIPE: Record = { + llamacpp: 'llamacpp_backend', + vllm: 'vllm_backend', + whispercpp: 'whispercpp_backend', + moonshine: 'moonshine_backend', +}; + +function normalizeBackendValue(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim().toLowerCase(); + return trimmed ? trimmed : undefined; +} + +/** Read `recipes[recipe].default_backend` from a /system-info payload. */ +function recipeDefaultBackend(systemInfo: Record | null | undefined, recipe: string): string | undefined { + const recipes = (systemInfo?.recipes ?? null) as Record | null; + if (!recipes) return undefined; + return normalizeBackendValue(recipes[recipe]?.default_backend); +} + +/** + * Resolve the CONCRETE backend that a load for `recipe` will actually use, in + * sensible precedence: explicit load options → model-preset recipe_options → + * recipe default backend (from /system-info). The backend preset's own value is + * deliberately NOT consulted here — that would be circular (we're deciding + * whether the backend preset even applies). + */ +function concreteBackendForRecipe( + recipe: string, + explicitOptions: RecipeOptions | null | undefined, + modelPresetOptions: RecipeOptions | null | undefined, + systemInfo: Record | null | undefined, +): string | undefined { + const field = BACKEND_FIELD_BY_RECIPE[recipe]; + if (!field) return undefined; + return normalizeBackendValue(explicitOptions?.[field]) + ?? normalizeBackendValue(modelPresetOptions?.[field]) + ?? recipeDefaultBackend(systemInfo, recipe); +} + +export interface BackendResolutionContext { + /** Options passed explicitly to the load call (highest precedence). */ + explicitOptions?: RecipeOptions | null; + /** The model preset's recipe_options (model-specific defaults). */ + modelPresetOptions?: RecipeOptions | null; + /** /system-info payload, used for the recipe's default_backend. */ + systemInfo?: Record | null; +} + +/** + * #2432 (round-3): resolve the GLOBAL backend preset that applies to a model's + * load, if any. + * + * Backend presets are keyed by the EXACT `recipe:backend` pair (e.g. + * `llamacpp:vulkan`). A binding therefore only applies when the CONCRETE backend + * that this load will use equals the binding's backend part — NOT merely when the + * recipe matches. We resolve the concrete backend the same way the load does + * (explicit options → model preset → recipe default_backend) and only merge the + * preset bound to that exact key. This keeps the semantics "all models using this + * BACKEND" rather than the wrong "all models using this RECIPE". * - * Backend presets are keyed by `recipe:backend` (e.g. `llamacpp:vulkan`). A - * registry model carries its recipe(s) but not the concrete backend it will - * load with (the backend is chosen at load time / by recipe_options). So we - * match a backend binding whenever its recipe-part matches one of the model's - * recipes. Default is treated as "no backend preset" (returns null) so it never + * Default is treated as "no backend preset" (returns null) so it never * contributes args — consistent with the Backend view hiding Default. */ -export function activePresetForModelBackend(model?: ModelInfo | null): Preset | null { +export function activePresetForModelBackend(model?: ModelInfo | null, ctx?: BackendResolutionContext): Preset | null { if (!model) return null; const applied = loadBackendApplied(); - const keys = Object.keys(applied); - if (keys.length === 0) return null; + if (Object.keys(applied).length === 0) return null; - const modelRecipes = new Set(); + // Ordered list of the model's recipes (active recipe first). + const recipes: string[] = []; const active = String((model as Record).recipe || '').trim().toLowerCase(); - if (active) modelRecipes.add(active); + if (active) recipes.push(active); for (const recipe of recipesForModel(model)) { const name = recipeName(recipe); - if (name) modelRecipes.add(name); + if (name && !recipes.includes(name)) recipes.push(name); } - if (modelRecipes.size === 0) return null; + if (recipes.length === 0) return null; - for (const key of keys.sort()) { - const recipePart = (key.split(':')[0] || '').trim().toLowerCase(); - if (!recipePart || !modelRecipes.has(recipePart)) continue; - const presetId = applied[key]; + for (const recipe of recipes) { + const backend = concreteBackendForRecipe(recipe, ctx?.explicitOptions, ctx?.modelPresetOptions, ctx?.systemInfo); + if (!backend) continue; + const presetId = applied[`${recipe}:${backend}`]; if (!presetId || presetId === DEFAULT_PRESET.id) continue; const preset = allStoredPresets().find(p => p.id === presetId); if (preset) return preset; @@ -653,21 +713,33 @@ export function recipeOptionsForCapability(options: RecipeOptions, capability: M } } -export function recipeOptionsForModel(modelName: string, model?: ModelInfo | null): RecipeOptions | undefined { +export function recipeOptionsForModel( + modelName: string, + model?: ModelInfo | null, + explicitOptions?: RecipeOptions | null, + systemInfo?: Record | null, +): RecipeOptions | undefined { const capability = model ? capabilityFromModelInfo(model) : undefined; // Model preset = model-specific defaults. const modelPreset = activePresetForModel(modelName); + const modelPresetOptions = modelPreset.recipe_options || {}; const modelOptions = model - ? recipeOptionsForCapability(modelPreset.recipe_options || {}, capability!) - : (modelPreset.recipe_options || {}); - - // Backend preset = GLOBAL backend/runtime defaults (#2432). Merge precedence: - // start from the backend preset's args as the base/global defaults, then layer - // the model preset's args on top so model-specific values WIN on conflict. This - // matches `main`'s "more specific source wins" argument-merge semantics. Default - // backend preset contributes nothing (activePresetForModelBackend returns null). - const backendPreset = activePresetForModelBackend(model); + ? recipeOptionsForCapability(modelPresetOptions, capability!) + : modelPresetOptions; + + // Backend preset = GLOBAL backend/runtime defaults (#2432). It only applies + // when the CONCRETE backend this load resolves to (explicit options → model + // preset → recipe default_backend) matches its exact `recipe:backend` binding. + // Merge precedence: start from the backend preset's args as the base/global + // defaults, then layer the model preset's args on top so model-specific values + // WIN on conflict. This matches `main`'s "more specific source wins" merge. + // Default backend preset contributes nothing (resolver returns null). + const backendPreset = activePresetForModelBackend(model, { + explicitOptions, + modelPresetOptions, + systemInfo, + }); const backendOptions = backendPreset && model ? recipeOptionsForCapability(backendPreset.recipe_options || {}, capability!) : (backendPreset ? (backendPreset.recipe_options || {}) : {}); diff --git a/prototype/ui-redesign/tests/a11y.spec.ts b/prototype/ui-redesign/tests/a11y.spec.ts index f94722bd74..8d9e5d4c19 100644 --- a/prototype/ui-redesign/tests/a11y.spec.ts +++ b/prototype/ui-redesign/tests/a11y.spec.ts @@ -1080,7 +1080,11 @@ test.describe('Accessibility — backend preset rail removal (#2432)', () => { // producing an "Applied to backends" row with no matching Backend-view // chip. Backend assignment is now disabled for Default with accessible // copy explaining that "no backend preset" is the default state. -// Range: A174–A176. +// GAP 3 (round-3): backend bindings now apply only when the CONCRETE backend +// that the load resolves to matches the exact `recipe:backend` key, and +// the Presets UI disables assignment to backends the server reports as +// `unsupported`. +// Range: A174–A179. test.describe('Accessibility — backend-preset merge + Default handling (#2432 review)', () => { const MODEL = 'Llama-3.1-8B'; @@ -1112,15 +1116,19 @@ test.describe('Accessibility — backend-preset merge + Default handling (#2432 backends: { vulkan: { state: 'installable', version: 'b1', message: '', action: '' }, cpu: { state: 'installed', version: 'b1', message: '', action: '', can_uninstall: true }, + cuda: { state: 'unsupported', version: '', message: 'No NVIDIA GPU detected', action: '' }, }, }, }, }; - test('A174 — a backend preset is merged into effective recipe_options on load (backend = base, model wins on conflict)', async ({ page }) => { - // Model preset = model-specific defaults; backend preset = global runtime - // defaults. ctx_size + llamacpp_args appear in BOTH (model must win); - // llamacpp_backend appears ONLY in the backend preset (must still merge in). + test('A174 — a backend preset merges only when the CONCRETE backend matches its exact recipe:backend key (resolved via recipe default_backend)', async ({ page }) => { + // EXACT backend matching: the model carries only recipe `llamacpp` with no + // concrete backend in its load/preset options, so the load resolves to the + // recipe's default_backend = `cpu` (from /system-info). ONLY the binding for + // the exact key `llamacpp:cpu` may merge. ctx_size + llamacpp_args appear in + // BOTH presets (model must win); llamacpp_backend appears ONLY in the backend + // preset (must still merge in, proving the backend preset actually applied). const modelPreset = preset('m-model', 'Model Wins', { ctx_size: 8192, llamacpp_args: '--model-wins' }); const backendPreset = preset('m-backend', 'Backend Base', { ctx_size: 2048, llamacpp_args: '--backend-base', llamacpp_backend: 'cpu' }); @@ -1230,6 +1238,132 @@ test.describe('Accessibility — backend-preset merge + Default handling (#2432 const serious = results.violations.filter(v => v.impact === 'serious' || v.impact === 'critical'); expect(serious, formatViolations(serious)).toEqual([]); }); + + test('A177 — a backend preset bound to llamacpp:cpu does NOT merge into a vulkan load (exact backend match only)', async ({ page }) => { + // The model preset pins the concrete backend to `vulkan`, so this load + // resolves to `llamacpp:vulkan`. A preset bound to the DIFFERENT key + // `llamacpp:cpu` must NOT contribute — proving backend-level (not + // recipe-level) matching. ctx_size lives ONLY in the cpu preset, so its + // absence in the load body proves the cpu binding stayed out. + const modelPreset = preset('m-model-vk', 'Vulkan Model', { llamacpp_backend: 'vulkan', llamacpp_args: '--model-vk' }); + const backendPreset = preset('m-backend-cpu', 'CPU Backend', { ctx_size: 2048, llamacpp_args: '--cpu-base', llamacpp_backend: 'cpu' }); + + let loadBody: Record | null = null; + + await page.addInitScript( + ({ presets, applied, backend, model }) => { + localStorage.setItem('lemonade:guest:shared:user_presets', JSON.stringify(presets)); + localStorage.setItem('lemonade:guest:shared:applied_presets', JSON.stringify({ [model]: applied })); + localStorage.setItem('lemonade:guest:shared:backend_presets', JSON.stringify(backend)); + localStorage.removeItem('lemonade:guest:shared:running_presets'); + }, + { presets: [modelPreset, backendPreset], applied: 'm-model-vk', backend: { 'llamacpp:cpu': 'm-backend-cpu' }, model: MODEL }, + ); + + await page.route('**/api/v1/health', route => + route.fulfill({ contentType: 'application/json', body: JSON.stringify({ status: 'ok', version: 'test', all_models_loaded: [] }) }), + ); + await page.route('**/api/v1/system-info**', route => route.fulfill({ json: MOCK_SYSTEM_INFO })); + await page.route('**/api/v1/models**', route => + route.fulfill({ contentType: 'application/json', body: JSON.stringify({ data: [{ id: MODEL, name: MODEL, labels: ['llm'], recipe: 'llamacpp', downloaded: true }] }) }), + ); + await page.route('**/api/v1/load', async route => { + try { loadBody = route.request().postDataJSON() as Record; } catch { /* ignore */ } + await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ status: 'ok' }) }); + }); + + await page.goto('/'); + await page.waitForSelector('.titlebar__nav'); + await page.locator('.titlebar__nav').getByText('Models').click(); + await page.waitForSelector('.manager--detail'); + await page.waitForSelector('.model-list-item', { timeout: 5000 }); + await page.locator('.model-list-item').first().click(); + + const loadBtn = page.locator(`button[aria-label="Load ${MODEL}"]`); + await expect(loadBtn).toBeVisible(); + await loadBtn.click(); + + await expect.poll(() => loadBody).not.toBeNull(); + const body = loadBody!; + expect(body.model_name).toBe(MODEL); + // The load uses the vulkan backend the model preset selected. + expect(body.llamacpp_backend).toBe('vulkan'); + expect(body.llamacpp_args).toBe('--model-vk'); + // The cpu-bound preset's signature arg must be ABSENT (it did not merge). + expect(body.ctx_size).toBeUndefined(); + }); + + test('A178 — an unsupported backend cannot receive a global backend preset (option disabled + not assignable, accessibly)', async ({ page }) => { + await page.addInitScript(() => { + try { + for (const k of Object.keys(localStorage)) { + if (k.includes('backend_presets') || k.includes('applied_presets') || k.includes('user_presets')) localStorage.removeItem(k); + } + } catch {} + }); + await page.route('**/api/v1/system-info**', route => route.fulfill({ json: MOCK_SYSTEM_INFO })); + await page.goto('/'); + await page.waitForSelector('.titlebar__nav'); + await page.locator('.titlebar__nav').getByText('Presets').click(); + await page.waitForSelector('.recipe-card', { timeout: 5000 }); + + // Open a chat-capable starter (compatible with the llamacpp backend). + await page.locator('.recipe-card', { hasText: 'Balanced' }).first().click(); + await page.waitForSelector('.slideover.is-open', { timeout: 5000 }); + + const select = page.locator('[data-backend-apply-target]'); + await expect(select).toBeVisible(); + + // The unsupported backend (llamacpp:cuda) is surfaced but its option is + // programmatically disabled and labelled so screen readers convey the state. + const cudaOption = select.locator('option[value="llamacpp:cuda"]'); + await expect(cudaOption).toHaveCount(1); + await expect(cudaOption).toBeDisabled(); + await expect(cudaOption).toHaveText(/unsupported/i); + // The disabled option carries an explanatory title for hover/AT. + expect(await cudaOption.getAttribute('title')).toMatch(/unsupported/i); + + // A disabled