llm-tuner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>LLM Tuner</title>
7
+ <link rel="icon" type="image/png" href="icon.png">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
10
+ <link rel="stylesheet" href="style.css">
11
+ </head>
12
+ <body>
13
+ <div class="shell">
14
+ <aside class="rail">
15
+ <div class="brand">
16
+ <img src="icon.png" class="brand-mark" alt="LLM Tuner">
17
+ <div>
18
+ <strong>LLM Tuner</strong>
19
+ <small data-i18n="web.tagline">load your model with the fastest configuration</small>
20
+ </div>
21
+ </div>
22
+ <button id="settings-open" class="settings-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">
23
+ <span class="settings-trigger-icon">⚙</span>
24
+ <span data-i18n="web.settings">Settings</span>
25
+ </button>
26
+ <ol class="steps" id="steps">
27
+ <li data-step="engine" class="active" data-i18n="web.stepEngine">Engine</li>
28
+ <li data-step="model" data-i18n="web.modelAndContext">Model and context</li>
29
+ <li data-step="load" data-i18n="web.stepLoad">Load</li>
30
+ </ol>
31
+ <div class="hw-mini" id="hw-mini" data-i18n="web.detectingHw">Detecting hardware…</div>
32
+ <p class="rail-note" data-i18n="web.railNote">The configuration is decided from your hardware, the model metadata and real measurements. The result is saved as a preset: it is only measured again if you change the context or the load profile.</p>
33
+ </aside>
34
+
35
+ <main>
36
+ <section id="sec-engine" class="panel">
37
+ <header><h2 data-i18n="web.whichEngine">Which engine do you want to use?</h2></header>
38
+ <div id="engines" class="engines"></div>
39
+ <p class="muted small" id="engine-reason"></p>
40
+ <div id="install" hidden>
41
+ <label for="install-plan" data-i18n="web.installMethod">Install method</label>
42
+ <div class="row wrap">
43
+ <select id="install-plan"></select>
44
+ <button id="install-btn" class="btn primary" type="button" data-i18n="web.install">Install</button>
45
+ </div>
46
+ <pre id="install-log" class="log" hidden></pre>
47
+ </div>
48
+ </section>
49
+
50
+ <section id="sec-model" class="panel" hidden>
51
+ <header><h2 data-i18n="web.modelAndContext">Model and context</h2><span class="pill" id="preset-pill" hidden></span></header>
52
+ <div class="row wrap">
53
+ <div class="field grow">
54
+ <label for="model" data-i18n="web.model">Model</label>
55
+ <select id="model"></select>
56
+ </div>
57
+ <div class="field">
58
+ <label for="ctx" data-i18n="web.context">Context (tokens)</label>
59
+ <input id="ctx" type="number" min="512" step="1024" value="16384">
60
+ </div>
61
+ </div>
62
+ <div id="ctx-chips" class="chips"></div>
63
+ <div class="field profile-field">
64
+ <span id="profile-label" class="field-label" data-i18n="web.profile">Load profile</span>
65
+ <div id="profile-options" class="profile-options" role="radiogroup" aria-labelledby="profile-label" aria-describedby="profile-detail"></div>
66
+ <div id="profile-detail" class="profile-detail small" aria-live="polite"></div>
67
+ </div>
68
+ <div id="model-meta" class="meta muted small"></div>
69
+ <div id="gpus" class="gpus"></div>
70
+ <div class="row wrap actions">
71
+ <label class="check"><input type="checkbox" id="force"> <span data-i18n="web.force">Measure again even if a preset exists</span></label>
72
+ <button id="load-btn" class="btn primary" type="button" disabled data-i18n="web.loadButton">Load</button>
73
+ </div>
74
+ </section>
75
+
76
+ <section id="sec-load" class="panel" hidden>
77
+ <header><h2 id="load-title" data-i18n="web.loading">Loading</h2><span class="pill" id="load-status" data-i18n="web.inProgress">in progress</span></header>
78
+ <div id="bench" hidden>
79
+ <p class="muted small" data-i18n="web.benchIntro">There is no preset for this context and profile: several configurations are tested on your hardware. It takes a few minutes and only happens once.</p>
80
+ <div class="table-wrap">
81
+ <table id="results">
82
+ <thead><tr><th data-i18n="web.colConfig">Configuration</th><th data-i18n="web.colGpuLayers">GPU layers</th><th class="num" data-i18n="web.colEstimated">Estimated</th><th class="num" data-i18n="web.colShort">Short</th><th class="num" data-i18n="web.colDeep">With context</th><th class="num">CPU</th><th data-i18n="web.colVram">Peak VRAM</th></tr></thead>
83
+ <tbody></tbody>
84
+ </table>
85
+ </div>
86
+ </div>
87
+ <pre id="load-log" class="log"></pre>
88
+ <div id="load-result" class="result"></div>
89
+ <div id="chat-box" class="chat-box" hidden>
90
+ <div class="chat-header">
91
+ <div>
92
+ <strong data-i18n="web.chatTitle">Test connection</strong>
93
+ <small class="muted" data-i18n="web.chatSubtitle">Send a prompt to test model response</small>
94
+ </div>
95
+ </div>
96
+ <div id="chat-messages" class="chat-messages"></div>
97
+ <form id="chat-form" class="chat-form">
98
+ <input id="chat-input" type="text" autocomplete="off" data-i18n-placeholder="web.chatPlaceholder" placeholder="Type a prompt to test…">
99
+ <button id="chat-send" class="btn primary" type="submit" data-i18n="web.chatSend">Send</button>
100
+ </form>
101
+ </div>
102
+ </section>
103
+ </main>
104
+ </div>
105
+ <div id="settings-backdrop" class="drawer-backdrop" hidden></div>
106
+ <aside id="settings-drawer" class="drawer" role="dialog" aria-modal="true" aria-labelledby="settings-title" hidden>
107
+ <div class="drawer-header">
108
+ <h3 id="settings-title" data-i18n="web.settings">Settings</h3>
109
+ <button id="settings-close" class="drawer-close" type="button" aria-label="Close">✕</button>
110
+ </div>
111
+ <div class="drawer-body">
112
+ <div class="drawer-field lang-switch">
113
+ <label for="lang" data-i18n="web.language">Language</label>
114
+ <select id="lang">
115
+ <option value="en">English</option>
116
+ <option value="es">Español</option>
117
+ </select>
118
+ </div>
119
+ <div class="drawer-field theme-switch">
120
+ <label for="theme" data-i18n="web.theme">Theme</label>
121
+ <select id="theme">
122
+ <option value="system" data-i18n="web.themeSystem">System</option>
123
+ <option value="light" data-i18n="web.themeLight">Light</option>
124
+ <option value="dark" data-i18n="web.themeDark">Dark</option>
125
+ </select>
126
+ </div>
127
+ </div>
128
+ </aside>
129
+ <script src="app.js" type="module"></script>
130
+ </body>
131
+ </html>
@@ -0,0 +1,100 @@
1
+ // View-model of the load profile selector. Pure: no DOM and no imports, so the browser and the Node tests load the same file.
2
+ // `plan` is the `profiles` object of POST /api/plan (a `Tuner.profilePlan()` result), or null while there is none;
3
+ // `t(key, params)` translates a catalog key.
4
+
5
+ export const PROFILE_IDS = Object.freeze(['speed', 'balanced', 'quality']);
6
+ export const DEFAULT_PROFILE = 'balanced';
7
+
8
+ // Reasons of a `variant-picked` event that mean the profile could not be honoured: the core flags them with a `profile-fallback` event right after.
9
+ const FALLBACK_REASONS = new Set(['profile.variant.noFullGpu']);
10
+
11
+ /** Profile to preselect: the one stored in the settings, else the recommended one, else balanced. */
12
+ export function initialProfile(settingsProfile, plan) {
13
+ return settingsProfile ?? plan?.recommended?.profile ?? DEFAULT_PROFILE;
14
+ }
15
+
16
+ /** The options of the selector as `{ id, name, description, recommended }`; none is recommended without a plan. */
17
+ export function profileOptions(plan, t, ids = PROFILE_IDS) {
18
+ return ids.map((id) => ({
19
+ id,
20
+ name: t(`profile.name.${id}`),
21
+ description: t(`profile.desc.${id}`),
22
+ recommended: id === plan?.recommended?.profile,
23
+ }));
24
+ }
25
+
26
+ /**
27
+ * Lines under the selector for the chosen profile, as `{ kind, level, text }`: what will load and why (`detail`),
28
+ * why a profile is recommended (`recommend`), a warning that the preview is conservative while VRAM is in use (`busy`),
29
+ * one estimate per heavier variant that would fit (`hint`) and the advice to select another variant in the engine (`switch`).
30
+ * Empty without a plan.
31
+ */
32
+ export function profileDetailLines(plan, profile, t, { engineName, formatBytes }) {
33
+ const info = plan?.profiles?.[profile];
34
+ if (!info) return [];
35
+ // What the engine will load: the selected variant when it cannot switch to the profile's pick.
36
+ const loaded = plan.variants.find((v) => v.key === info.loads);
37
+ const detail = t('web.profileDetail', { quant: loaded?.quant ?? info.loads, kv: info.kvTypes.join(', ') });
38
+ const reason = t(info.reasonCode, { quant: info.quant ?? '?', key: info.variant });
39
+ const lines = [{ kind: 'detail', level: info.fallback ? 'warn' : 'note', text: `${detail} ${reason}` }];
40
+
41
+ if (plan.recommended?.reasonCode) {
42
+ lines.push({ kind: 'recommend', level: 'note', text: t(plan.recommended.reasonCode) });
43
+ }
44
+ // The plan was made with the VRAM that is free now; a real load frees it first.
45
+ if (plan.vramBusy) {
46
+ lines.push({ kind: 'busy', level: 'warn', text: t('profile.preview.vramBusy') });
47
+ }
48
+ for (const hint of plan.hints ?? []) {
49
+ lines.push({ kind: 'hint', level: 'note', text: t('web.profileHint', { quant: hint.quant, size: formatBytes(hint.estimatedBytes) }) });
50
+ }
51
+ if (info.recommendSwitch) {
52
+ lines.push({ kind: 'switch', level: 'note', text: t('web.profileSwitch', { quant: info.quant ?? info.variant, engine: engineName }) });
53
+ }
54
+ return lines;
55
+ }
56
+
57
+ /**
58
+ * Lines to log for a `variant-picked` or `profile-fallback` progress event, as `{ text, level }` with level `note` or `warn`.
59
+ * `previous` is the progress event before it; without `engineName` the switch advice is left out.
60
+ */
61
+ export function profileEventLines(event, t, { engineName = null, previous = null } = {}) {
62
+ const { variant } = event;
63
+ // Nothing to say without the variant; throwing here would break the progress handler of a running load.
64
+ if (!variant) return [];
65
+ const isFallback = event.type === 'profile-fallback';
66
+ const lines = [];
67
+
68
+ // A fallback that repeats the reason of the line before it is not logged again.
69
+ if (!(isFallback && event.reasonCode === previous?.reasonCode)) {
70
+ const level = isFallback || FALLBACK_REASONS.has(event.reasonCode) ? 'warn' : 'note';
71
+ lines.push({ text: t(event.reasonCode, { quant: variant.quant ?? '?', key: variant.key }), level });
72
+ }
73
+
74
+ // The engine cannot load the pick itself: tell the user which variant to select there.
75
+ if (event.type === 'variant-picked' && event.recommendSwitch && engineName) {
76
+ lines.push({ text: t('web.profileSwitch', { quant: variant.quant ?? variant.key, engine: engineName }), level: 'note' });
77
+ }
78
+
79
+ return lines;
80
+ }
81
+
82
+ /** Presets measured with a profile; an entry saved before profiles existed has none and counts as balanced. */
83
+ export function presetsFor(presets, profile) {
84
+ return presets.filter((p) => (p.profile || DEFAULT_PROFILE) === profile);
85
+ }
86
+
87
+ /** The preset of a context and profile, or null. */
88
+ export function presetFor(presets, ctx, profile) {
89
+ return presetsFor(presets, profile).find((p) => p.ctx === ctx) ?? null;
90
+ }
91
+
92
+ /** True when an answer requested for an engine and model still belongs to the selection; the context is ignored. */
93
+ export function isCurrentModel(requested, current) {
94
+ return requested.engine === current.engine && requested.model === current.model;
95
+ }
96
+
97
+ /** True when a plan requested for an engine, model and context is still the plan of the selection. */
98
+ export function isCurrentPlan(requested, current) {
99
+ return isCurrentModel(requested, current) && requested.ctx === current.ctx;
100
+ }
@@ -0,0 +1,330 @@
1
+ :root {
2
+ --bg: #f1f5f9;
3
+ --surface: #ffffff;
4
+ --ink: #0f172a;
5
+ --muted: #475569;
6
+ --line: #cbd5e1;
7
+ --accent: #0284c7;
8
+ --accent-ink: #ffffff;
9
+ --gpu: #0891b2;
10
+ --cpu: #94a3b8;
11
+ --ok: #16a34a;
12
+ --warn: #d97706;
13
+ --bad: #dc2626;
14
+ --code-bg: #f8fafc;
15
+ --font: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
16
+ --mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
17
+ color-scheme: light;
18
+ }
19
+ @media (prefers-color-scheme: dark) {
20
+ :root:not([data-theme="light"]) {
21
+ --bg: #090e15;
22
+ --surface: #0f1722;
23
+ --ink: #e2e8f0;
24
+ --muted: #8b9bb0;
25
+ --line: #1e2c3d;
26
+ --accent: #00e5ff; /* electric cyan from logo */
27
+ --accent-ink: #05131d;
28
+ --gpu: #00e5ff;
29
+ --cpu: #3d4f63;
30
+ --ok: #34d399;
31
+ --warn: #fbbf24;
32
+ --bad: #f87171;
33
+ --code-bg: #0b121b;
34
+ color-scheme: dark;
35
+ }
36
+ }
37
+ :root[data-theme="dark"] {
38
+ --bg: #090e15;
39
+ --surface: #0f1722;
40
+ --ink: #e2e8f0;
41
+ --muted: #8b9bb0;
42
+ --line: #1e2c3d;
43
+ --accent: #00e5ff; /* electric cyan from logo */
44
+ --accent-ink: #05131d;
45
+ --gpu: #00e5ff;
46
+ --cpu: #3d4f63;
47
+ --ok: #34d399;
48
+ --warn: #fbbf24;
49
+ --bad: #f87171;
50
+ --code-bg: #0b121b;
51
+ color-scheme: dark;
52
+ }
53
+ :root[data-theme="light"] {
54
+ --bg: #f1f5f9;
55
+ --surface: #ffffff;
56
+ --ink: #0f172a;
57
+ --muted: #475569;
58
+ --line: #cbd5e1;
59
+ --accent: #0284c7;
60
+ --accent-ink: #ffffff;
61
+ --gpu: #0891b2;
62
+ --cpu: #94a3b8;
63
+ --ok: #16a34a;
64
+ --warn: #d97706;
65
+ --bad: #dc2626;
66
+ --code-bg: #f8fafc;
67
+ color-scheme: light;
68
+ }
69
+
70
+ * { box-sizing: border-box; }
71
+ [hidden] { display: none !important; }
72
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 var(--font); }
73
+ h2 { margin: 0; font-size: 1.05rem; font-weight: 600; text-wrap: balance; }
74
+ .muted { color: var(--muted); }
75
+ .small { font-size: .85rem; }
76
+
77
+ .shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
78
+ .rail { padding: 24px 20px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 28px; position: sticky; top: 0; height: 100vh; }
79
+ .brand { display: flex; gap: 12px; align-items: center; }
80
+ .brand strong { display: block; font-size: 1.05rem; }
81
+ .brand small { color: var(--muted); font-size: .78rem; }
82
+ .brand-mark { width: 32px; height: 32px; border-radius: 8px; object-fit: contain; flex-shrink: 0; display: block; }
83
+ .steps li { counter-increment: s; display: flex; gap: 10px; align-items: center; padding: 7px 8px; border-radius: 6px; color: var(--muted); font-size: .92rem; }
84
+ .steps li::before { content: counter(s); width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font: 500 .75rem var(--mono); border: 1px solid var(--line); }
85
+ .steps li.done::before { background: var(--gpu); border-color: var(--gpu); color: var(--surface); content: '✓'; }
86
+ .steps li.active { color: var(--ink); background: var(--surface); }
87
+ .steps li.active::before { border-color: var(--accent); color: var(--accent); }
88
+ .rail-note { margin-top: auto; font-size: .8rem; color: var(--muted); }
89
+
90
+ main { padding: 24px clamp(16px, 3vw, 40px); display: grid; gap: 18px; align-content: start; max-width: 1100px; }
91
+ .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 20px; display: grid; gap: 16px; }
92
+ .panel > header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
93
+ .pill { font: 500 .75rem var(--mono); padding: 3px 9px; border-radius: 99px; border: 1px solid var(--line); color: var(--muted); }
94
+ .pill.ok { color: var(--ok); border-color: currentColor; }
95
+ .pill.warn { color: var(--warn); border-color: currentColor; }
96
+ .pill.bad { color: var(--bad); border-color: currentColor; }
97
+
98
+ .hw-grid { display: flex; flex-wrap: wrap; gap: 12px 40px; }
99
+ .kv span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
100
+ .kv b { font-weight: 500; }
101
+ .gpus { display: grid; gap: 12px; }
102
+ .gpu { display: grid; gap: 6px; }
103
+ .gpu-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; font-size: .9rem; }
104
+ .gpu-head .tags { display: flex; gap: 6px; flex-wrap: wrap; }
105
+ .tag { font: .72rem var(--mono); padding: 1px 7px; border-radius: 4px; background: var(--code-bg); color: var(--muted); }
106
+ .tag.prio { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
107
+ .meter { height: 12px; border-radius: 3px; background: var(--code-bg); border: 1px solid var(--line); overflow: hidden; display: flex; }
108
+ .meter i { display: block; height: 100%; }
109
+ .meter .used { background: var(--cpu); }
110
+ .meter .model { background: var(--gpu); }
111
+ .meter .kv { background: color-mix(in srgb, var(--gpu) 55%, var(--accent)); }
112
+ .legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: .78rem; color: var(--muted); }
113
+ .legend span::before { content: ''; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; background: var(--c); }
114
+
115
+ .engines { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
116
+ .engine { text-align: left; border: 1px solid var(--line); background: var(--bg); border-radius: 8px; padding: 14px; display: grid; gap: 4px; cursor: pointer; color: var(--ink); font: inherit; }
117
+ .engine:hover { border-color: var(--muted); }
118
+ .engine.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
119
+ .engine b { font-size: 1rem; }
120
+ .engine small { color: var(--muted); }
121
+
122
+ .row { display: flex; gap: 12px; align-items: end; }
123
+ .row.wrap { flex-wrap: wrap; }
124
+ .field { display: grid; gap: 4px; }
125
+ .field.grow { flex: 1 1 280px; }
126
+ label { font-size: .78rem; color: var(--muted); }
127
+ select, input { font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; min-width: 0; max-width: 100%; }
128
+ input[type=number] { width: 150px; font-family: var(--mono); }
129
+ select:focus-visible, input:focus-visible, .btn:focus-visible, .engine:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
130
+
131
+ .btn { font: 500 .92rem var(--font); padding: 9px 16px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; }
132
+ .btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
133
+ .btn:disabled { opacity: .5; cursor: not-allowed; }
134
+
135
+ .meta { font-size: .88rem; }
136
+ .maxctx { display: flex; gap: 10px; flex-wrap: wrap; }
137
+ .maxctx div { border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; min-width: 150px; }
138
+ .maxctx span { display: block; font-size: .72rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
139
+ .maxctx b { font: 500 1.1rem var(--mono); font-variant-numeric: tabular-nums; }
140
+ .placement { display: grid; gap: 10px; }
141
+
142
+ .table-wrap { overflow-x: auto; }
143
+ table { width: 100%; border-collapse: collapse; font-size: .88rem; }
144
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
145
+ th { font-weight: 500; color: var(--muted); font-size: .76rem; text-transform: uppercase; letter-spacing: .05em; }
146
+ td.num, th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
147
+ tr.best td { background: color-mix(in srgb, var(--gpu) 10%, transparent); }
148
+ tr.fail td { color: var(--muted); }
149
+ td .err { color: var(--bad); white-space: normal; }
150
+
151
+ .log { margin: 0; max-height: 180px; overflow: auto; background: var(--code-bg); border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; font: .78rem/1.5 var(--mono); color: var(--muted); white-space: pre-wrap; }
152
+ .best { display: flex; flex-wrap: wrap; gap: 12px 32px; align-items: baseline; }
153
+ .best .big { font: 500 2rem var(--mono); color: var(--gpu); font-variant-numeric: tabular-nums; }
154
+ .preview { font-size: .88rem; display: grid; gap: 6px; }
155
+ .preview code, .preview pre { font-family: var(--mono); font-size: .8rem; background: var(--code-bg); padding: 2px 6px; border-radius: 4px; word-break: break-all; }
156
+ .preview pre { padding: 10px; margin: 0; white-space: pre-wrap; }
157
+ .notice { padding: 10px 12px; border-radius: 6px; border: 1px solid var(--line); font-size: .88rem; }
158
+ .notice.warn { border-color: var(--warn); }
159
+ .notice.ok { border-color: var(--ok); }
160
+
161
+ @media (max-width: 760px) {
162
+ .shell { grid-template-columns: 1fr; }
163
+ .rail { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); gap: 14px; padding: 16px; }
164
+ .steps { grid-auto-flow: column; overflow-x: auto; }
165
+ .steps li { font-size: 0; gap: 0; }
166
+ .steps li.active { font-size: .85rem; gap: 8px; }
167
+ .rail-note { display: none; }
168
+ main { padding: 16px; }
169
+ }
170
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
171
+
172
+
173
+ /* Load flow additions */
174
+ .hw-mini { display: grid; gap: 2px; font: .76rem/1.45 var(--mono); color: var(--muted); padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; }
175
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
176
+ .chip { font: 500 .8rem var(--mono); padding: 4px 10px; border-radius: 99px; border: 1px solid var(--line); background: var(--bg); color: var(--muted); cursor: pointer; }
177
+ .chip.fits { color: var(--ink); }
178
+ .chip.saved { color: var(--gpu); border-color: var(--gpu); }
179
+ .chip.current { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
180
+ .chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
181
+ .check { display: flex; gap: 8px; align-items: center; font-size: .85rem; color: var(--muted); }
182
+ .result { display: grid; gap: 10px; }
183
+ .result code { font: .8rem var(--mono); background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
184
+ .notice pre { margin: 8px 0 0; font: .8rem var(--mono); white-space: pre-wrap; }
185
+ @media (max-width: 760px) { .hw-mini { display: none; } }
186
+
187
+ /* Load profile selector: a radio group of three cards, in the visual language of the engine cards */
188
+ .field-label { font-size: .78rem; color: var(--muted); }
189
+ .profile-field { gap: 6px; }
190
+ .profile-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 8px; }
191
+ .profile-option { display: grid; gap: 2px; align-content: start; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); font-size: .92rem; cursor: pointer; }
192
+ .profile-option:hover { border-color: var(--muted); }
193
+ /* Selected: the checked radio plus a thicker border, so the state does not rely on colour alone. */
194
+ .profile-option.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
195
+ .profile-option:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 1px; }
196
+ .profile-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
197
+ .profile-head input { margin: 0; accent-color: var(--accent); cursor: pointer; }
198
+ .profile-head b { font-weight: 600; }
199
+ .profile-option small { color: var(--muted); font-size: .8rem; }
200
+ .profile-detail { display: grid; gap: 2px; color: var(--muted); }
201
+ .profile-detail .warn, .log .warn { color: var(--warn); }
202
+ .profile-detail .warn::before, .log .warn::before { content: '⚠ '; }
203
+ .actions { justify-content: space-between; align-items: center; }
204
+ /* Settings trigger & drawer */
205
+ .settings-trigger {
206
+ display: inline-flex;
207
+ align-items: center;
208
+ gap: 10px;
209
+ padding: 8px 12px;
210
+ background: var(--surface);
211
+ border: 1px solid var(--line);
212
+ border-radius: 6px;
213
+ color: var(--ink);
214
+ font: 500 .88rem var(--font);
215
+ cursor: pointer;
216
+ width: 100%;
217
+ text-align: left;
218
+ transition: border-color .15s ease, background-color .15s ease;
219
+ }
220
+ .settings-trigger:hover {
221
+ border-color: var(--accent);
222
+ background: var(--code-bg);
223
+ }
224
+ .settings-trigger-icon {
225
+ font-size: 1.05rem;
226
+ line-height: 1;
227
+ }
228
+
229
+ .drawer-backdrop {
230
+ position: fixed;
231
+ inset: 0;
232
+ background: rgba(0, 0, 0, 0.45);
233
+ backdrop-filter: blur(2px);
234
+ opacity: 0;
235
+ pointer-events: none;
236
+ transition: opacity .25s ease;
237
+ z-index: 900;
238
+ }
239
+ .drawer-backdrop.open {
240
+ opacity: 1;
241
+ pointer-events: auto;
242
+ }
243
+
244
+ .drawer {
245
+ position: fixed;
246
+ left: 0;
247
+ top: 0;
248
+ bottom: 0;
249
+ width: 300px;
250
+ max-width: 85vw;
251
+ background: var(--surface);
252
+ border-right: 1px solid var(--line);
253
+ box-shadow: 4px 0 24px rgba(0, 0, 0, 0.25);
254
+ z-index: 1000;
255
+ display: flex;
256
+ flex-direction: column;
257
+ transform: translateX(-100%);
258
+ transition: transform .25s ease;
259
+ }
260
+ .drawer.open {
261
+ transform: translateX(0);
262
+ }
263
+
264
+ .drawer-header {
265
+ padding: 16px 20px;
266
+ border-bottom: 1px solid var(--line);
267
+ display: flex;
268
+ justify-content: space-between;
269
+ align-items: center;
270
+ }
271
+ .drawer-header h3 {
272
+ margin: 0;
273
+ font-size: 1.05rem;
274
+ font-weight: 600;
275
+ }
276
+ .drawer-close {
277
+ background: transparent;
278
+ border: 0;
279
+ color: var(--muted);
280
+ font-size: 1.1rem;
281
+ padding: 4px 8px;
282
+ border-radius: 4px;
283
+ cursor: pointer;
284
+ line-height: 1;
285
+ }
286
+ .drawer-close:hover {
287
+ color: var(--ink);
288
+ background: var(--code-bg);
289
+ }
290
+ .drawer-body {
291
+ padding: 20px;
292
+ display: flex;
293
+ flex-direction: column;
294
+ gap: 18px;
295
+ overflow-y: auto;
296
+ }
297
+ .drawer-field {
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 6px;
301
+ }
302
+ .drawer-field label {
303
+ font-size: .82rem;
304
+ font-weight: 500;
305
+ color: var(--muted);
306
+ }
307
+ .drawer-field select {
308
+ width: 100%;
309
+ }
310
+ .lang-switch, .theme-switch {
311
+ display: flex;
312
+ flex-direction: column;
313
+ gap: 6px;
314
+ }
315
+
316
+ /* Test chat box */
317
+ .chat-box { margin-top: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); display: flex; flex-direction: column; overflow: hidden; }
318
+ .chat-header { padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--surface); display: flex; justify-content: space-between; align-items: center; }
319
+ .chat-header strong { display: block; font-size: .92rem; }
320
+ .chat-header small { font-size: .78rem; }
321
+ .chat-messages { padding: 12px 14px; max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; font-size: .88rem; }
322
+ .chat-msg { max-width: 85%; padding: 8px 12px; border-radius: 8px; line-height: 1.45; word-break: break-word; white-space: pre-wrap; }
323
+ .chat-msg.user { align-self: flex-end; background: var(--accent); color: var(--accent-ink); border-bottom-right-radius: 2px; }
324
+ .chat-msg.assistant { align-self: flex-start; background: var(--surface); border: 1px solid var(--line); color: var(--ink); border-bottom-left-radius: 2px; }
325
+ .chat-msg.error { align-self: flex-start; background: color-mix(in srgb, var(--bad) 15%, transparent); border: 1px solid var(--bad); color: var(--bad); }
326
+ .chat-msg.loading { align-self: flex-start; color: var(--muted); font-style: italic; background: transparent; padding: 4px 8px; }
327
+ .chat-form { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--line); background: var(--surface); }
328
+ .chat-form input { flex: 1; min-width: 0; }
329
+ .chat-form button { flex-shrink: 0; }
330
+