feat(ui): Home loaded-models skeleton list, button hierarchy, EmptyState wizard

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-06-18 10:53:41 +00:00
parent c7d9dbda6b
commit 76f3e032ae
10 changed files with 101 additions and 66 deletions

View File

@@ -0,0 +1,29 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Home editorial redesign', () => {
test('renders the serif greeting header', async ({ page }) => {
await page.goto('/app')
const greeting = page.locator('.home-greeting')
await expect(greeting).toBeVisible({ timeout: 15_000 })
const family = await greeting.evaluate(el => getComputedStyle(el).fontFamily)
expect(family.toLowerCase()).toContain('fraunces')
})
test('quick links expose a single primary action', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.home-greeting, .empty-state-title').first()).toBeVisible({ timeout: 15_000 })
const primaries = page.locator('.home-quick-links .btn-primary')
// At most one primary CTA in the quick-links row.
expect(await primaries.count()).toBeLessThanOrEqual(1)
})
test('loaded-models block uses an editorial section heading', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.home-greeting').first()).toBeVisible({ timeout: 15_000 })
// The refined loaded-models block introduces a SectionHeading; the legacy
// inline ".home-loaded-text" label is gone.
const heading = page.locator('.home-loaded .section-heading')
await expect(heading).toBeVisible({ timeout: 15_000 })
await expect(heading).toHaveText(/active models/i)
})
})

View File

