style(local-model): white for the feature, built like the primary launcher

The violet button was the one thing on the start screen outside the app's
blue, and it carried white text and a see-through disc where its neighbour
has dark ink and a solid disc. It is now white with dark ink and a solid
disc, the same on the Install button, and the chooser's selected row, tile
and model bar follow. Light skins turn it over to dark, since a white button
vanishes on a pale page. The context takes the app's blue.

The tokens are named for what they mean now (--lm-brand, --lm-context).

Brings test/local-model-ui.browser.test.ts up to the redesign it had fallen
behind: it is outside the gate, so the button's 46 px, the 12 px bar and the
memory panel's edges had gone unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-07 04:23:02 +01:00
1 parent aa76b1b304
commit be8fc2980a
3 files changed
+85 -61

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: violet for the feature, green good, amber tight, orange slow, teal the context, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `Line truncated
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "RecoLine truncated
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
+58 -42
View File
@@ -22327,37 +22327,51 @@ 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 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, rose the brief peaks). All of them are set here, once,
and the light skins get deeper values so they hold on a pale page. */
/* The feature's own colours. White is what the local model is known by, on the
start screen and in its window: it stands apart from the app's blue instead of
fighting it, and on a pale page the same idea turns to dark ink. The others
carry meaning (blue the context, green good, amber tight, orange slow, rose the
brief peaks). All of them are set here, once. */
:root {
--lm-violet: #a78bfa;
--lm-teal: #2dd4bf;
--lm-brand: #e6edf5;
--lm-context: #38b6f0;
--lm-amber: #f5c451;
--lm-orange: #fb923c;
--lm-rose: #f58ba8;
--lm-good: #4ade80;
--lm-cta-a: #8567f4;
--lm-cta-b: #6346e0;
--lm-cta-hover-a: #9479f7;
--lm-cta-hover-b: #7054ea;
--lm-cta-a: #ffffff;
--lm-cta-b: #dfe7f0;
--lm-cta-hover-a: #ffffff;
--lm-cta-hover-b: #eef3f8;
--lm-cta-ink: #11151c;
--lm-cta-disc: #11151c;
--lm-cta-glyph: #ffffff;
--lm-cta-glow: rgba(223, 231, 240, 0.45);
}
/* A white button is gone on a pale page, so the light skins turn it over. */
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-brand: #2a3140;
--lm-context: #1c6fb8;
--lm-amber: #a86a00;
--lm-orange: #c2500f;
--lm-rose: #c43a68;
--lm-good: #16803c;
--lm-cta-a: #333b4a;
--lm-cta-b: #11151c;
--lm-cta-hover-a: #434c5c;
--lm-cta-hover-b: #1f2530;
--lm-cta-ink: #ffffff;
--lm-cta-disc: #ffffff;
--lm-cta-glyph: #11151c;
--lm-cta-glow: rgba(17, 21, 28, 0.4);
}
/* 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. */
built like the primary button (dark ink, a solid disc) in the feature's colour. */
.welcome-local-model {
margin-bottom: 1.1rem;
height: 46px;
@@ -22366,18 +22380,18 @@ html[data-skin='rose-pine-dawn'] {
border: 1px solid var(--lm-cta-a);
border-radius: 23px;
background: linear-gradient(135deg, var(--lm-cta-a), var(--lm-cta-b));
color: #fff;
color: var(--lm-cta-ink);
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.005em;
box-shadow: 0 8px 24px -12px rgba(99, 70, 224, 0.75), inset 0 1px 0 rgba(255, 255, 255, 0.18);
box-shadow: 0 8px 24px -12px var(--lm-cta-glow), inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
.welcome-local-model:hover,
.welcome-local-model:focus-visible {
border-color: var(--lm-cta-hover-a);
background: linear-gradient(135deg, var(--lm-cta-hover-a), var(--lm-cta-hover-b));
color: #fff;
color: var(--lm-cta-ink);
}
/* `.welcome-chip` is inline-flex, so `[hidden]` needs re-asserting or the
@@ -22394,7 +22408,8 @@ html[data-skin='rose-pine-dawn'] {
padding: 8px;
box-sizing: border-box;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
background: var(--lm-cta-disc);
color: var(--lm-cta-glyph);
}
/* The window: App Settings' chrome (`.modal`, `.modal-header`, the boxed
@@ -22546,13 +22561,13 @@ html[data-skin='rose-pine-dawn'] {
flex-direction: column;
gap: 0.6rem;
padding: 0.8rem 0.9rem 0.75rem;
border: 1px solid color-mix(in srgb, var(--lm-violet) 22%, var(--border));
border: 1px solid color-mix(in srgb, var(--lm-brand) 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%
color-mix(in srgb, var(--lm-brand) 11%, transparent),
color-mix(in srgb, var(--lm-context) 6%, transparent) 75%
),
var(--bg-input);
}
@@ -22619,16 +22634,16 @@ html[data-skin='rose-pine-dawn'] {
flex: none;
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--lm-violet), var(--lm-teal));
background: linear-gradient(90deg, var(--lm-brand), var(--lm-context));
transition: width 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.lm-part-model {
background: var(--lm-violet);
background: var(--lm-brand);
}
.lm-part-context {
background: var(--lm-teal);
background: var(--lm-context);
}
/* The brief peaks are not held all the time, so they are striped. */
@@ -22740,18 +22755,18 @@ html[data-skin='rose-pine-dawn'] {
}
.lm-model:focus-visible {
outline: 2px solid var(--lm-violet);
outline: 2px solid var(--lm-brand);
outline-offset: -2px;
}
/* Selected: violet on the reading edge and a fill that fades out to the right. */
/* Selected: the feature's colour 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(--lm-violet);
border-inline-start-color: var(--lm-brand);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--lm-violet) 17%, transparent),
color-mix(in srgb, var(--lm-violet) 4%, transparent)
color-mix(in srgb, var(--lm-brand) 17%, transparent),
color-mix(in srgb, var(--lm-brand) 4%, transparent)
);
}
@@ -22764,8 +22779,8 @@ html[data-skin='rose-pine-dawn'] {
}
.lm-model-on .lm-radio {
border-color: var(--lm-violet);
background: radial-gradient(circle, var(--lm-violet) 0 3px, transparent 3.5px);
border-color: var(--lm-brand);
background: radial-gradient(circle, var(--lm-brand) 0 3px, transparent 3.5px);
}
/* The size tile: a model's billions of parameters, coloured by how large it is. */
@@ -22784,13 +22799,13 @@ html[data-skin='rose-pine-dawn'] {
}
.lm-tile-large {
background: color-mix(in srgb, var(--lm-violet) 20%, transparent);
color: var(--lm-violet);
background: color-mix(in srgb, var(--lm-brand) 20%, transparent);
color: var(--lm-brand);
}
.lm-tile-mid {
background: color-mix(in srgb, var(--lm-teal) 18%, transparent);
color: var(--lm-teal);
background: color-mix(in srgb, var(--lm-context) 18%, transparent);
color: var(--lm-context);
}
.lm-tile-small {
@@ -22938,7 +22953,7 @@ html[data-skin='rose-pine-dawn'] {
}
.lm-select:focus-visible {
border-color: var(--lm-violet);
border-color: var(--lm-brand);
}
.lm-runs-hint {
@@ -23099,8 +23114,8 @@ html[data-skin='rose-pine-dawn'] {
}
.lm-step-now .lm-step-mark {
border-color: var(--lm-violet);
background: radial-gradient(circle, var(--lm-violet) 0 3px, transparent 3.5px);
border-color: var(--lm-brand);
background: radial-gradient(circle, var(--lm-brand) 0 3px, transparent 3.5px);
}
.lm-step-stopped .lm-step-mark {
@@ -23215,7 +23230,7 @@ html[data-skin='rose-pine-dawn'] {
.lm-check input {
margin: 0;
accent-color: var(--lm-violet);
accent-color: var(--lm-brand);
}
/* ── the foot: one promise on the left, the action on the right ──── */
@@ -23261,21 +23276,22 @@ html[data-skin='rose-pine-dawn'] {
font-weight: 700;
}
/* 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. */
/* The main action wears the same gradient and ink as the start screen button,
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;
color: var(--lm-cta-ink);
}
.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));
color: var(--lm-cta-ink);
}
.local-model-window .wt-btn.lm-btn:focus-visible {
outline: 2px solid var(--lm-violet);
outline: 2px solid var(--lm-brand);
outline-offset: 2px;
}