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 <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-23 12:35:44 +02:00
1 parent 6108ba69fa
commit 0a44303c21
46 files changed
+433 -102

No files matched your search

+2 -1
View File
@@ -40,9 +40,10 @@ export function createFaceRecognition(ctx, api, identity) {
function FeatureOff() {
return (
<main className="page idn-page idn-off" data-testid="face-off">
<ui.PageHeader title={t('faces.title')} supporting={t('faces.lede')} />
<section className="idn-needed dk-card">
<span className="dk-badge dk-badge--warn"><Icon name="lock" /> {t('off.badge')}</span>
<h1 className="idn-h2">{t('off.face.title')}</h1>
<h2 className="idn-h2">{t('off.face.title')}</h2>
<p className="idn-needed__text">{t('off.face.text')}</p>
<details className="idn-disclosure" open>
<summary><Icon name="chevron-right" /> {t('needed.whatTitle')}</summary>
+2 -2
View File
@@ -918,8 +918,8 @@ export function createFineTune(ctx, api, shared) {
return (
<div className="page page--medium bt-page" data-testid="fine-tune-page">
<ui.PageHeader
title={<>{t('fineTune.title')} <span className="dk-badge dk-badge--warn bt-experimental">{t('landing.experimental')}</span></>}
supporting={t('fineTune.lede')}
title={t('fineTune.title')}
supporting={<><span className="dk-badge dk-badge--warn bt-experimental">{t('landing.experimental')}</span> {t('fineTune.lede')}</>}
actions={(
<div className="bt-head-acts">
{selectedJob && (
+8 -2
View File
@@ -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; }
@@ -42,7 +42,7 @@ export function createKnownList(ctx) {
<div className="dk-empty-icon"><Icon name={kind === 'face' ? 'smile' : 'mic'} /></div>
<h3 className="dk-empty-title">{t(`${kind}.emptyTitle`)}</h3>
<p className="dk-empty-text">{t(`${kind}.emptyText`)}</p>
<button type="button" className="dk-btn dk-btn--primary" onClick={() => onEnrol()} disabled={!canEnrol}><Icon name="plus" /> {t(`${kind}.enrol`)}</button>
<button type="button" className="dk-btn dk-btn--primary" onClick={() => onEnrol()} disabled={!canEnrol} data-testid="enrol-open"><Icon name="plus" /> {t(`${kind}.enrol`)}</button>
</div>
)
}
@@ -314,9 +314,12 @@ export function createWorkbench(ctx, deps) {
<section className="idn-known" aria-labelledby={`${kind}-known-title`}>
<div className="idn-known__head">
<h2 className="idn-h2" id={`${kind}-known-title`}>{t(`${kind}.knownTitle`)} <span className="dk-hubtab-count">{known}</span></h2>
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => openSheet({})} disabled={!model} data-testid="enrol-open">
<Icon name="plus" /> {t(`${kind}.enrol`)}
</button>
{/* With nobody saved yet the empty state below carries the one enrol button. */}
{(known > 0 || reg.extras.length > 0) && (
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => openSheet({})} disabled={!model} data-testid="enrol-open">
<Icon name="plus" /> {t(`${kind}.enrol`)}
</button>
)}
</div>
<p className="dk-hint idn-known__note" data-testid="registry-note">{t(reg.searched ? 'known.noteSearched' : 'known.note', { noun })}</p>
<KnownList
+8 -2
View File
@@ -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; }
+2 -2
View File
@@ -417,8 +417,8 @@ export function createQuantize(ctx, api, shared) {
return (
<div className="page page--medium bt-page quantize-page" data-testid="quantize-page">
<ui.PageHeader
title={<>{t('quantize.title')} <span className="dk-badge dk-badge--warn bt-experimental">{t('landing.experimental')}</span></>}
supporting={t('quantize.lede')}
title={t('quantize.title')}
supporting={<><span className="dk-badge dk-badge--warn bt-experimental">{t('landing.experimental')}</span> {t('quantize.lede')}</>}
actions={selectedJob && (
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setSelectedJob(null)} data-testid="job-back">
<Icon name="plus" /> {t('quantize.newJob')}
+8 -2
View File
@@ -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; }
@@ -154,7 +154,7 @@ export default function LineageView({
<Icon name="arrow-left" /> {t('studio.lineage.back')}
</button>
<div>
<h1 className="studio-lineage__title" title={root.title || undefined}>{clip(root.title, 72) || label(root.type)}</h1>
<h2 className="studio-lineage__title" title={root.title || undefined}>{clip(root.title, 72) || label(root.type)}</h2>
<p className="studio-lineage__sub">
{members.length > 1 ? t('studio.lineage.sub', { count: members.length }) : t('studio.lineage.subOne')}
{' · '}{relativeTime(members[members.length - 1].createdAt)}
@@ -266,7 +266,7 @@ export function ComposeCard({
return (
<form className="ws-compose" onSubmit={onSubmit} ref={ws?.composeRef} data-testid="ws-compose" data-rerun={ws?.rerunning || undefined} noValidate>
<header className="ws-compose__head">
<h1 className="page-title ws-title">{icon && <Icon name={icon} />}{title}</h1>
<h2 className="ws-title">{icon && <Icon name={icon} />}{title}</h2>
{lede && <span className="ws-lede">{lede}</span>}
</header>
{handoff}
@@ -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 ------------------------------------------------------------ */
@@ -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. */
+19 -1
View File
@@ -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 (
<div>
{/* 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. */}
<div className="studio-head">
<PageHeader
title={t('studio.overview.title')}
supporting={activeTab === 'overview' ? t('studio.overview.subtitle') : undefined}
actions={activeTab === 'overview' ? <span className="studio-key-hint"><kbd className="dk-kbd">/</kbd> {t('studio.composer.startTyping')}</span> : undefined}
/>
</div>
<div className="studio-tabs">
{tabs.map(tab => {
const dot = dotFor(tab)
@@ -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 (
<div data-testid="studio-overview" className="page-pad studio-front">
<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>}
/>
{modelsError ? (
<div className="studio-note studio-note--error studio-note--standalone" role="alert" data-testid="studio-models-error">
<h3>{t('studio.error.title')}</h3>
+17 -11
View File
@@ -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) {
<header className="tbl-head">
<div className="tbl-head__main">
<div>{back}</div>
<h1 className="tbl-title" data-testid="tbl-db-name">{name}</h1>
<h1 className="page-title 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}
@@ -109,9 +110,12 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
</div>
{isOwner ? (
<div className="tbl-toolbar__group">
<Button size="sm" icon="users" onClick={() => open(t('share.title', { name }), ({ close }) => (
<sharing.Sharing resource={{ id, name }} sharing={data.listing.sharing} onChanged={view.refresh} onLost={onLost} close={close} />
))}>{t('db.share')}</Button>
{/* Without named accounts there is nobody to share with, so no button. */}
{canShare(data.listing) ? (
<Button size="sm" icon="users" onClick={() => open(t('share.title', { name }), ({ close }) => (
<sharing.Sharing resource={{ id, name }} sharing={data.listing.sharing} onChanged={view.refresh} onLost={onLost} close={close} />
))}>{t('db.share')}</Button>
) : null}
<Button size="sm" icon="edit" onClick={() => open(t('rename.title'), ({ close }) => (
<databases.NameDialog initial={name} label={t('rename.submit')} close={close} onDone={() => view.refresh()}
submit={(n, operationId) => api.rename(id, n, db.revision(), { operationId })} />
@@ -127,13 +131,15 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
{view.state === 'error' ? <Problem error={view.error} onRetry={view.reload} /> : null}
{notice ? <Banner tone="success" testid="tbl-notice">{notice}</Banner> : null}
<nav className="tbl-tables" aria-label={t('db.tables')}>
<ul className="tbl-tabs" data-testid="tbl-tables">
{tables.map(x => (
<li key={x.name}>
<button type="button" className={current && current.name === x.name ? 'tbl-tab tbl-tab--on' : 'tbl-tab'} aria-current={current && current.name === x.name ? 'true' : undefined} onClick={() => select(x.name)} data-testid="tbl-table-tab">{x.name}</button>
</li>
))}
</ul>
{tables.length > 0 ? (
<ul className="tbl-tabs" data-testid="tbl-tables">
{tables.map(x => (
<li key={x.name}>
<button type="button" className={current && current.name === x.name ? 'tbl-tab tbl-tab--on' : 'tbl-tab'} aria-current={current && current.name === x.name ? 'true' : undefined} onClick={() => select(x.name)} data-testid="tbl-table-tab">{x.name}</button>
</li>
))}
</ul>
) : null}
{isOwner ? <Button size="sm" icon="plus" onClick={() => open(t('db.newTable'), ({ close }) => <schema.TableNameDialog db={db} onDone={changedSchema} onLost={onLost} close={close} />)}>{t('db.newTable')}</Button> : null}
</nav>
{current ? (
+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>
<h1 id="tbl-list-title" className="tbl-title">{t('list.heading')}</h1>
<h1 id="tbl-list-title" className="page-title 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}
+2 -1
View File
@@ -201,7 +201,8 @@ export function createSchema(ctx, ui, fields, hooks) {
}
function ColumnList({ table, actions }) {
if (table.columns.length === 0) return <p className="tbl-muted">{t('grid.noColumns.title')}</p>
// 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 (
<ul className="tbl-columns" data-testid="tbl-columns">
{table.columns.map(c => (
+8 -3
View File
@@ -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) {
<exporter.ExportDialog db={db} table={table} args={built.arguments} filtered={hasFilter} close={close} onLost={onLost} />
))
}
// 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 = <EmptyState icon="table" title={t('grid.noColumns.title')} text={db.role === 'owner' ? t('grid.noColumns.owner') : t('grid.noColumns.other')} />
@@ -122,7 +127,7 @@ export function createTableView(ctx, ui, api, hooks, rows, filters, exporter) {
} else if (shown && pageRows.length === 0) {
body = hasFilter
? <EmptyState icon="search" title={t('grid.noMatch.title')} text={t('grid.noMatch.text')}><Button onClick={clearFilter}>{t('filter.clear')}</Button></EmptyState>
: <EmptyState icon="table" title={t('grid.noRows.title')} text={t('grid.noRows.text')}>{canWrite ? <Button variant="primary" icon="plus" onClick={addRows}>{t('grid.addRows')}</Button> : null}</EmptyState>
: <EmptyState icon="table" title={t('grid.noRows.title')} text={t('grid.noRows.text')}>{addPlace === 'empty' ? <Button variant="primary" icon="plus" onClick={addRows}>{t('grid.addRows')}</Button> : null}</EmptyState>
} else if (shown) {
body = (
<div className="tbl-gridwrap" data-testid="tbl-grid-wrap">
@@ -191,9 +196,9 @@ export function createTableView(ctx, ui, api, hooks, rows, filters, exporter) {
<Button size="sm" icon="refresh" onClick={() => { setBump(n => n + 1) }}>{t('grid.refresh')}</Button>
<Button size="sm" icon="download" disabled={columns.length === 0} onClick={exportFile}>{t('export.action')}</Button>
</div>
{canWrite ? (
{addPlace === 'toolbar' ? (
<div className="tbl-toolbar__group">
<Button size="sm" variant="primary" icon="plus" disabled={columns.length === 0} onClick={addRows}>{t('grid.addRows')}</Button>
<Button size="sm" variant="primary" icon="plus" onClick={addRows}>{t('grid.addRows')}</Button>
</div>
) : null}
</div>
+18
View File
@@ -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'
}
+2 -2
View File
@@ -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; }
+23
View File
@@ -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')
})
+2 -1
View File
@@ -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 {
+34 -24
View File
@@ -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 (
<main className="page vc-speech" data-testid="voice-speech">
<ui.PageHeader title={t('speech.title')} supporting={t('speech.lede')} />
<ui.PageHeader title={t('voices.title')} supporting={t('speech.lede')} />
{tabs}
<label className="vc-field">
<span>{t('speech.model')}</span>
<ui.ModelSelector value={model} onChange={setModel} options={models.list.map(m => m.id)} loading={models.status === 'loading'} />
</label>
{cloning ? (
<form className="vc-speech__form dk-card" onSubmit={e => { e.preventDefault(); run() }} aria-label={t('voices.tab.speech')}>
<label className="vc-field">
<span>{t('speech.voice')}</span>
<select className="dk-input" value={voiceId} onChange={e => setVoiceId(e.target.value)} aria-label={t('speech.voice')}>
<option value="">{t('speech.defaultVoice')}</option>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<span className="dk-label">{t('speech.model')}</span>
<ui.ModelSelector value={model} onChange={setModel} options={models.list.map(m => m.id)} loading={models.status === 'loading'} />
{noModels && (
<span className="dk-hint" data-testid="speech-no-models">
{t('speech.noModels')} <a href={`${ctx.basePath}/app/models`}>{t('speech.noModelsLink')}</a>
</span>
)}
</label>
) : (
{cloning ? (
<label className="vc-field">
<span className="dk-label">{t('speech.voice')}</span>
<select className="dk-input" value={voiceId} onChange={e => setVoiceId(e.target.value)} aria-label={t('speech.voice')}>
<option value="">{t('speech.defaultVoice')}</option>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</label>
) : (
<label className="vc-field">
<span className="dk-label">{t('speech.voice')}</span>
<input className="dk-input" value={manualVoice} placeholder={t('speech.voicePlaceholder')} onChange={e => setManualVoice(e.target.value)} />
</label>
)}
<label className="vc-field">
<span>{t('speech.voice')}</span>
<input className="dk-input" value={manualVoice} onChange={e => setManualVoice(e.target.value)} />
<span className="dk-label">{t('speech.text')}</span>
<textarea className="dk-input" rows={5} value={text} placeholder={t('speech.textPlaceholder')} onChange={e => setText(e.target.value)} />
</label>
)}
<label className="vc-field">
<span>{t('speech.text')}</span>
<textarea className="dk-input" rows={5} value={text} onChange={e => setText(e.target.value)} />
</label>
<label className="vc-field">
<span>{t('speech.instructions')}</span>
<input className="dk-input" value={instructions} onChange={e => setInstructions(e.target.value)} />
</label>
<ui.Button variant="primary" busy={busy} onClick={run}>{t('speech.generate')}</ui.Button>
<label className="vc-field">
<span className="dk-label">{t('speech.instructions')}</span>
<input className="dk-input" value={instructions} onChange={e => setInstructions(e.target.value)} />
</label>
<div className="vc-speech__acts">
<ui.Button variant="primary" type="submit" busy={busy}>{t('speech.generate')}</ui.Button>
</div>
</form>
{error && <ui.ErrorWithTraceLink message={error} />}
{audioUrl && <ui.WaveformPlayer src={audioUrl} label={t('speech.result')} />}
{!viewer.hasFeature('audio_speech') && <p className="vc-note">{t('speech.noAccess')}</p>}
+3 -2
View File
@@ -366,14 +366,15 @@ JSON`
return (
<main className="page voice-library-page" data-testid="voice-library">
<ui.PageHeader
title={<>{t('voiceLibrary.title')} <span className="dk-hubtab-count">{profiles.length}</span></>}
title={t('voices.title')}
supporting={t('voiceLibrary.subtitle')}
actions={(
<div className="idn-speech__acts">
{cloningModels.length > 0
? <span className="dk-badge dk-badge--ok"><Icon name="check" /> {t(cloningModels.length === 1 ? 'voiceLibrary.summary.modelsReady_one' : 'voiceLibrary.summary.modelsReady_other', { count: cloningModels.length })}</span>
: <span className="dk-badge dk-badge--warn">{t('voiceLibrary.summary.noModels')}</span>}
{newButton('dk-btn dk-btn--secondary')}
{/* With no voices the empty state below carries the one primary action. */}
{profiles.length > 0 && newButton('dk-btn dk-btn--secondary')}
</div>
)}
/>
+2 -1
View File
@@ -38,10 +38,11 @@ export function createVoiceRecognition(ctx, api, identity) {
function FeatureOff({ feature, tabs }) {
return (
<main className="page idn-page idn-off" data-testid="voice-off">
<ui.PageHeader title={t('voices.title')} supporting={t('voices.lede')} />
{tabs}
<section className="idn-needed dk-card">
<span className="dk-badge dk-badge--warn"><Icon name="lock" /> {t('off.badge')}</span>
<h1 className="idn-h2">{t(`off.${feature}.title`)}</h1>
<h2 className="idn-h2">{t(`off.${feature}.title`)}</h2>
<p className="idn-needed__text">{t(`off.${feature}.text`)}</p>
<details className="idn-disclosure" open>
<summary><Icon name="chevron-right" /> {t('needed.whatTitle')}</summary>
+4
View File
@@ -11,6 +11,10 @@ export default {
"speech.instructions": "Instructions (optional)",
"speech.generate": "Generate speech",
"speech.result": "Result",
"speech.noModels": "No text-to-speech model is installed.",
"speech.noModelsLink": "Get one from the model gallery.",
"speech.voicePlaceholder": "Optional speaker or voice ID",
"speech.textPlaceholder": "What should it say?",
"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.",
+4
View File
@@ -1,5 +1,9 @@
.vc-undo { display: flex; gap: var(--dk-space-2, 8px); align-items: center; }
.vc-field { display: flex; flex-direction: column; gap: var(--dk-space-1, 4px); margin-bottom: var(--dk-space-3, 12px); }
.vc-speech__form { display: flex; flex-direction: column; gap: var(--dk-space-3, 12px); max-width: 640px; padding: var(--dk-space-4, 16px) var(--dk-space-5, 20px); margin-bottom: var(--dk-space-3, 12px); }
.vc-speech__form .vc-field { margin-bottom: 0; }
.vc-speech__form .dk-hint a { color: var(--dk-accent-text, var(--dk-accent)); }
.vc-speech__acts { display: flex; justify-content: flex-start; }
.vc-note { color: var(--dk-text-muted, var(--dk-text)); }
/* From pages/identity.css: the rules the library and create pages and the clip input use. */
+3 -3
View File
@@ -84,13 +84,13 @@ test('render: an older page name draws nothing itself; the shell sends it to its
}
})
test('render: each tab draws exactly one h1, its own title', async () => {
test('render: each tab draws exactly one h1, the one Voice title (the add form keeps its own)', 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: 'speech' }))), ['Voices'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))), ['Voices'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library/new' }))), ['Add a speech voice'])
})
+27
View File
@@ -37,3 +37,30 @@ test('speech: a failed answer surfaces the API message', async () => {
const core = { fetch: async () => ({ ok: false, status: 403, json: async () => ({ error: { message: 'feature not enabled for your account' } }), blob: async () => null }) }
await assert.rejects(() => speak(core, { model: 'm', input: 'x' }), /feature not enabled for your account/)
})
test('speech: the tab draws the one Voice title, a styled form and no second title', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech' }))
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Voices'])
assert.doesNotMatch(html, /Speak text/)
assert.match(html, /<form class="vc-speech__form dk-card"/)
assert.match(html, /placeholder="Optional speaker or voice ID"/)
})
test('library: the tab keeps the one Voice title and shows no count in it', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Voices'])
// While the list loads, or when it is empty, the empty state carries the one add button.
assert.doesNotMatch(html, /data-testid="voice-library-new"/)
})
test('off notice: a viewer without the feature gets the page title and a section heading, one h1', async () => {
const { mod, sdk } = await load({ viewer: { isAdmin: () => false, hasFeature: (f) => f !== 'voice_recognition' } })
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recognition' }))
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Voices'])
assert.match(html, /<h2 class="idn-h2">/)
})
@@ -149,6 +149,9 @@ test.describe('Add-on navigation', () => {
await expect(card).toBeVisible()
await expect(card.getByRole('listitem')).toHaveCount(3)
await expect(card).toContainText('official library is registered by default')
// One way into Browse while the card shows: the bar's button waits.
await expect(page.getByRole('link', { name: 'Browse add-ons' })).toHaveCount(1)
await expect(page.getByTestId('addons-add')).toHaveCount(0)
await expect(page.getByTestId('addon-tile')).toHaveCount(0)
await card.getByTestId('addons-first-run-browse').click()
await expect(page).toHaveURL(/tab=browse/)
@@ -160,6 +163,7 @@ test.describe('Add-on navigation', () => {
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile').first()).toBeVisible()
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
await expect(page.getByTestId('addons-add')).toBeVisible()
})
test('the first-run card is hidden from a caller who cannot install add-ons', async ({ page }) => {
@@ -201,7 +205,7 @@ test.describe('Add-on navigation', () => {
await page.route(/\/api\/addons\/apps$/, route => { appsCalled = true; return route.fulfill({ status: 403, json: {} }) })
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
await expect(page.getByTestId('addons-browse')).toHaveCount(0)
await expect(page.getByTestId('addons-add')).toHaveCount(0)
await expect(page.getByRole('alert')).toHaveCount(0)
expect(appsCalled).toBe(false)
})
@@ -416,6 +420,8 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
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')
// With nobody saved there is one enrol button, in the empty state.
await expect(page.getByRole('button', { name: /Enrol a speaker/ })).toHaveCount(1)
})
test('an old Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
@@ -461,7 +467,7 @@ 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, /^Speech voices/)
await expectOneVoiceTitle(page, 'Voices')
await page.getByTestId('voice-library-new').first().click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library\/new$/)
@@ -473,7 +479,7 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/speech$/)
await expect(page.getByTestId('voice-speech')).toBeVisible()
await expectVoiceTabs(page, 'speech')
await expectOneVoiceTitle(page, 'Speak text')
await expectOneVoiceTitle(page, 'Voices')
await tabs.getByRole('tab', { name: /^From a recording$/ }).click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recording$/)
@@ -482,6 +488,20 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await expectOneVoiceTitle(page, 'Voices')
})
test('the Text to speech tab says where to get a model when none is installed', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await asAdmin(page)
await serveVoice(page)
await mockIdentity(page)
// Registered last, so it wins over the fixture's model list.
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [] } }))
await page.goto('/app/addons/voice/voice/speech')
const hint = page.getByTestId('speech-no-models')
await expect(hint).toBeVisible({ timeout: 15_000 })
await expect(hint.getByRole('link', { name: /model gallery/ })).toHaveAttribute('href', /\/app\/models$/)
await expectOneVoiceTitle(page, 'Voices')
})
test('a viewer who is not an administrator gets no Library tab', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, voice_recognition: true }) }))
@@ -695,6 +715,18 @@ test.describe('Add-on navigation: the Face recognition page', () => {
await expect(page.getByTestId('tool-off')).toBeVisible()
})
test('Fine-tune titles itself with its name and keeps the Experimental badge out of the h1', async ({ page }) => {
await setup(page, { apps: [ftApp()] })
await asAdmin(page, { fine_tuning: true })
await page.route('**/api/fine-tuning/jobs', route => route.fulfill({ json: [] }))
await serveFineTuning(page)
await page.goto('/app/addons/fine-tuning/jobs')
await expect(page.getByTestId('fine-tune-page')).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Fine-tune')
await expect(page.locator('h1 .bt-experimental')).toHaveCount(0)
await expect(page.locator('.bt-experimental')).toBeVisible()
})
test('an old fine-tuning link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
@@ -83,6 +83,14 @@ test.describe('Add-on shell', () => {
expect(requested).toEqual([])
})
test('an add-on that is not listed is titled with its name, not the raw identifier', async ({ page }) => {
await serve(page)
await mockAddons(page, { apps: [uiApp()] })
await page.goto('/app/addons/voice/home')
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Voice')
})
test('a withdrawn add-on is unmounted on the next list and Reload brings it back', async ({ page }) => {
await serve(page)
const state = await mockAddons(page, { apps: [uiApp()] })
@@ -39,6 +39,25 @@ test.describe('Studio add-on', () => {
await expect(page.locator('.studio-front').getByText(/studio\.[a-z]+\.[A-Za-z.]+/)).toHaveCount(0)
})
test('/studio/3d opens the 3D tab, and a generator tab has the one page title', async ({ page }) => {
await mockStudio(page)
await page.goto('/app/addons/studio/studio/3d')
await expect(page.locator('.studio-tab-active[data-tab="threed"]')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('h1')).toHaveCount(1)
await expect(page.locator('h1.page-title')).toHaveText('Studio')
// The card of the generator is a section under that title.
await expect(page.getByTestId('ws-compose').getByRole('heading', { level: 2 })).toBeVisible()
})
test('the tab strip stays on one row at 1280 wide', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 })
await mockStudio(page)
await page.goto('/app/addons/studio/studio/tts')
await expect(page.locator('.studio-tab-active[data-tab="tts"]')).toBeVisible({ timeout: 15_000 })
const tops = await page.locator('.studio-tab').evaluateAll(tabs => [...new Set(tabs.map(el => Math.round(el.getBoundingClientRect().top)))])
expect(tops).toHaveLength(1)
})
test('a model id with a slash and a colon travels as ?model= and reaches the page', async ({ page }) => {
await mockStudio(page)
await page.goto('/app/studio/images/sd-1.5-lcm?size=768x768')
@@ -7,6 +7,6 @@ test.describe('Studio - Transform', () => {
test('Studio exposes a Transform tab that renders Audio Transform', async ({ page }) => {
await page.goto('/app/studio/transform')
await expect(page.locator('.studio-tab', { hasText: 'Transform' })).toBeVisible()
await expect(page.locator('h1.page-title', { hasText: 'Audio Transform' })).toBeVisible({ timeout: 15_000 })
await expect(page.locator('h2.ws-title', { hasText: 'Audio Transform' })).toBeVisible({ timeout: 15_000 })
})
})
@@ -33,7 +33,9 @@ test.describe('Workspace frame (Images)', () => {
test('the compose card has the model chip, the essentials, an estimate and one action', async ({ page }) => {
await setup(page)
await page.goto('/app/studio/images')
await expect(compose(page).getByRole('heading', { level: 1 })).toContainText('Image')
// The page title is Studio's own h1 above the tabs; the card heading is a section.
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Studio')
await expect(compose(page).getByRole('heading', { level: 2 })).toContainText('Image')
await expect(compose(page).getByRole('button', { name: 'flux.1-schnell' })).toBeVisible()
await expect(page.locator('[data-testid="ws-size"]')).toHaveValue('512x512')
await expect(page.locator('[data-testid="ws-count"]')).toHaveValue('1')
+3 -4
View File
@@ -919,10 +919,9 @@ test.describe('Tables UI', () => {
await page.getByTestId('tbl-name-dialog').getByLabel('Name').fill('Local notes')
await page.getByTestId('tbl-name-dialog').getByRole('button', { name: 'Create database' }).click()
await expect(page.getByTestId('tbl-db-name')).toHaveText('Local notes', { timeout: 25_000 })
await page.getByRole('button', { name: 'Share' }).click()
const off = page.getByTestId('tbl-sharing-disabled')
await expect(off).toBeVisible({ timeout: 25_000 })
await expect(off).toContainText('one local owner')
// Nobody to share with, so the page does not offer the button at all.
await expect(page.getByRole('button', { name: 'Rename' })).toBeVisible({ timeout: 25_000 })
await expect(page.getByRole('button', { name: 'Share' })).toHaveCount(0)
await shoot(page, 'tables-ui-local-sharing')
// No grant or picker request is ever made, and the host says the same.
expect(requests.filter(r => /\/grants|\/users/.test(r))).toEqual([])
+2 -1
View File
@@ -11,6 +11,7 @@ import { useAuth } from './context/AuthContext'
import RouteFallback from './components/RouteFallback'
import { hubs, hubOwnsPath } from './components/hub/hubConfig'
import Icon from './components/Icon'
import { displayVersion } from './utils/version'
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
@@ -48,7 +49,7 @@ export default function App() {
useEffect(() => {
systemApi.version()
.then(data => setVersion(typeof data === 'string' ? data : (data?.version || '')))
.then(data => setVersion(displayVersion(typeof data === 'string' ? data : (data?.version || ''))))
.catch(() => {})
}, [])
+4 -4
View File
@@ -6,7 +6,7 @@ import PageHeader from '../components/PageHeader'
import CatalogEmpty from '../components/catalog/CatalogEmpty'
import LoadingSpinner from '../components/LoadingSpinner'
import ConfirmDialog from '../components/ConfirmDialog'
import { localizedText } from '../components/addons/addonState'
import { addonDisplayName, localizedText } from '../components/addons/addonState'
import AddonErrorBoundary from './AddonErrorBoundary'
import { useAddonRegistry } from './useAddonRegistry'
import { appStatus } from './registryState'
@@ -45,7 +45,7 @@ export default function AddonRoute() {
const app = registry.apps.find(a => a.id === addonId) || null
const instance = registry.instances.find(i => i.id === addonId) || null
const status = appStatus(app)
const title = (app && localizedText(app, 'title', i18n.language)) || addonId
const title = (app && localizedText(app, 'title', i18n.language)) || addonDisplayName(addonId)
const pages = app && app.ui ? app.ui.pages || [] : []
const subpath = splat || ''
const resolved = subpath ? resolveSubpath(subpath, pages) : null
@@ -136,7 +136,7 @@ export default function AddonRoute() {
// mounts it draws its own title, even to say it is not enabled on this
// server, and the shell draws none.
const unavailable = (reason, extra) => (
<div className="page ad-page" data-testid="addon-unavailable" data-reason={reason}>
<div className="page ad-page ad-page--shell" data-testid="addon-unavailable" data-reason={reason}>
<PageHeader title={title} />
<CatalogEmpty icon="puzzle" title={t('shell.unavailable.title')} text={t(`shell.reasons.${REASONS.includes(reason) ? reason : 'import_failed'}`)} role="status">
<div className="ad-shell-actions">
@@ -177,7 +177,7 @@ export default function AddonRoute() {
}
if (!resolved && !isStale) {
return (
<div className="page ad-page" data-testid="addon-missing-page">
<div className="page ad-page ad-page--shell" data-testid="addon-missing-page">
<PageHeader title={title} />
<CatalogEmpty icon="puzzle" title={t('shell.pageMissing.title')} text={t('shell.pageMissing.body', { title, page: subpath.split('/')[0] || '' })} role="status">
<div className="ad-shell-actions">{back}</div>
@@ -141,6 +141,7 @@ export default function AppsView({ api }) {
)
}
const firstRun = showFirstRun({ apps: state.apps, isAdmin, canList })
return (
<div className="ad-apps">
{state.stale && (
@@ -159,14 +160,13 @@ export default function AppsView({ api }) {
value={query}
onChange={e => setQuery(e.target.value)}
/>
{isAdmin && (
<>
<Link className="dk-btn dk-btn--sm" to="/app/addons?tab=browse" data-testid="addons-browse"><Icon name="search" /> {t('drawer.browse')}</Link>
<Link className="dk-btn dk-btn--primary dk-btn--sm" to="/app/addons?tab=browse" data-testid="addons-add"><Icon name="plus" /> {t('drawer.add')}</Link>
</>
{/* One way into Browse per screen: the first-run card carries it while
nothing is installed, and the bar carries it afterwards. */}
{isAdmin && !firstRun && (
<Link className="dk-btn dk-btn--primary dk-btn--sm" to="/app/addons?tab=browse" data-testid="addons-add"><Icon name="plus" /> {t('drawer.add')}</Link>
)}
</div>
{showFirstRun({ apps: state.apps, isAdmin, canList }) ? (
{firstRun ? (
<section className="ad-card ad-first" data-testid="addons-first-run" aria-labelledby="ad-first-title">
<h2 className="ad-card__title" id="ad-first-title">{t('apps.firstRun.title')}</h2>
<ol className="ad-first__steps">
@@ -66,6 +66,14 @@ export function localizedText(app, field, language) {
return fallback || ''
}
// The name to show for an add-on the list does not carry (not installed, or
// not usable by this viewer): its identifier with a capital first letter, so
// the page says "Voice" and not "voice".
export function addonDisplayName(id) {
const text = String(id || '').trim()
return text ? text.charAt(0).toUpperCase() + text.slice(1) : ''
}
// Tool summary of a headless add-on: the names the server sent and how many
// more there are.
export function toolSummary(app, limit = 4) {
@@ -9,6 +9,12 @@
.ad-page { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
.ad-page .page-header { margin-bottom: 0; }
/* A page an add-on draws sits in this shell page and in its own .page, so its
content starts two page paddings in. The shell's own pages (not available,
no such page) take the same inset, so the title does not jump when an
add-on comes and goes. */
.ad-page--shell { padding-inline: calc(var(--spacing-xl) * 2); }
@media (max-width: 639px) { .ad-page--shell { padding-inline: calc(var(--spacing-md) * 2); } }
.ad-panel { min-width: 0; }
/* ---- Tabs ---------------------------------------------------------------- */
@@ -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; }
+8
View File
@@ -0,0 +1,8 @@
// The version string the server sends is "<version> (<commit>)". A build made
// without the linker flags has neither, and the string is then " ()": nothing
// worth showing. This returns '' for that, and the text trimmed otherwise.
export function displayVersion(raw) {
const text = String(raw == null ? '' : raw).trim()
if (/^\(\s*\)$/.test(text)) return ''
return text.replace(/\s*\(\s*\)\s*$/, '').trim()
}
@@ -0,0 +1,69 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
// One rule for the title of an add-on page: it is the host's page title (the
// `page-title` class, drawn by PageHeader or by an h1 that carries the class),
// at the host's size. An add-on style sheet does not resize it, and a card or a
// section inside the page is an h2, never a second h1.
const here = dirname(fileURLToPath(import.meta.url))
const addons = join(here, '../../../../../addons')
const ids = readdirSync(addons).filter(n => existsSync(join(addons, n, 'ui/src')))
function files(dir, ext) {
const out = []
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === 'dist') continue
const p = join(dir, name)
if (statSync(p).isDirectory()) out.push(...files(p, ext))
else if (ext.test(name)) out.push(p)
}
return out
}
test('addon titles: every h1 an add-on draws itself carries the host page-title class', () => {
const bad = []
for (const id of ids) {
for (const f of files(join(addons, id, 'ui/src'), /\.jsx$/)) {
for (const m of readFileSync(f, 'utf8').matchAll(/<h1\b([^>]*)>/g)) {
if (!/page-title/.test(m[1])) bad.push(`${id}: ${f.split('/ui/src/')[1]}: <h1${m[1]}>`)
}
}
}
assert.deepEqual(bad, [])
})
test('addon titles: no add-on style sheet sets the size of its page title', () => {
const bad = []
for (const id of ids) {
for (const f of files(join(addons, id, 'ui/src'), /\.css$/)) {
for (const m of readFileSync(f, 'utf8').matchAll(/([^{}]*(?:title|__title|-hd__title)[^{}]*)\{([^}]*)\}/g)) {
const selector = m[1].trim()
// Only the rules for the page's own title: the three that used to resize it.
if (/^(\.tbl-title|\.talk-hd__title|\.ws-title\b.*)$/.test(selector) && /font-size|font:/.test(m[2]) && selector !== '.ws-title') {
bad.push(`${id}: ${selector}`)
}
}
}
}
assert.deepEqual(bad, [])
})
test('addon titles: the Studio card heading is a section, and Studio draws the one page title', () => {
const workspace = readFileSync(join(addons, 'studio/ui/src/components/studio/Workspace.jsx'), 'utf8')
assert.match(workspace, /<h2 className="ws-title">/)
const studio = readFileSync(join(addons, 'studio/ui/src/pages/Studio.jsx'), 'utf8')
assert.match(studio, /<PageHeader/)
})
test('addon titles: the experimental badge of Fine-tune and Quantize sits outside the h1', () => {
for (const [id, file] of [['fine-tuning', 'FineTune.jsx'], ['quantization', 'Quantize.jsx']]) {
const src = readFileSync(join(addons, id, 'ui/src', file), 'utf8')
const title = /title=\{([^\n]*)\}\n\s*supporting=\{([^\n]*)\}/.exec(src)
assert.ok(title, `${id}: header not found`)
assert.doesNotMatch(title[1], /experimental/)
assert.match(title[2], /landing\.experimental/)
}
})
@@ -0,0 +1,25 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { displayVersion } from '../../src/utils/version.js'
import { addonDisplayName } from '../../src/components/addons/addonState.js'
test('displayVersion shows nothing for the empty version of a source build', () => {
assert.equal(displayVersion(' ()'), '')
assert.equal(displayVersion('()'), '')
assert.equal(displayVersion(''), '')
assert.equal(displayVersion(undefined), '')
assert.equal(displayVersion(null), '')
})
test('displayVersion keeps a real version and drops only an empty commit', () => {
assert.equal(displayVersion('v3.4.0 (abc123)'), 'v3.4.0 (abc123)')
assert.equal(displayVersion('v3.4.0 ()'), 'v3.4.0')
assert.equal(displayVersion(' v3.4.0 '), 'v3.4.0')
})
test('addonDisplayName capitalises the identifier of an add-on the list does not carry', () => {
assert.equal(addonDisplayName('voice'), 'Voice')
assert.equal(addonDisplayName('fine-tuning'), 'Fine-tuning')
assert.equal(addonDisplayName(''), '')
assert.equal(addonDisplayName(undefined), '')
})
+1 -1
View File
@@ -36,7 +36,7 @@ go build ./addons/tables/...
[Backup, upgrade and restore](#backup-upgrade-and-restore)). A copy of the data
directory made while the add-on runs is not a consistent backup.
- **Ownership mode.** Named-user mode keeps databases private to their owners and
shareable. Local-owner mode has one owner and no sharing. The mode is stored with the
shareable. Local-owner mode has one owner and no sharing, so the database page shows no Share button. The mode is stored with the
data and is changed only while the host is stopped, with an explicit mapping (see
[Moving between the modes](../addons/#moving-between-the-modes)).
- **CSV.** An export is a private, temporary file with the formula policy described in
+3
View File
@@ -2995,6 +2995,9 @@
"unit: batch: every cell error is named by row and column and nothing is sent",
"unit: batch: steps stay inside the add-on bounds",
"unit: batch: the selection toggles by row identifier and survives paging",
"unit: layout: Share is offered only where the installation has named accounts",
"unit: layout: a table with no rows offers Add rows once, in its empty state",
"unit: layout: a reader, or a table with no columns, is offered no Add rows",
"unit: errors: each answer of the host gets its own words and recovery",
"unit: codecs: text keeps what was typed, bounds it, and null is explicit",
"unit: codecs: integers are exact up to 64 bits and never silently rounded",