ui(models): polish the variant detail view and scope rendered Markdown

The gallery detail pane rendered every field through the same two-column
label/value row, including the description. Multi-paragraph prose in a value
cell ran eight rows tall at the top of the pane on a ~1200px measure, breaking
the grid's rhythm exactly where the eye enters. Move it into its own full-width
block above the table, capped at a 68ch measure, keeping the label.

Rendered Markdown had no scoped typography anywhere in the app, so a
description opening with `#` inherited the browser default 2em inside a 13px
surface while a `##` further down was indistinguishable from body text. Add a
reusable .markdown-body block mapping h1-h6, paragraphs, lists, links, code,
blockquotes, images and tables onto the existing type scale, and apply it to
every renderMarkdown() consumer: the models detail, the backends detail, both
Manage details and the voice library detail.

Rebalance the variants list so the name leads. Backend and size drop from
badge/secondary weight to muted metadata; the FITS badge goes entirely, since
it was true of nearly every row and so said nothing, while the variant that
does not fit keeps a warning badge and a dimmed name. AUTO-SELECTED stays
marked because it answers what a plain Install produces. Rows share the
parent's grid tracks via subgrid so name, backend, size and status line up
down the list instead of raggedly following name length.

Finally, make each variant row actionable. It looked like a list of choices
but was inert text, with per-variant install hidden behind the split-button
chevron elsewhere; each row is now a button onto the existing
handleInstall(modelId, variant) path, with hover, keyboard focus and a
disabled state while an install is in flight.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-07-19 13:07:25 +00:00
parent 08a434da4f
commit 8130d7ce9d
7 changed files with 410 additions and 33 deletions

View File

@@ -663,6 +663,64 @@ test.describe("Models Gallery - Variant picker", () => {
// Expanding is the second trigger point, so it pays for exactly one fetch.
expect(variantUrls).toHaveLength(1);
});
test("the variant rows line up as columns", async ({ page }) => {
await variantRow(page).click();
const rows = page.locator(".variant-row");
await expect(rows).toHaveCount(4);
const columns = await rows.evaluateAll((els) =>
els.map((el) => ({
backend: el.querySelector(".variant-row__backend").getBoundingClientRect().x,
size: el.querySelector(".variant-row__size").getBoundingClientRect().right,
})),
);
// Names differ in length, so without shared tracks each row would start
// its backend at a different x. Sub-pixel rounding is the only tolerance.
for (const c of columns) {
expect(Math.abs(c.backend - columns[0].backend)).toBeLessThan(1.5);
expect(Math.abs(c.size - columns[0].size)).toBeLessThan(1.5);
}
});
test("only the informative status is badged", async ({ page }) => {
await variantRow(page).click();
const detail = page.locator('td[colspan="8"]');
await expect(detail.locator(".variant-row")).toHaveCount(4);
// "Fits" was true of three rows out of four and said nothing; the row that
// does not fit is the one worth marking.
await expect(detail.getByText("Fits", { exact: true })).toHaveCount(0);
const unfit = detail.locator(".variant-row--unfit");
await expect(unfit).toHaveCount(1);
await expect(unfit).toContainText("llama-model-f16");
await expect(unfit.locator(".badge-warning")).toHaveText("Does not fit");
// Auto-selected still answers "what do I get if I just hit Install".
await expect(
detail.locator(".variant-row", { hasText: "llama-model-q8" }),
).toContainText("Auto-selected");
});
test("clicking a variant row installs that variant", async ({ page }) => {
await variantRow(page).click();
await page
.locator(".variant-row", { hasText: "llama-model-mlx" })
.click();
await expect.poll(() => installUrls.length).toBe(1);
expect(installUrls[0]).toContain("variant=llama-model-mlx");
});
test("a variant row is reachable and actionable from the keyboard", async ({
page,
}) => {
await variantRow(page).click();
const row = page.locator(".variant-row", { hasText: "llama-model-f16" });
await row.focus();
// A build that does not fit stays installable: the explicit choice is an
// override the server honours.
await expect(row).toBeFocused();
await page.keyboard.press("Enter");
await expect.poll(() => installUrls.length).toBe(1);
expect(installUrls[0]).toContain("variant=llama-model-f16");
});
});
// The gallery is heading towards hiding the individual builds a parent entry
@@ -842,6 +900,14 @@ const MARKDOWN_MODELS_RESPONSE = {
installed: false,
tags: ["chat"],
},
{
name: "headings-model",
description:
"# Top Heading\n\nBody copy.\n\n## Sub Heading\n\nMore body copy.",
backend: "llama-cpp",
installed: false,
tags: ["chat"],
},
{
name: "no-description-model",
description: "",
@@ -850,7 +916,7 @@ const MARKDOWN_MODELS_RESPONSE = {
tags: ["chat"],
},
],
availableModels: 2,
availableModels: 3,
installedModels: 0,
};
@@ -918,4 +984,65 @@ test.describe("Models Gallery - Markdown descriptions", () => {
await expect(row).toBeVisible();
await expect(row.locator("div[title='']")).toHaveText("—");
});
test("a heading in the description renders on the UI type scale", async ({
page,
}) => {
await page.locator("tr", { hasText: "headings-model" }).click();
const prose = page.locator(".detail-prose__body.markdown-body");
await expect(prose).toBeVisible();
const h1 = prose.locator("h1");
await expect(h1).toHaveText("Top Heading");
const sizes = await prose.evaluate((el) => {
const px = (sel) =>
parseFloat(getComputedStyle(el.querySelector(sel)).fontSize);
return { h1: px("h1"), h2: px("h2"), p: px("p") };
});
// The bug: an unscoped h1 inherits the browser default 2em, which is 26px
// inside this 13px surface and swamps the pane. The scale tops out at
// --text-xl (1.25rem / 20px), so anything at or above that is the default
// leaking through rather than a styled heading.
expect(sizes.h1).toBeGreaterThan(sizes.p);
expect(sizes.h1).toBeLessThanOrEqual(20);
expect(sizes.h1).toBeGreaterThanOrEqual(14);
// The inverse defect: a subheading that is indistinguishable from body
// text. It must stay below h1 and at or above the body size.
expect(sizes.h2).toBeLessThanOrEqual(sizes.h1);
expect(sizes.h2).toBeGreaterThanOrEqual(sizes.p);
});
test("the description sits outside the label/value grid on a readable measure", async ({
page,
}) => {
await page.locator("tr", { hasText: "headings-model" }).click();
const detail = page.locator('td[colspan="8"]');
// Description is no longer a row of the scalar table.
await expect(detail.locator("table td", { hasText: "Description" })).toHaveCount(
0,
);
await expect(detail.locator(".detail-prose__label")).toHaveText(
"Description",
);
const proseWidth = await page
.locator(".detail-prose__body")
.evaluate((el) => el.getBoundingClientRect().width);
const paneWidth = await detail.evaluate(
(el) => el.getBoundingClientRect().width,
);
// A measure, not the full pane: the cap is a ch count, so the exact pixel
// value moves with the font, but it must stay well inside the pane.
expect(proseWidth).toBeLessThan(paneWidth * 0.85);
});
test("a model without a description renders no prose block", async ({
page,
}) => {
await page.locator("tr", { hasText: "no-description-model" }).click();
const detail = page.locator('td[colspan="8"]');
await expect(detail).toBeVisible();
await expect(detail.locator(".detail-prose")).toHaveCount(0);
// The scalar rows still render, so the pane is not blank.
await expect(detail).toContainText("Backend");
});
});

