From 0a44303c212e69e070115bbb834912755df07905 Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Fri, 23 Oct 2026 12:35:44 +0200 Subject: [PATCH] fix(addons): one page title, one primary action and clearer empty states (#12850) A visual pass over the add-on pages found the same few problems on several of them. Fix them with one rule per problem instead of page by page. Titles: every add-on page takes the host page-title size. Talk and Tables no longer shrink it, Studio draws one "Studio" title above its tabs (the card of a generator is a section), and Voice keeps one title on all its tabs. The Text to speech tab gets a styled form. The "not available" page shows the add-on name, not the lowercase id, and sits at the same inset as an add-on page. One primary action: the empty state of a list carries the add button, so the header button waits until there is something in the list (Voice library, enrolling a speaker or a person, Add rows). The Add-ons page shows one way into Browse. A table with no columns says so once. Tables: Share is hidden when there are no named accounts. The active table tab is highlighted and an empty tab list leaves no gap before "New table". Studio: the tab strip stays on one row at 1280 wide, /studio/3d opens the 3D tab, and an input chip has a divider between its label and its placeholder. Fine-tune and Quantize: the Experimental badge sits beside the lede, not inside the h1; an open step shows its number instead of an empty circle; the sticky action bar keeps a gap above it. The footer shows nothing for the empty version of a source build, and the Voice Text to speech tab says where to get a model when none is installed. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- addons/face/ui/src/FaceRecognition.jsx | 3 +- addons/fine-tuning/ui/src/FineTune.jsx | 4 +- addons/fine-tuning/ui/src/style.css | 10 ++- addons/internal/identity-ui/src/KnownList.jsx | 2 +- addons/internal/identity-ui/src/Workbench.jsx | 9 ++- addons/internal/tools-ui/src/tools.css | 10 ++- addons/quantization/ui/src/Quantize.jsx | 4 +- addons/quantization/ui/src/style.css | 10 ++- .../ui/src/components/studio/LineageView.jsx | 2 +- .../ui/src/components/studio/Workspace.jsx | 2 +- .../ui/src/components/studio/studio.css | 18 +++-- .../ui/src/components/studio/workspace.css | 3 + addons/studio/ui/src/pages/Studio.jsx | 20 +++++- addons/studio/ui/src/pages/StudioOverview.jsx | 7 -- addons/tables/ui/src/Database.jsx | 28 +++++--- addons/tables/ui/src/Databases.jsx | 2 +- addons/tables/ui/src/Schema.jsx | 3 +- addons/tables/ui/src/TableView.jsx | 11 ++- addons/tables/ui/src/lib/layout.js | 18 +++++ addons/tables/ui/src/style.css | 4 +- addons/tables/ui/test/layout.test.mjs | 23 +++++++ addons/talk/ui/src/pages/talk.css | 3 +- addons/voice/ui/src/Speech.jsx | 58 +++++++++------- addons/voice/ui/src/VoiceLibrary.jsx | 5 +- addons/voice/ui/src/VoiceRecognition.jsx | 3 +- addons/voice/ui/src/i18n/en.js | 4 ++ addons/voice/ui/src/style.css | 4 ++ addons/voice/ui/test/render.test.mjs | 6 +- addons/voice/ui/test/speech.test.mjs | 27 ++++++++ .../react-ui/e2e/addon-navigation.spec.js | 38 +++++++++- core/http/react-ui/e2e/addons-shell.spec.js | 8 +++ core/http/react-ui/e2e/studio-addons.spec.js | 19 +++++ .../react-ui/e2e/studio-transform.spec.js | 2 +- .../react-ui/e2e/studio-workspace.spec.js | 4 +- core/http/react-ui/e2e/tables-ui.spec.js | 7 +- core/http/react-ui/src/App.jsx | 3 +- core/http/react-ui/src/addons/AddonRoute.jsx | 8 +-- .../src/components/addons/AppsView.jsx | 12 ++-- .../src/components/addons/addonState.js | 8 +++ .../react-ui/src/components/addons/addons.css | 6 ++ .../react-ui/src/components/tools/tools.css | 10 ++- core/http/react-ui/src/utils/version.js | 8 +++ .../react-ui/tests/unit/addonstitles.test.mjs | 69 +++++++++++++++++++ .../react-ui/tests/unit/shellnames.test.mjs | 25 +++++++ docs/content/features/tables.md | 2 +- scripts/addons/suites.json | 3 + 46 files changed, 433 insertions(+), 102 deletions(-) create mode 100644 addons/tables/ui/src/lib/layout.js create mode 100644 addons/tables/ui/test/layout.test.mjs create mode 100644 core/http/react-ui/src/utils/version.js create mode 100644 core/http/react-ui/tests/unit/addonstitles.test.mjs create mode 100644 core/http/react-ui/tests/unit/shellnames.test.mjs diff --git a/addons/face/ui/src/FaceRecognition.jsx b/addons/face/ui/src/FaceRecognition.jsx index bd5689cbc..d2495deb7 100644 --- a/addons/face/ui/src/FaceRecognition.jsx +++ b/addons/face/ui/src/FaceRecognition.jsx @@ -40,9 +40,10 @@ export function createFaceRecognition(ctx, api, identity) { function FeatureOff() { return (
+
{t('off.badge')} -

{t('off.face.title')}

+

{t('off.face.title')}

{t('off.face.text')}

{t('needed.whatTitle')} diff --git a/addons/fine-tuning/ui/src/FineTune.jsx b/addons/fine-tuning/ui/src/FineTune.jsx index 8879aa979..d4f3b207d 100644 --- a/addons/fine-tuning/ui/src/FineTune.jsx +++ b/addons/fine-tuning/ui/src/FineTune.jsx @@ -918,8 +918,8 @@ export function createFineTune(ctx, api, shared) { return (
{t('fineTune.title')} {t('landing.experimental')}} - supporting={t('fineTune.lede')} + title={t('fineTune.title')} + supporting={<>{t('landing.experimental')} {t('fineTune.lede')}} actions={(
{selectedJob && ( diff --git a/addons/fine-tuning/ui/src/style.css b/addons/fine-tuning/ui/src/style.css index c30ff257e..a025d0868 100644 --- a/addons/fine-tuning/ui/src/style.css +++ b/addons/fine-tuning/ui/src/style.css @@ -14,7 +14,7 @@ .bt-h1 { margin: 0; font: var(--dk-weight-bold) var(--dk-text-32) / 1.15 var(--dk-font-display); letter-spacing: -.02em; text-wrap: balance; } .bt-h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); color: var(--dk-text); } .bt-h3 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); color: var(--dk-text); } -.bt-experimental { vertical-align: middle; margin-left: var(--dk-space-2); } +.bt-experimental { vertical-align: middle; margin-right: var(--dk-space-2); } .bt-head-acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; justify-content: flex-end; } .bt-stack { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; } .bt-empty { color: var(--dk-muted); } @@ -43,6 +43,12 @@ .bt-section__title { margin: 0; display: flex; align-items: center; gap: var(--dk-space-3); font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); } .bt-section__mark { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; border: 2px solid var(--dk-edge); box-sizing: border-box; color: var(--dk-on-accent); } .bt-section__mark .lai-icon { font-size: 13px; } +/* A step that is not done yet shows its number, so an open circle reads as + "to do" and not as a checkbox that failed to tick. */ +.bt-page { counter-reset: bt-step; } +.bt-section { counter-increment: bt-step; } +.bt-section[data-done="false"] .bt-section__mark { color: var(--dk-muted); font: var(--dk-weight-bold) var(--dk-text-12) / 1 var(--dk-font-mono); } +.bt-section[data-done="false"] .bt-section__mark::before { content: counter(bt-step); } .bt-section[data-done="true"] .bt-section__mark { background: var(--dk-accent); border-color: var(--dk-accent); } .bt-section__body { display: flex; flex-direction: column; gap: var(--dk-space-3); padding-left: calc(24px + var(--dk-space-3)); min-width: 0; } .bt-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--dk-space-3); } @@ -101,7 +107,7 @@ .bt-check__meter { margin-top: var(--dk-space-2); } /* ---- Action bar --------------------------------------------------------------- */ -.bt-bar { position: sticky; bottom: 0; z-index: var(--dk-z-sticky); display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-4); flex-wrap: wrap; padding: var(--dk-space-3) 0; background: var(--dk-canvas); border-top: 1px solid var(--dk-edge); } +.bt-bar { position: sticky; margin-top: var(--dk-space-4); bottom: 0; z-index: var(--dk-z-sticky); display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-4); flex-wrap: wrap; padding: var(--dk-space-3) 0; background: var(--dk-canvas); border-top: 1px solid var(--dk-edge); } .bt-bar__text { margin: 0; flex: 1 1 280px; min-width: 0; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); overflow-wrap: anywhere; } .bt-bar__acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; margin-left: auto; } diff --git a/addons/internal/identity-ui/src/KnownList.jsx b/addons/internal/identity-ui/src/KnownList.jsx index 0f2e98377..d6d990053 100644 --- a/addons/internal/identity-ui/src/KnownList.jsx +++ b/addons/internal/identity-ui/src/KnownList.jsx @@ -42,7 +42,7 @@ export function createKnownList(ctx) {

{t(`${kind}.emptyTitle`)}

{t(`${kind}.emptyText`)}

- +
) } diff --git a/addons/internal/identity-ui/src/Workbench.jsx b/addons/internal/identity-ui/src/Workbench.jsx index c32dfc653..2fc93f3b2 100644 --- a/addons/internal/identity-ui/src/Workbench.jsx +++ b/addons/internal/identity-ui/src/Workbench.jsx @@ -314,9 +314,12 @@ export function createWorkbench(ctx, deps) {

{t(`${kind}.knownTitle`)} {known}

- + {/* With nobody saved yet the empty state below carries the one enrol button. */} + {(known > 0 || reg.extras.length > 0) && ( + + )}

{t(reg.searched ? 'known.noteSearched' : 'known.note', { noun })}

{t('quantize.title')} {t('landing.experimental')}} - supporting={t('quantize.lede')} + title={t('quantize.title')} + supporting={<>{t('landing.experimental')} {t('quantize.lede')}} actions={selectedJob && (
-

{clip(root.title, 72) || label(root.type)}

+

{clip(root.title, 72) || label(root.type)}

{members.length > 1 ? t('studio.lineage.sub', { count: members.length }) : t('studio.lineage.subOne')} {' ยท '}{relativeTime(members[members.length - 1].createdAt)} diff --git a/addons/studio/ui/src/components/studio/Workspace.jsx b/addons/studio/ui/src/components/studio/Workspace.jsx index 9fe5d3896..a63d37e54 100644 --- a/addons/studio/ui/src/components/studio/Workspace.jsx +++ b/addons/studio/ui/src/components/studio/Workspace.jsx @@ -266,7 +266,7 @@ export function ComposeCard({ return (

-

{icon && }{title}

+

{icon && }{title}

{lede && {lede}}
{handoff} diff --git a/addons/studio/ui/src/components/studio/studio.css b/addons/studio/ui/src/components/studio/studio.css index f38dc0e09..b4ddaf281 100644 --- a/addons/studio/ui/src/components/studio/studio.css +++ b/addons/studio/ui/src/components/studio/studio.css @@ -42,21 +42,29 @@ /* The seven types, as one row above every Studio page. The current one is outlined and lifted onto a card; there is no underline and no rail. */ +.studio-head { padding: var(--spacing-xl) var(--spacing-xl) 0; } +.studio-head .page-header { margin-bottom: var(--dk-space-3); } +/* One row at any width: eight tabs do not fit a narrow window, so the row + scrolls sideways instead of dropping the last tab onto a second line. */ .studio-tabs { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; gap: var(--dk-space-1); - padding: var(--dk-space-4) var(--spacing-xl) 0; + padding: 3px var(--dk-space-2) 3px var(--spacing-xl); + overflow-x: auto; + scrollbar-width: none; } +.studio-tabs::-webkit-scrollbar { display: none; } .studio-tab { position: relative; /* contains the hidden state text so it cannot widen the scrolling row */ display: inline-flex; align-items: center; gap: var(--dk-space-2); height: 38px; - padding: 0 var(--dk-space-3); + padding: 0 10px; border: 0; border-radius: var(--dk-radius-3); + flex: none; background: transparent; color: var(--dk-muted); font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-body); @@ -73,8 +81,8 @@ box-shadow: inset 0 0 0 1.5px var(--dk-text); } @media (max-width: 720px) { - .studio-tabs { flex-wrap: nowrap; overflow-x: auto; padding-left: var(--dk-space-4); padding-right: var(--dk-space-4); scrollbar-width: none; } - .studio-tab { flex: none; } + .studio-head { padding: var(--dk-space-4) var(--dk-space-4) 0; } + .studio-tabs { padding-left: var(--dk-space-4); padding-right: var(--dk-space-4); } } /* --- Composer ------------------------------------------------------------ */ diff --git a/addons/studio/ui/src/components/studio/workspace.css b/addons/studio/ui/src/components/studio/workspace.css index 2f892eacb..632a04911 100644 --- a/addons/studio/ui/src/components/studio/workspace.css +++ b/addons/studio/ui/src/components/studio/workspace.css @@ -166,6 +166,9 @@ text-align: right; } .ws-chip__input::placeholder { color: var(--dk-muted); opacity: 1; } +/* The label of an input chip is set off from what is typed (or its placeholder) + by a hairline, so "Voice" and "Optional speaker or voice ID" read as two things. */ +.ws-chip--input > span { align-self: stretch; display: inline-flex; align-items: center; padding-right: var(--dk-space-3); border-right: 1px solid var(--dk-edge); color: var(--dk-text); } /* A wide field holds a sentence-length placeholder ("Optional speaker or voice ID"), which 120px cut off mid-word. Left-aligned text, and it gives way on a phone instead of overflowing the row. */ diff --git a/addons/studio/ui/src/pages/Studio.jsx b/addons/studio/ui/src/pages/Studio.jsx index 4ed1165df..f57c82e74 100644 --- a/addons/studio/ui/src/pages/Studio.jsx +++ b/addons/studio/ui/src/pages/Studio.jsx @@ -16,6 +16,7 @@ import { } from '../utils/capabilities' import { upscaleModels } from '../utils/upscale' import Icon from '../components/Icon' +import PageHeader from '../components/PageHeader' import '../components/studio/studio.css' // One table for the six generators: the capability that makes a modality @@ -34,6 +35,14 @@ const MODALITIES = [ const OVERVIEW_TAB = { key: 'overview', icon: 'compass' } +// Short addresses people type or older links carry, and the tab each opens. +// The 3D tab is keyed `threed`, but /studio/3d is the address anyone expects. +const TAB_ALIASES = { '3d': 'threed', image: 'images' } +export function resolveTab(pathTab, keys) { + const key = TAB_ALIASES[pathTab] || pathTab + return keys.includes(key) ? key : 'overview' +} + const TAB_COMPONENTS = { diarization: Diarization, images: ImageGen, @@ -82,7 +91,7 @@ export default function Studio() { // Overview is the fallback for anything unrecognised or gated off. Landing on // Images was never a decision, only the first entry in an array. - const activeTab = tabs.some(tab => tab.key === pathTab) ? pathTab : 'overview' + const activeTab = resolveTab(pathTab, tabs.map(tab => tab.key)) const setTab = (key) => navigate(key === 'overview' ? '/app/studio' : `/app/studio/${key}`) @@ -101,6 +110,15 @@ export default function Studio() { return (
+ {/* One page title for every tab, at the size of every other add-on page. + The card of a generator below it is a section, not a second title. */} +
+ / {t('studio.composer.startTyping')} : undefined} + /> +
{tabs.map(tab => { const dot = dotFor(tab) diff --git a/addons/studio/ui/src/pages/StudioOverview.jsx b/addons/studio/ui/src/pages/StudioOverview.jsx index bccb094b6..080907ee1 100644 --- a/addons/studio/ui/src/pages/StudioOverview.jsx +++ b/addons/studio/ui/src/pages/StudioOverview.jsx @@ -1,7 +1,6 @@ import { useCallback, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import PageHeader from '../components/PageHeader' import Icon from '../components/Icon' // eslint-disable-next-line no-unused-vars import StudioComposer from '../components/studio/StudioComposer' @@ -80,12 +79,6 @@ export default function StudioOverview({ modalities, upscalers, modelsLoading, m return (
- / {t('studio.composer.startTyping')}} - /> - {modelsError ? (

{t('studio.error.title')}

diff --git a/addons/tables/ui/src/Database.jsx b/addons/tables/ui/src/Database.jsx index ca820cba2..ed0e1b9e9 100644 --- a/addons/tables/ui/src/Database.jsx +++ b/addons/tables/ui/src/Database.jsx @@ -3,6 +3,7 @@ // listing for this person; what the page offers follows it, and the host // refuses the rest. import { describeError } from './lib/errors.js' +import { canShare } from './lib/layout.js' export function createDatabase(ctx, ui, api, hooks, parts) { const { sdk, h, R, Fragment, t } = ctx @@ -100,7 +101,7 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
{back}
-

{name}

+

{name}

{!isOwner && resource.owner ? {t('db.sharedBy', { name: resource.owner })} : null} @@ -109,9 +110,12 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
{isOwner ? (
- + {/* Without named accounts there is nobody to share with, so no button. */} + {canShare(data.listing) ? ( + + ) : null} - - ))} - + {tables.length > 0 ? ( +
    + {tables.map(x => ( +
  • + +
  • + ))} +
+ ) : null} {isOwner ? : null} {current ? ( diff --git a/addons/tables/ui/src/Databases.jsx b/addons/tables/ui/src/Databases.jsx index f35422c1e..32a50d30f 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/src/Schema.jsx b/addons/tables/ui/src/Schema.jsx index 8dd6b3a9e..04b0c7616 100644 --- a/addons/tables/ui/src/Schema.jsx +++ b/addons/tables/ui/src/Schema.jsx @@ -201,7 +201,8 @@ export function createSchema(ctx, ui, fields, hooks) { } function ColumnList({ table, actions }) { - if (table.columns.length === 0) return

{t('grid.noColumns.title')}

+ // The grid above already says the table has no columns; saying it again here only repeats it. + if (table.columns.length === 0) return null return (
    {table.columns.map(c => ( diff --git a/addons/tables/ui/src/TableView.jsx b/addons/tables/ui/src/TableView.jsx index 89ff58509..de488a4de 100644 --- a/addons/tables/ui/src/TableView.jsx +++ b/addons/tables/ui/src/TableView.jsx @@ -7,6 +7,7 @@ import { setAll, toggle, allSelected } from './lib/batch.js' import { display } from './lib/codecs.js' import { describeError } from './lib/errors.js' import { exportArguments } from './lib/export.js' +import { addRowsPlace } from './lib/layout.js' const CACHE_LIMIT = 40 @@ -112,6 +113,10 @@ export function createTableView(ctx, ui, api, hooks, rows, filters, exporter) { )) } + // One primary action per screen: the empty state of a table with no rows + // carries its own Add rows button, so the toolbar keeps its button for the + // other cases. + const addPlace = addRowsPlace({ canWrite, columnCount: columns.length, loaded: Boolean(shown), rowCount: pageRows.length, filtered: hasFilter }) let body if (columns.length === 0) { body = @@ -122,7 +127,7 @@ export function createTableView(ctx, ui, api, hooks, rows, filters, exporter) { } else if (shown && pageRows.length === 0) { body = hasFilter ? - : {canWrite ? : null} + : {addPlace === 'empty' ? : null} } else if (shown) { body = (
    @@ -191,9 +196,9 @@ export function createTableView(ctx, ui, api, hooks, rows, filters, exporter) {
    - {canWrite ? ( + {addPlace === 'toolbar' ? (
    - +
    ) : null}
diff --git a/addons/tables/ui/src/lib/layout.js b/addons/tables/ui/src/lib/layout.js new file mode 100644 index 000000000..7103d28c1 --- /dev/null +++ b/addons/tables/ui/src/lib/layout.js @@ -0,0 +1,18 @@ +// Which of two controls a screen offers, so a screen never carries two for the +// same action. Pure rules, no page code. + +// Sharing needs named accounts to share with. An installation with one local +// owner has none, and the listing says so; the page then has no Share button. +export function canShare(listing) { + return Boolean(listing && listing.sharing) +} + +// Where "Add rows" is offered for the open table: +// 'toolbar' the toolbar button, for a table that has rows or a filter in use +// 'empty' the button of the empty state, for a table with columns and no rows +// 'none' a reader, or a table with no columns to fill +export function addRowsPlace({ canWrite, columnCount, loaded, rowCount, filtered }) { + if (!canWrite || columnCount === 0) return 'none' + if (loaded && rowCount === 0 && !filtered) return 'empty' + return 'toolbar' +} diff --git a/addons/tables/ui/src/style.css b/addons/tables/ui/src/style.css index c4795a1ba..ce9d99225 100644 --- a/addons/tables/ui/src/style.css +++ b/addons/tables/ui/src/style.css @@ -9,7 +9,7 @@ .tbl-page { display: flex; flex-direction: column; gap: var(--dk-space-4, 16px); min-width: 0; } .tbl-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3, 12px); } .tbl-head__main { display: flex; flex-direction: column; gap: var(--dk-space-2, 8px); min-width: 0; } -.tbl-title { margin: 0; font-size: var(--dk-text-18, 18px); font-weight: var(--dk-weight-semibold, 600); overflow-wrap: anywhere; } +.tbl-title { margin: 0; overflow-wrap: anywhere; } .tbl-subtitle { margin: 0; font-size: var(--dk-text-14, 14px); font-weight: var(--dk-weight-semibold, 600); } .tbl-muted { margin: 0; color: var(--dk-muted); } .tbl-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } @@ -36,7 +36,7 @@ a.tbl-db__name:hover { text-decoration: underline; } .tbl-tabs { display: flex; flex-wrap: wrap; gap: var(--dk-space-1, 4px); min-width: 0; } .tbl-tab { min-height: 32px; padding: 0 var(--dk-space-3, 12px); border: 1px solid transparent; border-radius: var(--dk-radius-1, 4px); background: transparent; color: var(--dk-text); font: inherit; cursor: pointer; } .tbl-tab:hover { background: var(--dk-hover); } -.tbl-tab--on { border-color: var(--dk-edge); background: var(--dk-inset); font-weight: var(--dk-weight-semibold, 600); } +.tbl-tab--on { border-color: transparent; background: var(--dk-accent-wash); color: var(--dk-accent-text); font-weight: var(--dk-weight-semibold, 600); } /* Toolbar, selection bar, pager */ .tbl-tableview { display: flex; flex-direction: column; gap: var(--dk-space-3, 12px); min-width: 0; } diff --git a/addons/tables/ui/test/layout.test.mjs b/addons/tables/ui/test/layout.test.mjs new file mode 100644 index 000000000..71def345e --- /dev/null +++ b/addons/tables/ui/test/layout.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { addRowsPlace, canShare } from '../src/lib/layout.js' + +test('layout: Share is offered only where the installation has named accounts', () => { + assert.equal(canShare({ sharing: true }), true) + assert.equal(canShare({ sharing: false }), false) + assert.equal(canShare({}), false) + assert.equal(canShare(null), false) +}) + +test('layout: a table with no rows offers Add rows once, in its empty state', () => { + const base = { canWrite: true, columnCount: 2, loaded: true, rowCount: 0, filtered: false } + assert.equal(addRowsPlace(base), 'empty') + assert.equal(addRowsPlace({ ...base, rowCount: 3 }), 'toolbar') + assert.equal(addRowsPlace({ ...base, filtered: true }), 'toolbar') + assert.equal(addRowsPlace({ ...base, loaded: false }), 'toolbar') +}) + +test('layout: a reader, or a table with no columns, is offered no Add rows', () => { + assert.equal(addRowsPlace({ canWrite: false, columnCount: 2, loaded: true, rowCount: 0, filtered: false }), 'none') + assert.equal(addRowsPlace({ canWrite: true, columnCount: 0, loaded: true, rowCount: 0, filtered: false }), 'none') +}) diff --git a/addons/talk/ui/src/pages/talk.css b/addons/talk/ui/src/pages/talk.css index ebd8b222f..543fc83e2 100644 --- a/addons/talk/ui/src/pages/talk.css +++ b/addons/talk/ui/src/pages/talk.css @@ -10,7 +10,8 @@ .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); } +/* The title takes the host's page-title size, like every other add-on page. */ +.talk-hd__title { margin: 0; } .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/voice/ui/src/Speech.jsx b/addons/voice/ui/src/Speech.jsx index e3550cc0d..d104719c7 100644 --- a/addons/voice/ui/src/Speech.jsx +++ b/addons/voice/ui/src/Speech.jsx @@ -29,6 +29,7 @@ export function createSpeech(ctx) { const selected = useMemo(() => models.list.find(m => m.id === model) || null, [models.list, model]) const cloning = Boolean(selected?.voice_cloning) + const noModels = models.status === 'ready' && models.list.length === 0 useEffect(() => { if (!cloning) { setProfiles([]); setVoiceId(''); return undefined } @@ -56,35 +57,44 @@ export function createSpeech(ctx) { return (
- + {tabs} - - {cloning ? ( + { e.preventDefault(); run() }} aria-label={t('voices.tab.speech')}> - ) : ( + {cloning ? ( + + ) : ( + + )}