app store panel keyed (553 -> 544)

StatusBadge's aria-labels (installing, failed, disabled, installed) are keyed too.
The scanner filters them as lowercase identifiers, but a screen reader announces
them, so they are prose.
This commit is contained in:
2026-08-23 14:13:04 +00:00
parent f4ab274db9
commit 26039e52b1
4 changed files with 70 additions and 19 deletions
@@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router';
import { Loader2, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore';
import { useTranslation } from '../../locales';
// One entry: what it is, how to install it, and what to do about it once installed.
//
@@ -41,6 +42,7 @@ function Field({ field, value, onChange }: { field: ConfigField; value: string;
}
export const AppStoreDetail = () => {
const { t } = useTranslation();
const [params] = useSearchParams();
const id = params.get('selected');
const { items, install, setEnabled, uninstall } = useAppStore();
@@ -51,7 +53,11 @@ export const AppStoreDetail = () => {
const [log, setLog] = useState<string[]>([]);
if (!item) {
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select an app</div>;
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t('appStore.selectApp')}
</div>
);
}
// `existing` is first in `modes` by catalogue rule, so the default lands on "I already have one" —
@@ -82,13 +88,13 @@ export const AppStoreDetail = () => {
person. `completeAt` links to the page that mints the token. */}
{item.install.status === 'blocked' && item.install.lastError && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<div className="font-medium">Needs you</div>
<div className="font-medium">{t('appStore.needsYou')}</div>
<p className="mt-1 text-muted-foreground">{item.install.lastError}</p>
</div>
)}
{item.install.status === 'failed' && item.install.lastError && (
<div className="rounded-md border-destructive/40 bg-destructive/10 border p-3 text-sm">
<div className="font-medium">Install failed</div>
<div className="font-medium">{t('appStore.installFailed')}</div>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{item.install.lastError}</p>
</div>
)}
@@ -97,7 +103,7 @@ export const AppStoreDetail = () => {
<div className="flex flex-col gap-3">
<div className="text-sm text-muted-foreground">
Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '}
{item.install.enabled ? 'enabled' : 'disabled'}
{item.install.enabled ? t('appStore.enabled') : t('appStore.disabled')}
{item.processStatus && ` · process ${item.processStatus}`}
</div>
<div className="flex gap-2">
@@ -106,17 +112,14 @@ export const AppStoreDetail = () => {
disabled={setEnabled.isPending}
onClick={() => setEnabled.mutate({ id: item.id, enabled: !item.install.enabled })}
>
{item.install.enabled ? 'Disable' : 'Enable'}
{item.install.enabled ? t('appStore.disable') : t('appStore.enable')}
</Button>
<Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}>
Uninstall
{t('appStore.uninstall')}
</Button>
</div>
{/* Said plainly, because it is the question anyone hesitates over before clicking. */}
<p className="text-xs text-muted-foreground">
Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data,
configuration and this app’s tables are kept either way.
</p>
<p className="text-xs text-muted-foreground">{t('appStore.removalNote')}</p>
</div>
) : (
// A real <form>, so pressing Enter in a field installs — the div it used to be silently ate it.
@@ -1,6 +1,7 @@
import { Link, useSearchParams } from 'react-router';
import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react';
import { useAppStore, type StoreItem } from './useAppStore';
import { useTranslation } from '../../locales';
// The list of everything installable, with what has happened to each.
//
@@ -10,26 +11,31 @@ import { useAppStore, type StoreItem } from './useAppStore';
/** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */
function StatusBadge({ item }: { item: StoreItem }) {
const { t } = useTranslation();
const { status, enabled } = item.install;
if (status === 'not-installed') return null;
if (status === 'installing') {
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label="installing" />;
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label={t('appStore.installing')} />;
}
if (status === 'failed') return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label="failed" />;
if (status === 'failed')
return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label={t('appStore.failed')} />;
// Blocked is not a failure: everything worked and it is waiting for something only a person can give.
if (status === 'blocked') return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label="needs you" />;
if (!enabled) return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label="disabled" />;
if (status === 'blocked')
return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label={t('appStore.needsYouShort')} />;
if (!enabled)
return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label={t('appStore.disabled')} />;
// Installed and enabled, but the process is not running — the case worth surfacing rather than
// showing a tick that lies.
if (item.processStatus && item.processStatus !== 'online') {
return <AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label={item.processStatus} />;
}
return <Check className="h-3.5 w-3.5 text-success" aria-label="installed" />;
return <Check className="h-3.5 w-3.5 text-success" aria-label={t('appStore.installedIcon')} />;
}
export const AppStoreList = () => {
const { t } = useTranslation();
const { items, isLoading, error } = useAppStore();
const [params] = useSearchParams();
const selected = params.get('selected');
@@ -37,14 +43,14 @@ export const AppStoreList = () => {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('appStore.loading')}
</div>
);
}
if (error) {
// Distinguished from "nothing installable", which would otherwise look identical and is not a
// problem the user can act on.
return <div className="p-4 text-sm text-destructive">Could not load the app store.</div>;
return <div className="p-4 text-sm text-destructive">{t('appStore.loadFailed')}</div>;
}
const installed = items.filter((i) => i.install.status !== 'not-installed');
@@ -66,13 +72,17 @@ export const AppStoreList = () => {
<div className="flex h-full flex-col overflow-y-auto p-2">
{installed.length > 0 && (
<>
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Installed</div>
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('appStore.installed')}
</div>
{installed.map((item) => (
<Row key={item.id} item={item} />
))}
</>
)}
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Available</div>
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('appStore.available')}
</div>
{available.map((item) => (
<Row key={item.id} item={item} />
))}
@@ -32,5 +32,24 @@
"noShells": "No shells running.",
"refresh": "Refresh",
"kill": "Kill this shell"
},
"appStore": {
"loading": "Loading",
"loadFailed": "Could not load the app store.",
"installed": "Installed",
"available": "Available",
"needsYou": "Needs you",
"needsYouShort": "needs you",
"selectApp": "Select an app",
"installFailed": "Install failed",
"uninstall": "Uninstall",
"enable": "Enable",
"disable": "Disable",
"enabled": "enabled",
"disabled": "disabled",
"removalNote": "Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data, configuration and this app’s tables are kept either way.",
"installing": "installing",
"failed": "failed",
"installedIcon": "installed"
}
}
@@ -32,5 +32,24 @@
"noShells": "Nenhuma shell em execução.",
"refresh": "Atualizar",
"kill": "Terminar esta shell"
},
"appStore": {
"loading": "A carregar",
"loadFailed": "Não foi possível carregar a loja de aplicações.",
"installed": "Instaladas",
"available": "Disponíveis",
"needsYou": "Precisa de si",
"needsYouShort": "precisa de si",
"selectApp": "Selecione uma aplicação",
"installFailed": "A instalação falhou",
"uninstall": "Desinstalar",
"enable": "Ativar",
"disable": "Desativar",
"enabled": "ativada",
"disabled": "desativada",
"removalNote": "Desativar para o sidecar e o seu contentor. Desinstalar remove também os contentores — os seus dados, a configuração e as tabelas desta aplicação são mantidos em qualquer dos casos.",
"installing": "a instalar",
"failed": "falhou",
"installedIcon": "instalada"
}
}