From 3b553c63acbbb45a43b2a5ea113bc2cc06e35c27 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 21 Oct 2026 17:59:06 +0000 Subject: [PATCH] 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 --- addons/face/ui/src/FaceRecognition.jsx | 3 +-- addons/face/ui/src/i18n/en.js | 1 + addons/face/ui/test/recognition.test.mjs | 2 +- addons/face/ui/test/render.test.mjs | 9 ++++++- addons/fine-tuning/ui/test/load.mjs | 2 +- addons/fine-tuning/ui/test/render.test.mjs | 1 + addons/quantization/ui/test/load.mjs | 2 +- addons/quantization/ui/test/render.test.mjs | 1 + addons/studio/ui/src/pages/StudioOverview.jsx | 3 +-- addons/tables/ui/src/Database.jsx | 2 +- addons/tables/ui/src/Databases.jsx | 2 +- addons/tables/ui/test/render.test.mjs | 1 + addons/talk/ui/src/pages/Talk.jsx | 2 +- addons/talk/ui/src/pages/talk.css | 1 + addons/talk/ui/test/render.test.mjs | 7 ++++++ addons/voice/ui/src/Speech.jsx | 3 +-- addons/voice/ui/src/VoiceLibrary.jsx | 3 +-- addons/voice/ui/src/VoiceRecognition.jsx | 3 +-- addons/voice/ui/src/i18n/en.js | 3 +++ addons/voice/ui/test/render.test.mjs | 13 +++++++++- .../react-ui/e2e/addon-navigation.spec.js | 24 +++++++++---------- core/http/react-ui/e2e/studio-addons.spec.js | 3 ++- 22 files changed, 59 insertions(+), 32 deletions(-) diff --git a/addons/face/ui/src/FaceRecognition.jsx b/addons/face/ui/src/FaceRecognition.jsx index c298ac7d1..bd5689cbc 100644 --- a/addons/face/ui/src/FaceRecognition.jsx +++ b/addons/face/ui/src/FaceRecognition.jsx @@ -80,9 +80,8 @@ export function createFaceRecognition(ctx, api, identity) { return (
- {/* The add-on shell already titles the page "Face", so this header - carries the supporting line and the model picker only. */} diff --git a/addons/face/ui/src/i18n/en.js b/addons/face/ui/src/i18n/en.js index 097847d14..efa468ebb 100644 --- a/addons/face/ui/src/i18n/en.js +++ b/addons/face/ui/src/i18n/en.js @@ -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?", diff --git a/addons/face/ui/test/recognition.test.mjs b/addons/face/ui/test/recognition.test.mjs index f63fd9390..701e8d6b1 100644 --- a/addons/face/ui/test/recognition.test.mjs +++ b/addons/face/ui/test/recognition.test.mjs @@ -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 () => { diff --git a/addons/face/ui/test/render.test.mjs b/addons/face/ui/test/render.test.mjs index b9b24ace8..30eacd508 100644 --- a/addons/face/ui/test/render.test.mjs +++ b/addons/face/ui/test/render.test.mjs @@ -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' })), '

page:abc

') }) + +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>/g)].map(m => m[1]), ['Faces']) +}) diff --git a/addons/fine-tuning/ui/test/load.mjs b/addons/fine-tuning/ui/test/load.mjs index cae90a862..5d3b4b3fd 100644 --- a/addons/fine-tuning/ui/test/load.mjs +++ b/addons/fine-tuning/ui/test/load.mjs @@ -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: { diff --git a/addons/fine-tuning/ui/test/render.test.mjs b/addons/fine-tuning/ui/test/render.test.mjs index 6241752c2..d9b12bc5f 100644 --- a/addons/fine-tuning/ui/test/render.test.mjs +++ b/addons/fine-tuning/ui/test/render.test.mjs @@ -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(/]/g) || []).length, 1, 'one title') }) diff --git a/addons/quantization/ui/test/load.mjs b/addons/quantization/ui/test/load.mjs index 67cca6a7f..aebfdabc2 100644 --- a/addons/quantization/ui/test/load.mjs +++ b/addons/quantization/ui/test/load.mjs @@ -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: { diff --git a/addons/quantization/ui/test/render.test.mjs b/addons/quantization/ui/test/render.test.mjs index e6d601251..13384f360 100644 --- a/addons/quantization/ui/test/render.test.mjs +++ b/addons/quantization/ui/test/render.test.mjs @@ -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(/]/g) || []).length, 1, 'one title') }) diff --git a/addons/studio/ui/src/pages/StudioOverview.jsx b/addons/studio/ui/src/pages/StudioOverview.jsx index 23f95479e..bccb094b6 100644 --- a/addons/studio/ui/src/pages/StudioOverview.jsx +++ b/addons/studio/ui/src/pages/StudioOverview.jsx @@ -80,9 +80,8 @@ export default function StudioOverview({ modalities, upscalers, modelsLoading, m return (
- {/* The add-on shell already titles the page "Studio", so this header - carries the supporting line and the key hint only. */} / {t('studio.composer.startTyping')}} /> diff --git a/addons/tables/ui/src/Database.jsx b/addons/tables/ui/src/Database.jsx index d56ca1920..ca820cba2 100644 --- a/addons/tables/ui/src/Database.jsx +++ b/addons/tables/ui/src/Database.jsx @@ -100,7 +100,7 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
{back}
-

{name}

+

{name}

{!isOwner && resource.owner ? {t('db.sharedBy', { name: resource.owner })} : null} diff --git a/addons/tables/ui/src/Databases.jsx b/addons/tables/ui/src/Databases.jsx index deedca26f..f35422c1e 100644 --- a/addons/tables/ui/src/Databases.jsx +++ b/addons/tables/ui/src/Databases.jsx @@ -179,7 +179,7 @@ export function createDatabases(ctx, ui, api, hooks) {
-

{t('list.heading')}

+

{t('list.heading')}

{t('list.intro')}

{canCreate && data && data.resources.length > 0 ? : null} diff --git a/addons/tables/ui/test/render.test.mjs b/addons/tables/ui/test/render.test.mjs index 644e11a17..d05eff2bd 100644 --- a/addons/tables/ui/test/render.test.mjs +++ b/addons/tables/ui/test/render.test.mjs @@ -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>/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/) diff --git a/addons/talk/ui/src/pages/Talk.jsx b/addons/talk/ui/src/pages/Talk.jsx index cfed569f1..964219f51 100644 --- a/addons/talk/ui/src/pages/Talk.jsx +++ b/addons/talk/ui/src/pages/Talk.jsx @@ -716,7 +716,7 @@ export default function Talk() { return (
- {/* The add-on shell already titles the page "Talk". */} +

{t('title')}

{t('subtitle')}
{isConnected && ( diff --git a/addons/talk/ui/src/pages/talk.css b/addons/talk/ui/src/pages/talk.css index 03aec8fd5..ebd8b222f 100644 --- a/addons/talk/ui/src/pages/talk.css +++ b/addons/talk/ui/src/pages/talk.css @@ -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 { diff --git a/addons/talk/ui/test/render.test.mjs b/addons/talk/ui/test/render.test.mjs index e434e14d0..d35e1c42d 100644 --- a/addons/talk/ui/test/render.test.mjs +++ b/addons/talk/ui/test/render.test.mjs @@ -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>/g)].map(m => m[1]), ['Talk']) +}) diff --git a/addons/voice/ui/src/Speech.jsx b/addons/voice/ui/src/Speech.jsx index f8954a27a..e3550cc0d 100644 --- a/addons/voice/ui/src/Speech.jsx +++ b/addons/voice/ui/src/Speech.jsx @@ -56,8 +56,7 @@ export function createSpeech(ctx) { return (
- {/* The add-on shell already titles the page. */} - + {tabs}