@@ -43,6 +43,7 @@
"documentation": "Dokumentation"
},
"loadedModels": {
"heading": "Aktive Modelle",
"count_one": "{{count}} Modell geladen",
"count_other": "{{count}} Modelle geladen",
"stop": "Modell stoppen",

View File

@@ -43,6 +43,7 @@
"documentation": "Documentation"
},
"loadedModels": {
"heading": "Active models",
"count_one": "{{count}} model loaded",
"count_other": "{{count}} models loaded",
"stop": "Stop model",

View File

@@ -43,6 +43,7 @@
"documentation": "Documentación"
},
"loadedModels": {
"heading": "Modelos activos",
"count_one": "{{count}} modelo cargado",
"count_other": "{{count}} modelos cargados",
"stop": "Detener modelo",

View File

@@ -43,6 +43,7 @@
"documentation": "Dokumentasi"
},
"loadedModels": {
"heading": "Model aktif",
"count_one": "{{count}} model dimuat",
"count_other": "{{count}} model dimuat",
"stop": "Hentikan model",

View File

@@ -43,6 +43,7 @@
"documentation": "Documentazione"
},
"loadedModels": {
"heading": "Modelli attivi",
"count_one": "{{count}} modello caricato",
"count_other": "{{count}} modelli caricati",
"stop": "Ferma modello",

View File

@@ -43,6 +43,7 @@
"documentation": "문서"
},
"loadedModels": {
"heading": "활성 모델",
"count_one": "모델 {{count}}개 로드됨",
"count_other": "모델 {{count}}개 로드됨",
"stop": "모델 중지",

View File

@@ -43,6 +43,7 @@
"documentation": "文档"
},
"loadedModels": {
"heading": "活动模型",
"count_one": "已加载 {{count}} 个模型",
"count_other": "已加载 {{count}} 个模型",
"stop": "停止模型",

View File

@@ -5959,33 +5959,21 @@ select.input {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Quiet variant: a deliberately understated link (e.g. Documentation) so the
quick-links row keeps a single clear primary action. */
.home-link-btn--quiet { opacity: 0.8; }
/* Home loaded models */
.home-loaded-models {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
font-size: var(--text-xs);
color: var(--color-text-secondary);
.home-loaded {
width: 100%;
box-shadow: var(--shadow-subtle);
}
.home-loaded-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--color-success);
}
.home-loaded-text {
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.home-loaded-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
@@ -5994,12 +5982,11 @@ select.input {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
padding: 3px 6px 3px 10px;
background: var(--color-surface-sunken);
border: 1px solid var(--color-border-divider);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.home-loaded-item button {
background: none;
@@ -6007,18 +5994,16 @@ select.input {
color: var(--color-error);
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 0.625rem;
}
.home-loaded-empty {
color: var(--color-text-secondary);
font-size: var(--text-sm);
margin: 0;
}
.home-stop-all {
margin-left: auto;
background: none;
border: 1px solid var(--color-error);
color: var(--color-error);
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.75rem;
cursor: pointer;
font-family: inherit;
align-self: flex-start;
}
/* Home wizard (no models) */

View File

@@ -14,6 +14,9 @@ import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi
import { API_CONFIG } from '../utils/config'
import { greetingKey } from '../utils/greeting'
import StatusPill from '../components/StatusPill'
import Skeleton from '../components/Skeleton'
import SectionHeading from '../components/SectionHeading'
import EmptyState from '../components/EmptyState'
import { staggerStyle } from '../hooks/useStagger'
export default function Home() {
@@ -437,53 +440,64 @@ export default function Home() {
<i className="fas fa-user-shield" /> {t('quickLinks.manageByChat')}
</button>
)}
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
<i className="fas fa-desktop" /> {t('quickLinks.installedModels')}
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
<i className="fas fa-download" aria-hidden="true" /> {t('quickLinks.browseGallery')}
</button>
<button className="home-link-btn" onClick={() => navigate('/app/models')}>
<i className="fas fa-download" /> {t('quickLinks.browseGallery')}
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
<i className="fas fa-desktop" aria-hidden="true" /> {t('quickLinks.installedModels')}
</button>
<button className="home-link-btn" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> {t('quickLinks.importModel')}
<i className="fas fa-upload" aria-hidden="true" /> {t('quickLinks.importModel')}
</button>
</>
)}
<a className="home-link-btn" href="https://localai.io" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> {t('quickLinks.documentation')}
<a className="home-link-btn home-link-btn--quiet" href="https://localai.io" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" aria-hidden="true" /> {t('quickLinks.documentation')}
</a>
</div>
{/* Loaded models status */}
{loadedCount > 0 && (
<div className="home-loaded-models">
<span className="home-loaded-dot" />
<span className="home-loaded-text">{t('loadedModels.count', { count: loadedCount })}</span>
<div className="home-loaded-list">
{[...loadedModels].sort((a, b) => a.id.localeCompare(b.id)).map(m => (
<span key={m.id} className="home-loaded-item">
{m.id}
<button onClick={() => handleStopModel(m.id)} title={t('loadedModels.stop')}>
<i className="fas fa-times" />
</button>
</span>
))}
</div>
{loadedCount > 1 && (
<button className="home-stop-all" onClick={handleStopAll}>
{t('loadedModels.stopAll')}
</button>
)}
</div>
)}
<section className="home-loaded">
<SectionHeading>{t('loadedModels.heading')}</SectionHeading>
{modelsLoading ? (
<Skeleton variant="line" count={2} />
) : loadedCount > 0 ? (
<>
<ul className="home-loaded-list reveal-stagger">
{[...loadedModels].sort((a, b) => a.id.localeCompare(b.id)).map((m, i) => (
<li key={m.id} className="home-loaded-item" style={staggerStyle(i)}>
<StatusPill status="healthy" label={m.id} />
<button
type="button"
onClick={() => handleStopModel(m.id)}
title={t('loadedModels.stop')}
aria-label={t('loadedModels.stop')}
>
<i className="fas fa-times" aria-hidden="true" />
</button>
</li>
))}
</ul>
{loadedCount > 1 && (
<button className="btn btn-secondary btn-sm home-stop-all" onClick={handleStopAll}>
{t('loadedModels.stopAll')}
</button>
)}
</>
) : (
<p className="home-loaded-empty">{t('statusLine.noModelsLoaded')}</p>
)}
</section>
</>
) : isAdmin ? (
/* No models installed - compact getting started */
<div className="home-wizard">
<div className="home-wizard-hero">
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="home-logo" />
<h1>{t('wizard.getStarted', { name: branding.instanceName })}</h1>
<p>{t('wizard.intro')}</p>
</div>
<EmptyState
eyebrow={branding.instanceName}
icon="fa-rocket"
title={t('wizard.getStarted', { name: branding.instanceName })}
body={t('wizard.intro')}
/>
<div className="home-wizard-steps card">
<div className="home-wizard-step">