mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 00:59:28 -04:00
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:
1 parent
6108ba69fa
commit
0a44303c21
46 files changed
+433
-102
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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(() => {})
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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
@@ -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), '')
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user