feat(local-model): a memory map and real colour in the chooser, the button first

The owner's verdict on the chooser: nice, but not nice enough, too much just
blue. He also wants "Run a local model" on top of the start screen, above
Run Claude Code.

The memory bar is now a map: the model, its context and the brief peaks side
by side on the track of what this Mac can give a model, each with its size in
a legend, and the verdict on the fit beside the figure. recommend() sends what
the need is made of as `Candidate.memory`.

Colour now carries meaning: green for good and tested, amber for tight, orange
for slow, teal for the context. Violet is the feature's own colour, on its
start screen button and its main action. Every row has a size tile and a speed
gauge scaled to the fastest row in the list. The light skins get deeper values
of the same colours, and the window is 40 px taller to hold it without
scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-07 03:21:35 +01:00
1 parent 04f232f239
commit eb91279a3b
8 files changed
+432 -111

No files matched your search

+5
View File
@@ -295,6 +295,11 @@ function buildCandidate(
...(harness.row.slowFirstTurn ? { slowFirstTurn: true } : {}),
needBytes: need,
needGb: formatGb(need),
memory: {
modelBytes: model.bytes + model.overheadBytes,
contextBytes: model.heldBytesPerToken * ctx,
peakBytes: model.peakExtraBytes,
},
downloadBytes: model.bytes,
downloadGb: formatGb(model.bytes),
budgetShare: round(need / budgetBytes, 3),
+6
View File
@@ -481,6 +481,12 @@ export interface Candidate {
needBytes: number;
/** `needBytes` as decimal GB text, no unit. */
needGb: string;
/**
* What `needBytes` is made of, for the chooser's memory map: the model (`bytes +
* overheadBytes`), its context in use (`heldBytesPerToken * context`) and the brief peaks
* (`peakExtraBytes`). The three add up to `needBytes`.
*/
memory?: { modelBytes: number; contextBytes: number; peakBytes: number };
/** The model download in bytes (`LocalModelEntry.bytes`). */
downloadBytes: number;
/** `downloadBytes` as decimal GB text, no unit. */
+6 -3
View File
@@ -1435,9 +1435,12 @@
estimated: '估算',
'The selected model needs {share}% of the memory this Mac can give a model':
'所选模型需要此 Mac 可分配给模型的内存的 {share}%',
'The model at its peak, with all {context} tokens of context in use':
'模型的峰值占用({context} 个 Token 的上下文全部用满时)',
'What stays free': '剩余可用',
'The model': '模型',
'Context, {context} tokens': '上下文,{context} 个 Token',
'Brief peaks': '短时峰值',
'Stays free': '剩余可用',
'Good fit': '宽裕',
'Tight fit': '偏紧',
'Models that fit this Mac': '此 Mac 装得下的模型',
Speed: '速度',
Download: '下载',
+3 -3
View File
@@ -510,10 +510,9 @@
it sits under them as a quiet link; settings-ui.js rewrites its
contents on every state change and owns its inline display. -->
<div class="welcome-actions">
<div class="welcome-cli-actions" id="welcomeCliActions"></div>
<!-- A model that runs on the Mac the server runs on (local-model-ui.js).
One button in the chip family, a sibling of the launcher slot and
never inside it (renderWelcomeCliActions empties that slot on every
The first thing on the start screen, above the launchers, in the size of
the primary button. A sibling of the launcher slot and never inside it (renderWelcomeCliActions empties that slot on every
state change). Ships `hidden`: only that module reveals it, once the
server has answered for a Mac. It opens the chooser window
(#localModelModal) and the module writes its label through its own
@@ -522,6 +521,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9.5 2.5v3.5M14.5 2.5v3.5M9.5 18v3.5M14.5 18v3.5M2.5 9.5H6M2.5 14.5H6M18 9.5h3.5M18 14.5h3.5"/></svg>
<span class="welcome-label" id="welcomeLocalModelLabel">Run a local model</span>
</button>
<div class="welcome-cli-actions" id="welcomeCliActions"></div>
<button type="button" class="welcome-tunnel-link" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel
+97 -23
View File
@@ -14,7 +14,7 @@
*
* ## Three surfaces, one window
*
* - Welcome screen: ONE button (`#welcomeLocalModelBtn`) under the launchers, in the chip
* - Welcome screen: ONE large button (`#welcomeLocalModelBtn`) above the launchers, on the chip
* family. "Run a local model", or "Local model" once one is installed. Ships `hidden` and
* shows only once the server has answered for a Mac.
* - App Settings > Models: one row (`#localModelSettings`), the state in a few words, and a
@@ -324,6 +324,72 @@ function localModelBar({ share, role, label, kind, from }) {
return bar;
}
/**
* The memory map: one bar for what this Mac can give a model, split into what the selected
* model takes of it. The track that stays is what stays free. A candidate without its parts
* (`memory`) gets one plain fill.
*/
function localModelMemoryBar(candidate, budgetBytes, last, label) {
const percent = (bytes) => (budgetBytes > 0 ? Math.max(0, (bytes / budgetBytes) * 100) : 0);
const parts = candidate.memory;
const bar = localModelBar({ share: candidate.budgetShare, role: 'meter', kind: 'memory', from: last.memory, label });
if (!parts) return bar;
const fills = [
['model', parts.modelBytes],
['context', parts.contextBytes],
['peak', parts.peakBytes],
];
bar.replaceChildren(
...fills.map(([name, bytes]) => {
const to = Math.min(100, Math.round(percent(bytes) * 10) / 10);
const fill = localModelEl('div', `lm-bar-fill lm-part-${name}`);
fill.dataset.kind = `memory-${name}`;
fill.dataset.to = String(to);
const from = last[fill.dataset.kind];
fill.style.width = `${Number.isFinite(from) ? from : to}%`;
return fill;
})
);
return bar;
}
/** The legend under the memory map: a swatch, what it stands for and its size in whole GB. */
function localModelMemoryParts(candidate, budgetBytes) {
const parts = candidate.memory;
if (!parts) return null;
const gb = (bytes) => localModelGb(String(Math.round(Math.max(0, bytes) / 1e9)));
const free = budgetBytes - parts.modelBytes - parts.contextBytes - parts.peakBytes;
const rows = [
['model', localModelT('The model'), parts.modelBytes],
[
'context',
localModelT('Context, {context} tokens', { context: localModelCount(candidate.context) }),
parts.contextBytes,
],
['peak', localModelT('Brief peaks'), parts.peakBytes],
['free', localModelT('Stays free'), free],
];
const legend = localModelEl('div', 'lm-legend');
for (const [name, text, bytes] of rows) {
const item = localModelEl('span', 'lm-legend-item');
item.appendChild(localModelEl('span', `lm-swatch lm-part-${name}`));
item.appendChild(localModelEl('span', 'lm-legend-name', text));
item.appendChild(localModelEl('span', 'lm-legend-size', gb(bytes)));
legend.appendChild(item);
}
return legend;
}
/** The size tile of a model row: its billions of parameters, coloured by how large it is. */
function localModelTile(candidate) {
const size = Number(candidate.paramsB);
if (!Number.isFinite(size) || size <= 0) return localModelEl('span', 'lm-tile');
const tone = size >= 30 ? 'large' : size >= 15 ? 'mid' : 'small';
const tile = localModelEl('span', `lm-tile lm-tile-${tone}`, `${Math.floor(size)}B`);
tile.setAttribute('aria-hidden', 'true');
return tile;
}
/**
* One row of the confirm screen's table: what is fetched, how large it is, and under the name
* a second line that says what it is (`note`) and, for a coding agent, the literal command of
@@ -944,8 +1010,8 @@ Object.assign(CodemanApp.prototype, {
);
},
/** One model row of the chooser: a radio with the name, the tag, the speed and the download. */
_localModelRow(candidate, { checked, tabStop }) {
/** One model row of the chooser: radio, size tile, name, tag, speed with its gauge, download. */
_localModelRow(candidate, { checked, tabStop, topSpeed }) {
const row = localModelEl('div', 'lm-model');
row.setAttribute('role', 'radio');
row.setAttribute('aria-checked', checked ? 'true' : 'false');
@@ -954,6 +1020,7 @@ Object.assign(CodemanApp.prototype, {
row.dataset.modelId = candidate.modelId;
row.appendChild(localModelEl('span', 'lm-radio'));
if (checked) row.classList.add('lm-model-on');
row.appendChild(localModelTile(candidate));
row.appendChild(localModelEl('span', 'lm-model-name', String(candidate.modelLabel || candidate.modelId)));
const tagSlot = localModelEl('span', 'lm-model-tag');
const label = candidate.tag && typeof candidate.tag.label === 'string' ? candidate.tag.label : '';
@@ -961,10 +1028,23 @@ Object.assign(CodemanApp.prototype, {
const tag = localModelEl('span', 'lm-tag', localModelT(label));
// The tags of the row the window opens on take the accent. Every other tag is quiet.
if (LOCAL_MODEL_ACCENT_TAGS.includes(candidate.tag.kind)) tag.classList.add('lm-tag-accent');
tag.classList.add(`lm-tag-${candidate.tag.kind}`);
tagSlot.appendChild(tag);
}
row.appendChild(tagSlot);
row.appendChild(localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate)));
const speed = localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate));
const gauge = localModelEl(
'span',
`lm-gauge lm-gauge-${candidate.speed === 'slow' ? 'slow' : candidate.speed === 'ok' ? 'ok' : 'unknown'}`
);
const fast = Number(candidate.tokensPerSecond);
if (Number.isFinite(fast) && topSpeed > 0) {
const needle = localModelEl('span', 'lm-gauge-fill');
needle.style.width = `${Math.max(6, Math.round((fast / topSpeed) * 100))}%`;
gauge.appendChild(needle);
}
speed.appendChild(gauge);
row.appendChild(speed);
row.appendChild(localModelEl('span', 'lm-model-size', localModelGb(candidate.downloadGb)));
return row;
},
@@ -989,29 +1069,22 @@ Object.assign(CodemanApp.prototype, {
if (recommendation.budgetSource === 'estimated') {
head.appendChild(localModelEl('span', 'lm-figure-aside', localModelT('estimated')));
}
const fitWord =
candidate.fit === 'tight' ? localModelT('Tight fit') : candidate.fit === 'good' ? localModelT('Good fit') : '';
if (fitWord) head.appendChild(localModelEl('span', `lm-fit lm-fit-${candidate.fit}`, fitWord));
meter.appendChild(head);
const share = String(Math.round((Number.isFinite(candidate.budgetShare) ? candidate.budgetShare : 0) * 100));
// The bar is the model at its PEAK, split into what makes that peak.
meter.appendChild(
localModelBar({
share: candidate.budgetShare,
role: 'meter',
kind: 'memory',
from: last.memory,
label: localModelT('The selected model needs {share}% of the memory this Mac can give a model', { share }),
})
);
const legend = localModelEl('div', 'lm-legend');
const context = localModelCount(candidate.context);
// The fill is the model at its PEAK. The installed line uses the same words for it.
legend.appendChild(
localModelEl(
'span',
'',
localModelT('The model at its peak, with all {context} tokens of context in use', { context })
localModelMemoryBar(
candidate,
Number(recommendation.budgetBytes),
last,
localModelT('The selected model needs {share}% of the memory this Mac can give a model', { share })
)
);
legend.appendChild(localModelEl('span', '', localModelT('What stays free')));
meter.appendChild(legend);
const legend = localModelMemoryParts(candidate, Number(recommendation.budgetBytes));
if (legend) meter.appendChild(legend);
parts.push(meter);
// The server's headline: its word for a pick it has run on a Mac like this one labels the
@@ -1035,9 +1108,10 @@ Object.assign(CodemanApp.prototype, {
const group = localModelEl('div', 'lm-models');
group.setAttribute('role', 'radiogroup');
group.setAttribute('aria-labelledby', 'localModelListLabel');
const topSpeed = Math.max(0, ...candidates.map((row) => Number(row.tokensPerSecond) || 0));
for (const row of candidates) {
const checked = row === candidate;
const el = this._localModelRow(row, { checked, tabStop: checked });
const el = this._localModelRow(row, { checked, tabStop: checked, topSpeed });
el.addEventListener('click', () => this.selectLocalModel(row.modelId));
group.appendChild(el);
}
+243 -65
View File
@@ -22327,31 +22327,55 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
body scrolling between them. A click never moves the next button away.
═══════════════════════════════════════════════════════════════ */
/* The welcome button: a second large button under the launchers, in the size
of the primary one, so a model on this Mac is offered as a main way in and
not as a footnote (the owner asked for it big and in the middle). It keeps
the chip's markup and the chip family's rules for everything it does not
restate here. */
/* The feature's own colours. Violet is what the local model is known by, on
the start screen and in its window; the others carry meaning (green good,
amber tight, orange slow, teal the context). All of them are set here, once,
and the light skins get deeper values so they hold on a pale page. */
:root {
--lm-violet: #a78bfa;
--lm-teal: #2dd4bf;
--lm-amber: #f5c451;
--lm-orange: #fb923c;
--lm-good: #4ade80;
--lm-cta-a: #8567f4;
--lm-cta-b: #6346e0;
--lm-cta-hover-a: #9479f7;
--lm-cta-hover-b: #7054ea;
}
html[data-skin='paper-gray'],
html[data-skin='solarized-light'],
html[data-skin='catppuccin-latte'],
html[data-skin='rose-pine-dawn'] {
--lm-violet: #6d3fd8;
--lm-teal: #0b8378;
--lm-amber: #a86a00;
--lm-orange: #c2500f;
--lm-good: #16803c;
}
/* The welcome button: the first thing on the start screen, above the launchers,
in the size of the primary button and in the feature's violet. */
.welcome-local-model {
margin-top: 1.1rem;
margin-bottom: 1.1rem;
height: 46px;
gap: 0.65rem;
padding: 0 1.4rem 0 0.45rem;
border: 1px solid var(--accent);
border: 1px solid var(--lm-cta-a);
border-radius: 23px;
background: var(--control-bg);
color: var(--accent);
background: linear-gradient(135deg, var(--lm-cta-a), var(--lm-cta-b));
color: #fff;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.005em;
box-shadow: 0 8px 24px -14px rgba(var(--accent-rgb), 0.5);
box-shadow: 0 8px 24px -12px rgba(99, 70, 224, 0.75), inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.welcome-local-model:hover,
.welcome-local-model:focus-visible {
background: rgba(var(--accent-rgb), 0.12);
color: var(--accent);
border-color: var(--accent);
border-color: var(--lm-cta-hover-a);
background: linear-gradient(135deg, var(--lm-cta-hover-a), var(--lm-cta-hover-b));
color: #fff;
}
/* `.welcome-chip` is inline-flex, so `[hidden]` needs re-asserting or the
@@ -22368,7 +22392,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
padding: 8px;
box-sizing: border-box;
border-radius: 50%;
background: rgba(var(--accent-rgb), 0.16);
background: rgba(255, 255, 255, 0.18);
}
/* The window: App Settings' chrome (`.modal`, `.modal-header`, the boxed
@@ -22380,7 +22404,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
flex-direction: column;
width: min(640px, 94vw);
max-width: min(640px, 94vw);
height: min(600px, 88vh);
height: min(640px, 88vh);
max-height: 88vh;
}
@@ -22518,7 +22542,17 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
.lm-meter {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.6rem;
padding: 0.8rem 0.9rem 0.75rem;
border: 1px solid color-mix(in srgb, var(--lm-violet) 22%, var(--border));
border-radius: 12px;
background:
linear-gradient(
120deg,
color-mix(in srgb, var(--lm-violet) 11%, transparent),
color-mix(in srgb, var(--lm-teal) 6%, transparent) 75%
),
var(--bg-input);
}
.lm-meter-head {
@@ -22529,7 +22563,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-figure {
font-size: 1.06rem;
font-size: 1.12rem;
font-weight: 680;
letter-spacing: -0.012em;
color: var(--text);
@@ -22547,40 +22581,103 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
font-variant-numeric: tabular-nums;
}
.lm-bar {
height: 10px;
/* How the selected model fits, in the colour of its verdict. */
.lm-fit {
align-self: center;
margin-inline-start: auto;
padding: 0.12rem 0.55rem;
border-radius: 999px;
background: var(--text-muted);
font-size: 0.72rem;
font-weight: 650;
white-space: nowrap;
}
.lm-fit-good {
background: color-mix(in srgb, var(--lm-good) 16%, transparent);
color: var(--lm-good);
}
.lm-fit-tight {
background: color-mix(in srgb, var(--lm-amber) 16%, transparent);
color: var(--lm-amber);
}
/* The bar: the track is what this Mac can give a model. The memory map fills it
with three parts side by side; a download fills it with one. */
.lm-bar {
display: flex;
gap: 2px;
height: 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-muted) 34%, transparent);
overflow: hidden;
}
.lm-bar-fill {
flex: none;
height: 100%;
border-radius: 999px;
background: var(--accent);
border-radius: 2px;
background: linear-gradient(90deg, var(--lm-violet), var(--lm-teal));
transition: width 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.lm-part-model {
background: var(--lm-violet);
}
.lm-part-context {
background: var(--lm-teal);
}
/* The brief peaks are not held all the time, so they are striped. */
.lm-part-peak {
background: repeating-linear-gradient(
-45deg,
var(--lm-amber) 0 3px,
color-mix(in srgb, var(--lm-amber) 45%, transparent) 3px 6px
);
}
.lm-part-free {
background: color-mix(in srgb, var(--text-muted) 34%, transparent);
}
.lm-legend {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
font-size: 0.7rem;
gap: 0.25rem 0.9rem;
font-size: 0.72rem;
line-height: 1.4;
color: var(--text-dim);
}
.lm-legend > :last-child {
text-align: end;
.lm-legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.lm-swatch {
flex-shrink: 0;
width: 9px;
height: 9px;
border-radius: 3px;
}
.lm-legend-size {
font-weight: 620;
color: var(--text);
}
/* ── the models: one grid, so the numbers line up down the list ──── */
/* Radio, name, tag, speed, download. The tag sits next to the name and takes
the room that is left, which pushes the two numbers to the right edge. */
/* Radio, size tile, name, tag, speed, download. The tag sits next to the name
and takes the room that is left, which pushes the two numbers to the right. */
.lm-list {
display: grid;
grid-template-columns: auto minmax(0, max-content) minmax(0, 1fr) auto auto;
column-gap: 0.9rem;
grid-template-columns: auto auto minmax(0, max-content) minmax(0, 1fr) auto auto;
column-gap: 0.75rem;
row-gap: 0.1rem;
/* A row's fill reaches into the window's padding, so what is IN a row lines
up with every other line of the window on both edges. */
@@ -22613,7 +22710,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-caption-label {
grid-column: 1 / span 3;
grid-column: 1 / span 4;
}
/* The server's word for a pick it has run on a Mac like this one stands where
@@ -22630,8 +22727,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-model {
min-height: 2.35rem;
border-radius: 7px;
min-height: 2.6rem;
border-radius: 9px;
cursor: pointer;
transition: background var(--transition-smooth);
}
@@ -22641,15 +22738,19 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-model:focus-visible {
outline: 2px solid var(--accent);
outline: 2px solid var(--lm-violet);
outline-offset: -2px;
}
/* Selected: the accent on the reading edge and a faint fill. Nothing else. */
/* Selected: violet on the reading edge and a fill that fades out to the right. */
.lm-model-on,
.lm-model-on:hover {
border-inline-start-color: var(--accent);
background: rgba(var(--accent-rgb), 0.075);
border-inline-start-color: var(--lm-violet);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--lm-violet) 17%, transparent),
color-mix(in srgb, var(--lm-violet) 4%, transparent)
);
}
.lm-radio {
@@ -22661,8 +22762,38 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-model-on .lm-radio {
border-color: var(--accent);
background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px);
border-color: var(--lm-violet);
background: radial-gradient(circle, var(--lm-violet) 0 3px, transparent 3.5px);
}
/* The size tile: a model's billions of parameters, coloured by how large it is. */
.lm-tile {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 34px;
height: 25px;
border-radius: 7px;
font-size: 0.68rem;
font-weight: 720;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.lm-tile-large {
background: color-mix(in srgb, var(--lm-violet) 20%, transparent);
color: var(--lm-violet);
}
.lm-tile-mid {
background: color-mix(in srgb, var(--lm-teal) 18%, transparent);
color: var(--lm-teal);
}
.lm-tile-small {
background: color-mix(in srgb, var(--text-muted) 22%, transparent);
color: var(--text-dim);
}
.lm-model-name {
@@ -22678,21 +22809,37 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
justify-content: flex-start;
}
/* A tag is text with a hairline, not a coloured pill. The accent is for the
pick (and for a row that was run on a Mac like this one). */
/* A tag says one thing about a row, in the colour of what it says. */
.lm-tag {
padding: 0.05rem 0.42rem;
border: 1px solid var(--border-light);
padding: 0.08rem 0.45rem;
border-radius: 5px;
background: color-mix(in srgb, var(--text-muted) 16%, transparent);
font-size: 0.66rem;
font-weight: 600;
line-height: 1.5;
color: var(--text-dim);
white-space: nowrap;
}
.lm-tag-accent {
border-color: rgba(var(--accent-rgb), 0.5);
color: var(--accent);
.lm-tag-tested,
.lm-tag-best {
background: color-mix(in srgb, var(--lm-good) 15%, transparent);
color: var(--lm-good);
}
.lm-tag-tested::before {
content: '\2713\00a0';
}
.lm-tag-best-tight,
.lm-tag-tight {
background: color-mix(in srgb, var(--lm-amber) 15%, transparent);
color: var(--lm-amber);
}
.lm-tag-slow {
background: color-mix(in srgb, var(--lm-orange) 15%, transparent);
color: var(--lm-orange);
}
.lm-model-speed,
@@ -22703,10 +22850,41 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-model-speed {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
font-size: 0.76rem;
color: var(--text-dim);
}
/* The speed at a glance: full is the fastest model in the list. */
.lm-gauge {
display: block;
width: 64px;
height: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-muted) 26%, transparent);
overflow: hidden;
}
.lm-gauge-fill {
display: block;
height: 100%;
border-radius: 999px;
background: var(--lm-good);
}
.lm-gauge-slow .lm-gauge-fill {
background: var(--lm-orange);
}
.lm-gauge-unknown {
background: none;
border-top: 1px dashed color-mix(in srgb, var(--text-muted) 55%, transparent);
height: 0;
}
.lm-model-size {
font-size: 0.8rem;
font-weight: 560;
@@ -22722,8 +22900,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
.lm-caption,
.lm-model {
grid-template-columns: 14px 10.5rem minmax(0, 1fr) 8.6rem 4.4rem;
column-gap: 0.9rem;
grid-template-columns: 14px 34px 9.5rem minmax(0, 1fr) 7rem 4rem;
column-gap: 0.75rem;
}
.lm-model {
@@ -22758,7 +22936,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-select:focus-visible {
border-color: var(--accent);
border-color: var(--lm-violet);
}
.lm-runs-hint {
@@ -22895,8 +23073,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-step-done .lm-step-mark {
border-color: var(--accent);
background: var(--accent);
border-color: var(--lm-good);
background: var(--lm-good);
}
/* The check of a finished step, drawn from two borders in the page colour. */
@@ -22907,7 +23085,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
top: 1px;
width: 3px;
height: 6px;
border: solid var(--accent-ink);
border: solid var(--bg-card);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg);
}
@@ -22919,8 +23097,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-step-now .lm-step-mark {
border-color: var(--accent);
background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px);
border-color: var(--lm-violet);
background: radial-gradient(circle, var(--lm-violet) 0 3px, transparent 3.5px);
}
.lm-step-stopped .lm-step-mark {
@@ -22947,7 +23125,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
.lm-dot-on {
background: var(--green);
background: var(--lm-good);
}
.lm-state-text {
@@ -23035,7 +23213,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
.lm-check input {
margin: 0;
accent-color: var(--accent);
accent-color: var(--lm-violet);
}
/* ── the foot: one promise on the left, the action on the right ──── */
@@ -23081,21 +23259,21 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
font-weight: 700;
}
/* OG has no designed --accent-ink: the main action takes the deeper blue and
light text of that skin's own Run button, as `.welcome-primary` does. Keep
the two in step. */
html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn {
border-color: rgba(96, 165, 250, 0.5);
background: linear-gradient(135deg, #1e4fb8 0%, #2563eb 100%);
/* The main action is the feature's violet on every skin, like its start screen
button: white text on the same gradient, so no skin needs a rule of its own. */
.local-model-window .wt-btn-primary.lm-btn {
border-color: var(--lm-cta-a);
background: linear-gradient(135deg, var(--lm-cta-a), var(--lm-cta-b));
color: #fff;
}
html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #2454a8 0%, #2d6cf0 100%);
.local-model-window .wt-btn-primary.lm-btn:hover:not(:disabled) {
border-color: var(--lm-cta-hover-a);
background: linear-gradient(135deg, var(--lm-cta-hover-a), var(--lm-cta-hover-b));
}
.local-model-window .wt-btn.lm-btn:focus-visible {
outline: 2px solid var(--accent);
outline: 2px solid var(--lm-violet);
outline-offset: 2px;
}