From 8130d7ce9d405d371f1a612d2746014dbb3142e2 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 19 Jul 2026 13:07:25 +0000 Subject: [PATCH] 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 --- core/http/react-ui/e2e/models-gallery.spec.js | 129 +++++++++- .../react-ui/public/locales/en/models.json | 4 +- core/http/react-ui/src/App.css | 230 ++++++++++++++++++ core/http/react-ui/src/pages/Backends.jsx | 2 +- core/http/react-ui/src/pages/Manage.jsx | 4 +- core/http/react-ui/src/pages/Models.jsx | 72 ++++-- core/http/react-ui/src/pages/VoiceLibrary.jsx | 2 +- 7 files changed, 410 insertions(+), 33 deletions(-) diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index 389e8d0dd..7f3aec4d7 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -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"); + }); }); diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json index 5bcb7db14..37d8edfa5 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -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}}" } } diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 130f33084..5ce200b59 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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; } +} diff --git a/core/http/react-ui/src/pages/Backends.jsx b/core/http/react-ui/src/pages/Backends.jsx index deb938550..c19ca9637 100644 --- a/core/http/react-ui/src/pages/Backends.jsx +++ b/core/http/react-ui/src/pages/Backends.jsx @@ -848,7 +848,7 @@ function BackendDetail({ backend }) { {backend.description && (
)} diff --git a/core/http/react-ui/src/pages/Manage.jsx b/core/http/react-ui/src/pages/Manage.jsx index 9f612ea77..5c871bf6c 100644 --- a/core/http/react-ui/src/pages/Manage.jsx +++ b/core/http/react-ui/src/pages/Manage.jsx @@ -1079,7 +1079,7 @@ function ModelDetail({ model, enriched, matchedCaps, distributedMode, onNavigate
{description ? (
) : ( @@ -1189,7 +1189,7 @@ function BackendDetail({ backend, enriched, upgradeInfo, nodes, distributedMode
{description ? (
) : ( diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index 1f0830bbc..664067dbe 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -672,7 +672,7 @@ export default function Models() { {isExpanded && ( - + )} @@ -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 (
+ {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. +
+
{t('detail.description')}
+
+
+ )} - - {model.description && ( -
- )} - {model.gallery && ( @@ -848,23 +853,38 @@ function ModelDetail({ model, fit, sizeDisplay, vramDisplay, expandedFiles, setE )} {variantData?.variants?.length > 0 && ( -
- {variantData.variants.map(v => ( -
- {v.model} - {v.backend || t('variants.unknownBackend')} - {variantSizeLabel(v, t)} - - {v.fits ? t('variants.fits') : t('variants.doesNotFit')} - - {v.is_base && {t('variants.base')}} - {v.model === variantData.auto_selected && ( - - {t('variants.autoSelected')} +
+ {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. +
- ))} +