fix(addons): let each add-on draw its own page title

The shell no longer titles add-on pages, so each add-on draws one h1 per
page. Voice titles its tabs "Voices" (Recognition and From a recording),
"Speech voices" (Library) and "Speak text" (Text to speech); "Add a speech
voice" already had its own. Face draws "Faces", Studio "Studio" and Talk
"Talk". Tables promotes the database list and database name from h2 to h1.
Fine-tuning and Quantization already drew a title.

The add-on unit tests and the navigation and Studio specs now assert
exactly one h1 per page, with the page's own title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-21 17:59:06 +00:00
1 parent bf9ec9a1b0
commit 3b553c63ac
22 files changed
+59 -32

No files matched your search

+1 -2
View File
@@ -80,9 +80,8 @@ export function createFaceRecognition(ctx, api, identity) {
return (
<main className="page idn-page" data-testid="faces-page">
{/* The add-on shell already titles the page "Face", so this header
carries the supporting line and the model picker only. */}
<ui.PageHeader
title={t('faces.title')}
supporting={t('faces.lede')}
actions={!needModel ? (
<div className="idn-modelpick">
+1
View File
@@ -1,5 +1,6 @@
// Generated once from the host locale files; edit by hand from here on.
export default {
"faces.title": "Faces",
"faces.lede": "Find out whose face is in a photo, or whether two photos show the same person. Photos go only to the model running on this server.",
"tool.aria": "Recognition tool",
"tool.sameTab": "Same person?",
+1 -1
View File
@@ -64,7 +64,7 @@ test('recognition: the bundle has the face canvas, the face strings and no host
const js = readFileSync(join(dir, 'ui/face.js'), 'utf8')
assert.match(js, /biometrics-bbox/)
assert.doesNotMatch(js, /from ['"]react(-dom)?/)
assert.match(js, /faces\.lede/)
assert.match(js, /faces\.title/)
})
test('recognition: every translation key the sources read is in the Face bundle', async () => {
+8 -1
View File
@@ -22,7 +22,7 @@ export async function load(extra = {}) {
addonId: 'face', generation: 1, signal: controller.signal, pages: ['recognition'],
host: {
React,
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, stub(n === 'PageHeader' ? 'header' : 'div')])),
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, n === 'PageHeader' ? (p => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting)) : stub('div')])),
hooks: { useMediaCapture: () => ({}), useObjectUrl: () => null, useDelayedAction: () => ({ pending: null, schedule() {}, undo() {} }) },
viewer: { isAdmin: () => true, hasFeature: () => true },
basePath: '',
@@ -66,3 +66,10 @@ test('render: a registered page is drawn with its path', async () => {
const addon = await mod.createAddon(sdk, { pages: { recognition: ({ path }) => React.createElement('p', null, `page:${path}`) } })
assert.equal(renderToString(React.createElement(addon.Component, { page: 'recognition', path: 'abc' })), '<p>page:abc</p>')
})
test('render: the recognition page draws exactly one h1, its own title', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Faces'])
})
+1 -1
View File
@@ -24,7 +24,7 @@ export async function load(extra = {}) {
const controller = new AbortController()
const { viewer, components, layerSpec, fetch, ...rest } = extra
const stubs = Object.fromEntries(COMPONENTS.map(n => [n, stub('div')]))
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title, p.supporting, p.actions)
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting, p.actions)
const sdk = createHostSDK({
addonId: 'fine-tuning', generation: 1, signal: controller.signal, pages: ['jobs'],
host: {
@@ -47,4 +47,5 @@ test('render: the jobs page is drawn by the real registration path, which binds
assert.match(html, /data-testid="ft-bar"/)
assert.match(html, /data-testid="ft-jobs"/)
assert.match(html, /Fine-tune/)
assert.equal((html.match(/<h1[ >]/g) || []).length, 1, 'one title')
})
+1 -1
View File
@@ -24,7 +24,7 @@ export async function load(extra = {}) {
const controller = new AbortController()
const { viewer, components, layerSpec, fetch, ...rest } = extra
const stubs = Object.fromEntries(COMPONENTS.map(n => [n, stub('div')]))
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title, p.supporting, p.actions)
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting, p.actions)
const sdk = createHostSDK({
addonId: 'quantization', generation: 1, signal: controller.signal, pages: ['jobs'],
host: {
@@ -47,4 +47,5 @@ test('render: the jobs page is drawn by the real registration path, which binds
assert.match(html, /data-testid="qz-bar"/)
assert.match(html, /data-testid="qz-jobs"/)
assert.match(html, /Quantize/)
assert.equal((html.match(/<h1[ >]/g) || []).length, 1, 'one title')
})
@@ -80,9 +80,8 @@ export default function StudioOverview({ modalities, upscalers, modelsLoading, m
return (
<div data-testid="studio-overview" className="page-pad studio-front">
{/* The add-on shell already titles the page "Studio", so this header
carries the supporting line and the key hint only. */}
<PageHeader
title={t('studio.overview.title')}
supporting={t('studio.overview.subtitle')}
actions={<span className="studio-key-hint"><kbd className="dk-kbd">/</kbd> {t('studio.composer.startTyping')}</span>}
/>
+1 -1
View File
@@ -100,7 +100,7 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
<header className="tbl-head">
<div className="tbl-head__main">
<div>{back}</div>
<h2 className="tbl-title" data-testid="tbl-db-name">{name}</h2>
<h1 className="tbl-title" data-testid="tbl-db-name">{name}</h1>
<div className="tbl-db__meta">
<RoleBadge role={resource.role} />
{!isOwner && resource.owner ? <span className="tbl-muted">{t('db.sharedBy', { name: resource.owner })}</span> : null}
+1 -1
View File
@@ -179,7 +179,7 @@ export function createDatabases(ctx, ui, api, hooks) {
<section className="tbl-page" data-testid="tbl-list-page" aria-labelledby="tbl-list-title">
<header className="tbl-head">
<div>
<h2 id="tbl-list-title" className="tbl-title">{t('list.heading')}</h2>
<h1 id="tbl-list-title" className="tbl-title">{t('list.heading')}</h1>
<p className="tbl-muted">{t('list.intro')}</p>
</div>
{canCreate && data && data.resources.length > 0 ? <Button variant="primary" icon="plus" onClick={create}>{t('list.new')}</Button> : null}
+1
View File
@@ -58,6 +58,7 @@ test('render: the list, one database and the picker draw their loading state wit
const addon = await mod.createAddon(sdk)
const list = renderToString(React.createElement(addon.Component, { page: 'databases', path: '' }))
assert.match(list, /Your databases/)
assert.deepEqual([...list.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Your databases'])
assert.match(list, /data-addon="tables"/)
const one = renderToString(React.createElement(addon.Component, { page: 'databases', path: 'r0123456789abcdef0123456789abcde' }))
assert.match(one, /tbl-loading/)
+1 -1
View File
@@ -716,7 +716,7 @@ export default function Talk() {
return (
<div className="talk-page" data-view={view}>
<header className="talk-hd">
{/* The add-on shell already titles the page "Talk". */}
<h1 className="page-title talk-hd__title">{t('title')}</h1>
<span className="talk-hd__sub">{t('subtitle')}</span>
<div className="talk-hd__acts">
{isConnected && (
+1
View File
@@ -10,6 +10,7 @@
.talk-page a:focus-visible,
.talk-page input:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.talk-hd { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 0; }
.talk-hd__title { margin: 0; font-size: var(--dk-text-20, 20px); font-weight: var(--dk-weight-semibold, 600); }
.talk-hd__sub { color: var(--dk-muted); font-family: var(--font-mono); font-size: 12px; }
.talk-hd__acts { display: flex; align-items: center; gap: 2px; margin-left: auto; }
.talk-icobtn {
+7
View File
@@ -53,3 +53,10 @@ test('render: the manifest declares one page, the main page and SDK 2', () => {
assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
assert.deepEqual(m.capabilities.tools ?? [], [])
})
test('render: the page draws exactly one h1, its own title', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'talk', path: '', search: '' }))
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Talk'])
})
+1 -2
View File
@@ -56,8 +56,7 @@ export function createSpeech(ctx) {
return (
<main className="page vc-speech" data-testid="voice-speech">
{/* The add-on shell already titles the page. */}
<ui.PageHeader supporting={t('speech.lede')} />
<ui.PageHeader title={t('speech.title')} supporting={t('speech.lede')} />
{tabs}
<label className="vc-field">
<span>{t('speech.model')}</span>
+1 -2
View File
@@ -365,9 +365,8 @@ JSON`
return (
<main className="page voice-library-page" data-testid="voice-library">
{/* The add-on shell already titles the page, so this header carries the
supporting line and the actions only. */}
<ui.PageHeader
title={<>{t('voiceLibrary.title')} <span className="dk-hubtab-count">{profiles.length}</span></>}
supporting={t('voiceLibrary.subtitle')}
actions={(
<div className="idn-speech__acts">
+1 -2
View File
@@ -79,9 +79,8 @@ export function createVoiceRecognition(ctx, api, identity) {
return (
<main className="page idn-page" data-testid="voices-page">
{/* The add-on shell already titles the page "Voice", so this header
carries the supporting line and the model picker only. */}
<ui.PageHeader
title={t('voices.title')}
supporting={t('voices.lede')}
actions={tab === 'speakers' && !needModel ? (
<div className="idn-modelpick">
+3
View File
@@ -2,6 +2,7 @@
export default {
"voice.adminOnly.title": "Administrators only",
"voice.adminOnly.text": "Saved voices can be managed by an administrator of this server. Ask one if you need a voice added or removed.",
"speech.title": "Speak text",
"speech.lede": "Turn text into speech with a voice model of this server.",
"speech.model": "Model",
"speech.voice": "Voice",
@@ -11,6 +12,7 @@ export default {
"speech.generate": "Generate speech",
"speech.result": "Result",
"speech.noAccess": "Your account has no access to text-to-speech. The server will refuse the request.",
"voices.title": "Voices",
"voices.lede": "Recognise who is speaking, and keep voices that text-to-speech can use.",
"voices.tabs": "Voices sections",
"voices.tab.recognition": "Recognition",
@@ -157,6 +159,7 @@ export default {
"tts.voiceLibrary.create": "Create one",
"tts.voiceLibrary.manage": "Manage Personality Library",
"tts.voiceLibrary.namedVoiceHint": "This model does not accept reference-audio profiles. You can still enter a named speaker supported by its backend.",
"voiceLibrary.title": "Speech voices",
"voiceLibrary.subtitle": "A recording and its exact transcript. Text-to-speech models that accept reference audio can speak with it.",
"voiceLibrary.loadFailed": "Speech voices could not be loaded",
"voiceLibrary.loading": "Loading saved personalities…",
+12 -1
View File
@@ -22,7 +22,7 @@ export async function load(extra = {}) {
addonId: 'voice', generation: 1, signal: controller.signal, pages: ['voice', 'recognition', 'library', 'create', 'speech'],
host: {
React,
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, stub(n === 'PageHeader' ? 'header' : 'div')])),
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, n === 'PageHeader' ? (p => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting)) : stub('div')])),
hooks: { useMediaCapture: () => ({}), useObjectUrl: () => null, useDelayedAction: () => ({ pending: null, schedule() {}, undo() {} }) },
viewer: { isAdmin: () => true, hasFeature: () => true },
basePath: '',
@@ -83,3 +83,14 @@ test('render: an older page name draws nothing itself; the shell sends it to its
assert.equal(renderToString(React.createElement(addon.Component, { page, path: '' })), '', page)
}
})
test('render: each tab draws exactly one h1, its own title', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const h1s = (html) => [...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1].replace(/<[^>]+>/g, '').trim())
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: '' }))), ['Voices'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recording' }))), ['Voices'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech' }))), ['Speak text'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))), ['Speech voices 0'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library/new' }))), ['Add a speech voice'])
})
+11 -13
View File
@@ -405,11 +405,11 @@ async function expectVoiceTabs(page, active) {
await expect(tabs.locator('[aria-selected="true"]')).toHaveAttribute('data-tab', active)
}
// The add-on shell shows the one title, "Voice"; the Library, Text to speech and
// From a recording tabs do not repeat it with a heading of their own.
async function expectOneVoiceTitle(page) {
// The add-on draws its own title, once: the shell adds none, and each tab has
// exactly one h1.
async function expectOneVoiceTitle(page, title) {
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
await expect(page.getByRole('heading', { name: 'Voice', exact: true, level: 1 })).toBeVisible()
await expect(page.getByRole('heading', { name: title, level: 1 })).toBeVisible()
}
test.describe('Add-on navigation: the Voice recognition page', () => {
@@ -420,9 +420,7 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await mockIdentity(page)
await page.goto('/app/addons/voice/voice/recognition')
await expect(page.getByTestId('voices-page')).toBeVisible()
// The add-on shell shows the one title; the page does not repeat it.
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
await expect(page.getByRole('heading', { name: 'Voice', exact: true, level: 1 })).toBeVisible()
await expectOneVoiceTitle(page, 'Voices')
await expectVoiceTabs(page, 'recognition')
await expect(page.locator('[data-testid="addon-surface"][data-addon="voice"]')).toBeVisible()
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
@@ -472,24 +470,25 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library$/)
await expect(page.getByTestId('voice-library')).toBeVisible()
await expectVoiceTabs(page, 'library')
await expectOneVoiceTitle(page)
await expectOneVoiceTitle(page, /^Speech voices/)
await page.getByTestId('voice-library-new').first().click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library\/new$/)
await expect(page.getByTestId('voice-profile-create')).toBeVisible()
await expectVoiceTabs(page, 'library')
await expectOneVoiceTitle(page, /Add a speech voice/)
await tabs.getByRole('tab', { name: /^Text to speech$/ }).click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/speech$/)
await expect(page.getByTestId('voice-speech')).toBeVisible()
await expectVoiceTabs(page, 'speech')
await expectOneVoiceTitle(page)
await expectOneVoiceTitle(page, 'Speak text')
await tabs.getByRole('tab', { name: /^From a recording$/ }).click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recording$/)
await expect(page.getByTestId('recording-tab')).toBeVisible()
await expectVoiceTabs(page, 'recording')
await expectOneVoiceTitle(page)
await expectOneVoiceTitle(page, 'Voices')
})
test('a viewer who is not an administrator gets no Library tab', async ({ page }) => {
@@ -622,8 +621,7 @@ async function asFaceAdmin(page, features = {}) {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true, face_recognition: true, ...features }) }))
}
// The core page titles itself "Faces"; inside the add-on shell the shell shows
// the one title, "Face", and the page does not repeat it.
// The page draws its own title "Faces", once, in the core and in the add-on.
async function expectFacesPage(page, title = 'Faces') {
await expect(page.getByTestId('faces-page')).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
@@ -637,7 +635,7 @@ test.describe('Add-on navigation: the Face recognition page', () => {
await serveFace(page)
await mockIdentity(page)
await page.goto('/app/addons/face/recognition')
await expectFacesPage(page, 'Face')
await expectFacesPage(page)
await expect(page.locator('[data-testid="addon-surface"][data-addon="face"]')).toBeVisible()
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
})
+2 -1
View File
@@ -28,10 +28,11 @@ test.describe('Studio add-on', () => {
})
}
test('the shell titles the page once, and the page keeps its own words from the media locale', async ({ page }) => {
test('the page draws its own title once, and the page keeps its own words from the media locale', async ({ page }) => {
await mockStudio(page)
await page.goto('/app/addons/studio/studio')
await expect(page.getByTestId('studio-overview')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('h1')).toHaveCount(1)
await expect(page.locator('h1.page-title', { hasText: /^Studio$/ })).toHaveCount(1)
await expect(page.getByText('What do you want to make?')).toBeVisible()
// No raw key is drawn where words belong.