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

+2
View File
@@ -516,6 +516,7 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => {
// 23,105,798,956 + 37,000 x 131,072 + 3,000,000,000 + 5,000,000,000.
needBytes: 35_955_462_956,
needGb: '36.0',
memory: { modelBytes: 26_105_798_956, contextBytes: 4_849_664_000, peakBytes: 5_000_000_000 },
// What a Mac really downloads (the MLX build), not the 22.6 GB of the registry manifest.
downloadBytes: 23_105_798_956,
downloadGb: '23.1',
@@ -2059,6 +2060,7 @@ describe('the shape of the answer', () => {
'installBytes',
'installGb',
'license',
'memory',
'modelId',
'modelLabel',
'needBytes',
+70 -17
View File
@@ -528,7 +528,7 @@ async function opened(page: Mounted, first: LocalModelStatus, verb?: ApiHandler)
// ---------- The page ----------
describe('the markup the module fills', () => {
it('ships one hidden button among the welcome launchers, and no card', () => {
it('ships one hidden button above the welcome launchers, and no card', () => {
const button = REAL_DOCUMENT.getElementById('welcomeLocalModelBtn')!;
expect(button.hidden).toBe(true);
expect(button.tagName).toBe('BUTTON');
@@ -538,8 +538,9 @@ describe('the markup the module fills', () => {
expect(button.parentElement?.classList.contains('welcome-actions')).toBe(true);
expect(button.closest('#welcomeCliActions')).toBeNull();
const order = [...button.parentElement!.children].map((el) => el.id);
expect(order.indexOf('welcomeLocalModelBtn')).toBeGreaterThan(order.indexOf('welcomeCliActions'));
expect(order.indexOf('welcomeLocalModelBtn')).toBeLessThan(order.indexOf('welcomeTunnelBtn'));
// First on the start screen: the owner wants it above Run Claude Code.
expect(order.indexOf('welcomeLocalModelBtn')).toBe(0);
expect(order.indexOf('welcomeLocalModelBtn')).toBeLessThan(order.indexOf('welcomeCliActions'));
expect(button.getAttribute('onclick')).toBe('app.openLocalModelWindow()');
expect(button.hasAttribute('data-i18n-skip')).toBe(true);
// The read-only card of the first version is gone, with its styles.
@@ -833,10 +834,6 @@ describe('the chooser', () => {
expect(texts(body, '.lm-hw')).toEqual(['Apple M5 Pro, 48 GB memory, 897 GB free']);
// An estimate, in whole GB: "about 36 of 38", never "36.0 of 37.6".
expect(texts(body, '.lm-figure')).toEqual(['About 36 of 38 GB of memory']);
expect(texts(body, '.lm-legend span')).toEqual([
'The model at its peak, with all 131,072 tokens of context in use',
'What stays free',
]);
const group = body.querySelector('[role="radiogroup"]')!;
const label = page.document.getElementById(group.getAttribute('aria-labelledby')!)!;
@@ -1072,20 +1069,76 @@ describe('the memory bar', () => {
'The selected model needs 96% of the memory this Mac can give a model'
);
const expected: Array<[number, string, string, string]> = [
[1, '81', 'About 31 of 38 GB of memory', 'The model at its peak, with all 131,072 tokens of context in use'],
[4, '45', 'About 17 of 38 GB of memory', 'The model at its peak, with all 65,536 tokens of context in use'],
[0, '96', 'About 36 of 38 GB of memory', 'The model at its peak, with all 131,072 tokens of context in use'],
const expected: Array<[number, string, string]> = [
[1, '81', 'About 31 of 38 GB of memory'],
[4, '45', 'About 17 of 38 GB of memory'],
[0, '96', 'About 36 of 38 GB of memory'],
];
for (const [index, percent, figure, legend] of expected) {
for (const [index, percent, figure] of expected) {
page.body.querySelectorAll<HTMLElement>('[role="radio"]')[index].click();
expect(meter(page).getAttribute('aria-valuenow')).toBe(percent);
expect(fill(page).style.width).toBe(`${percent}%`);
expect(texts(page.body, '.lm-figure')).toEqual([figure]);
expect(texts(page.body, '.lm-legend span')[0]).toBe(legend);
}
});
it('splits the bar into what the model takes, and names each part with its size', async () => {
const page = mount();
const memory = { modelBytes: 26_105_798_956, contextBytes: 4_849_664_000, peakBytes: 5_000_000_000 };
await opened(
page,
status({
pick: candidate({ memory, fit: 'tight', budgetShare: 0.957, context: 131072 }),
alternatives: [],
budgetBytes: 37_580_963_840,
})
);
const fills = [...page.body.querySelectorAll<HTMLElement>('.lm-bar .lm-bar-fill')];
expect(fills.map((el) => el.dataset.kind)).toEqual(['memory-model', 'memory-context', 'memory-peak']);
expect(fills.map((el) => el.style.width)).toEqual(['69.5%', '12.9%', '13.3%']);
// The meter still says the whole share, for a screen reader.
expect(page.body.querySelector('.lm-bar')!.getAttribute('aria-valuenow')).toBe('96');
expect(texts(page.body, '.lm-legend-name')).toEqual([
'The model',
'Context, 131,072 tokens',
'Brief peaks',
'Stays free',
]);
expect(texts(page.body, '.lm-legend-size')).toEqual(['26 GB', '5 GB', '5 GB', '2 GB']);
expect(texts(page.body, '.lm-fit')).toEqual(['Tight fit']);
expect(page.body.querySelector('.lm-fit')!.classList.contains('lm-fit-tight')).toBe(true);
});
it('gives every row a size tile and a speed gauge scaled to the fastest row', async () => {
const page = mount();
await opened(
page,
status({
pick: candidate({ paramsB: 35, tokensPerSecond: 80, speed: 'ok' }),
alternatives: [
candidate({ modelId: 'b:1', modelLabel: 'B', paramsB: 27.8, tokensPerSecond: 8, speed: 'slow' }),
candidate({ modelId: 'c:1', modelLabel: 'C', paramsB: 9, tokensPerSecond: undefined, speed: 'unknown' }),
],
})
);
expect(texts(page.body, '.lm-tile')).toEqual(['35B', '27B', '9B']);
const tiles = [...page.body.querySelectorAll('.lm-tile')];
expect(tiles.map((el) => el.className)).toEqual([
'lm-tile lm-tile-large',
'lm-tile lm-tile-mid',
'lm-tile lm-tile-small',
]);
const gauges = [...page.body.querySelectorAll('.lm-gauge')];
expect(gauges.map((el) => el.className)).toEqual([
'lm-gauge lm-gauge-ok',
'lm-gauge lm-gauge-slow',
'lm-gauge lm-gauge-unknown',
]);
expect(gauges[0].querySelector<HTMLElement>('.lm-gauge-fill')!.style.width).toBe('100%');
expect(gauges[1].querySelector<HTMLElement>('.lm-gauge-fill')!.style.width).toBe('10%');
expect(gauges[2].querySelector('.lm-gauge-fill')).toBeNull();
});
it('never runs past its track', async () => {
const page = mount();
await opened(page, status({ pick: candidate({ budgetShare: 1.2 }), alternatives: [] }));
@@ -1093,11 +1146,11 @@ describe('the memory bar', () => {
expect(fill(page).style.width).toBe('100%');
});
it('is one flat fill on a track, and stands still when motion is not wanted', () => {
it('moves on a width transition, without a glow, and stands still when motion is not wanted', () => {
const bar = STYLES.match(/\.lm-bar-fill \{[^}]*\}/)![0];
expect(bar).toContain('background: var(--accent);');
expect(bar).not.toMatch(/gradient|box-shadow|animation/);
expect(STYLES).toMatch(/\.lm-bar \{[^}]*height: 10px;[^}]*border-radius: 999px;/);
expect(bar).toContain('transition: width');
expect(bar).not.toMatch(/box-shadow|animation/);
expect(STYLES).toMatch(/\.lm-bar \{[^}]*height: 12px;[^}]*border-radius: 999px;/);
expect(STYLES).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.lm-bar-fill,[^}]*transition: none;/);
});
});