View File

@@ -117,10 +117,10 @@
"auto": "Auto",
"autoSelected": "Auto-selected",
"base": "Base build",
"fits": "Fits",
"doesNotFit": "Does not fit",
"unknownSize": "Unknown size",
"unknownBackend": "Unknown backend",
"loading": "Loading variants..."
"loading": "Loading variants...",
"installVariant": "Install {{variant}}"
}
}

View File

@@ -9164,3 +9164,233 @@ button.collapsible-header:focus-visible {
.model-chip__state { opacity: 0.85; font-style: normal; }
.node-filter { margin-bottom: var(--spacing-lg); }
.node-detail__metrics { display: flex; gap: var(--spacing-xl); margin: var(--spacing-md) 0 var(--spacing-lg); flex-wrap: wrap; }
/* Rendered Markdown ---------------------------------------------------------
Gallery descriptions, backend descriptions and voice notes are all
author-supplied Markdown pasted from model cards. Left unscoped they inherit
browser defaults, so a description that opens with `#` renders a 2em heading
inside a 13px surface while a `##` further down is indistinguishable from
body text. This maps the whole element set onto the UI type scale so any
description reads as part of the app rather than as a document dropped into
it. Apply it to every element fed by renderMarkdown(). */
.markdown-body {
color: var(--color-text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-normal);
}
.markdown-body > :first-child { margin-top: 0; }
.markdown-body > :last-child { margin-bottom: 0; }
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
line-height: var(--leading-tight);
margin: var(--spacing-md) 0 var(--spacing-xs);
}
.markdown-body h1 { font-size: var(--text-lg); }
.markdown-body h2 { font-size: var(--text-base); }
.markdown-body h3 { font-size: var(--text-sm); }
/* Below h3 the scale has no room left to separate levels by size, so the
remaining depth is carried by case and weight instead. */
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
font-size: var(--text-xs);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.markdown-body p { margin: 0 0 var(--spacing-sm); }
.markdown-body ul,
.markdown-body ol {
margin: 0 0 var(--spacing-sm);
padding-left: var(--spacing-lg);
}
.markdown-body li { margin-bottom: var(--spacing-xs); }
.markdown-body li:last-child { margin-bottom: 0; }
.markdown-body li > ul,
.markdown-body li > ol { margin-top: var(--spacing-xs); }
.markdown-body a {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown-body a:hover { color: var(--color-primary-hover); }
.markdown-body code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-bg-tertiary);
border-radius: var(--radius-sm);
padding: 1px 4px;
}
.markdown-body pre {
margin: 0 0 var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--color-surface-sunken);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
overflow-x: auto;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: var(--text-xs);
}
.markdown-body blockquote {
margin: 0 0 var(--spacing-sm);
padding-left: var(--spacing-sm);
border-left: 2px solid var(--color-border-strong);
color: var(--color-text-muted);
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
}
.markdown-body hr {
border: 0;
border-top: 1px solid var(--color-border-divider);
margin: var(--spacing-md) 0;
}
/* Tables are the one element that can exceed the measure, so they scroll in
their own box rather than widening the surface around them. */
.markdown-body table {
display: block;
width: max-content;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 0 0 var(--spacing-sm);
font-size: var(--text-xs);
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--color-border-subtle);
padding: var(--spacing-xs) var(--spacing-sm);
text-align: left;
}
.markdown-body th {
background: var(--color-bg-tertiary);
font-weight: var(--font-weight-medium);
}
/* Prose block in a detail pane. The label/value table beside it is right for
scalars, but multi-paragraph prose in a value cell runs the full pane width
and swamps the rows it sits above, so the description gets its own
full-width block with a reading measure. */
.detail-prose {
margin-bottom: var(--spacing-md);
}
.detail-prose__label {
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-xs);
}
.detail-prose__body {
max-width: 68ch;
}
/* Variant list --------------------------------------------------------------
One row per alternative build of the same model. Names vary in length, so
the rows share the parent's tracks and line up as columns; the name leads
because it is what the reader is choosing between. Only the informative
status is badged: "fits" is true of nearly every row and says nothing, while
a build that does not fit, and the one that plain Install would pick, do. */
.variant-list {
/* Content-width rather than pane-width: a full-width row would strand the
install affordance an inch of empty space away from the name it acts on. */
display: grid;
width: max-content;
max-width: 100%;
grid-template-columns: minmax(0, auto) max-content max-content max-content max-content;
column-gap: var(--spacing-md);
row-gap: 2px;
}
.variant-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
width: 100%;
margin: 0;
padding: var(--spacing-xs) var(--spacing-sm);
font: inherit;
text-align: left;
color: inherit;
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color var(--duration-fast) var(--ease-default),
border-color var(--duration-fast) var(--ease-default);
}
/* Subgrid keeps the columns shared across rows; without it each row falls back
to sizing its own tracks, which is ragged but still legible. */
@supports not (grid-template-columns: subgrid) {
.variant-row { grid-template-columns: minmax(22ch, auto) max-content max-content max-content max-content; }
}
.variant-row:hover:not(:disabled) {
background: var(--color-bg-hover);
border-color: var(--color-border-default);
}
.variant-row:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 1px;
}
.variant-row:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.variant-row__name {
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
}
.variant-row__backend,
.variant-row__size {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.variant-row__size {
text-align: right;
font-variant-numeric: tabular-nums;
}
.variant-row__status {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
flex-wrap: wrap;
}
.variant-row__action {
color: var(--color-text-disabled);
font-size: var(--text-xs);
transition: color var(--duration-fast) var(--ease-default);
}
.variant-row:hover:not(:disabled) .variant-row__action,
.variant-row:focus-visible .variant-row__action {
color: var(--color-primary);
}
/* A build that does not fit stays installable, an explicit choice being an
override the server honours, but it should not read as a peer of the ones
that do. */
.variant-row--unfit .variant-row__name {
color: var(--color-text-muted);
}
@media (prefers-reduced-motion: reduce) {
.variant-row,
.variant-row__action { transition: none; }
}

View File

@@ -848,7 +848,7 @@ function BackendDetail({ backend }) {
<BackendDetailRow label="Description">
{backend.description && (
<div
style={{ color: 'var(--color-text-secondary)', lineHeight: 1.6 }}
className="markdown-body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(backend.description) }}
/>
)}

View File

@@ -1079,7 +1079,7 @@ function ModelDetail({ model, enriched, matchedCaps, distributedMode, onNavigate
<dd>
{description ? (
<div
className="resource-row__detail-md"
className="resource-row__detail-md markdown-body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(description) }}
/>
) : (
@@ -1189,7 +1189,7 @@ function BackendDetail({ backend, enriched, upgradeInfo, nodes, distributedMode
<dd>
{description ? (
<div
className="resource-row__detail-md"
className="resource-row__detail-md markdown-body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(description) }}
/>
) : (

View File

@@ -672,7 +672,7 @@ export default function Models() {
{isExpanded && (
<tr>
<td colSpan="8" style={{ padding: 0 }}>
<ModelDetail model={model} fit={fit} sizeDisplay={sizeDisplay} vramDisplay={vramDisplay} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} variantData={hasVariants ? variantData[name] : null} t={t} />
<ModelDetail model={model} fit={fit} sizeDisplay={sizeDisplay} vramDisplay={vramDisplay} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} variantData={hasVariants ? variantData[name] : null} installing={installing} onInstall={handleInstall} t={t} />
</td>
</tr>
)}
@@ -796,20 +796,25 @@ function DetailRow({ label, children }) {
)
}
function ModelDetail({ model, fit, sizeDisplay, vramDisplay, expandedFiles, setExpandedFiles, variantData, t }) {
function ModelDetail({ model, fit, sizeDisplay, vramDisplay, expandedFiles, setExpandedFiles, variantData, installing, onInstall, t }) {
const files = model.additionalFiles || model.files || []
const name = model.name || model.id
return (
<div style={{ padding: 'var(--spacing-md) var(--spacing-lg)', background: 'var(--color-bg-primary)', borderTop: '1px solid var(--color-border-subtle)' }}>
{model.description && (
// Prose sits outside the label/value table: an eight-line value cell
// in a grid of one-line ones breaks the rhythm exactly where the eye
// enters, and the full pane width is roughly double a readable measure.
<div className="detail-prose">
<div className="detail-prose__label">{t('detail.description')}</div>
<div
className="markdown-body detail-prose__body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(model.description) }}
/>
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
<DetailRow label={t('detail.description')}>
{model.description && (
<div
style={{ color: 'var(--color-text-secondary)', lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(model.description) }}
/>
)}
</DetailRow>
<DetailRow label={t('detail.gallery')}>
{model.gallery && (
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>
@@ -848,23 +853,38 @@ function ModelDetail({ model, fit, sizeDisplay, vramDisplay, expandedFiles, setE
)}
{variantData?.variants?.length > 0 && (
<DetailRow label={t('variants.title')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xs)' }}>
{variantData.variants.map(v => (
<div key={v.model} className="model-variant-row" style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>{v.model}</span>
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{v.backend || t('variants.unknownBackend')}</span>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>{variantSizeLabel(v, t)}</span>
<span className={`badge ${v.fits ? 'badge-success' : 'badge-warning'}`} style={{ fontSize: '0.6875rem' }}>
{v.fits ? t('variants.fits') : t('variants.doesNotFit')}
</span>
{v.is_base && <span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{t('variants.base')}</span>}
{v.model === variantData.auto_selected && (
<span className="badge badge-success" style={{ fontSize: '0.6875rem' }}>
<i className="fas fa-circle-check" /> {t('variants.autoSelected')}
<div className="variant-list">
{variantData.variants.map(v => {
const isAuto = v.model === variantData.auto_selected
return (
// Listing the alternatives without offering them made the
// detail view read as a menu that could not be ordered
// from; installing one is the same call the split-button
// chevron already makes.
<button
key={v.model}
type="button"
className={`variant-row${v.fits ? '' : ' variant-row--unfit'}`}
disabled={installing}
aria-label={t('variants.installVariant', { variant: v.model })}
onClick={(e) => { e.stopPropagation(); onInstall(name, v.model) }}
>
<span className="variant-row__name">{v.model}</span>
<span className="variant-row__backend">{v.backend || t('variants.unknownBackend')}</span>
<span className="variant-row__size">{variantSizeLabel(v, t)}</span>
<span className="variant-row__status">
{isAuto && (
<span className="badge badge-success">
<i className="fas fa-circle-check" /> {t('variants.autoSelected')}
</span>
)}
{!v.fits && <span className="badge badge-warning">{t('variants.doesNotFit')}</span>}
{v.is_base && !isAuto && <span className="badge badge-info">{t('variants.base')}</span>}
</span>
)}
</div>
))}
<i className="fas fa-download variant-row__action" aria-hidden="true" />
</button>
)
})}
</div>
</DetailRow>
)}

View File

@@ -319,7 +319,7 @@ JSON` : ''
<span className="voice-detail__eyebrow">{t('voiceLibrary.detail.eyebrow')}</span>
<h2>{selected.name}</h2>
{selected.description && (
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(selected.description) }} />
<div className="markdown-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(selected.description) }} />
)}
</div>
<StatusPill status="healthy" label={t('voiceLibrary.status.ready')} />