fix(ui): design critique overhaul — a11y, honest metrics, i18n repair (critique 2026-08-30)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA combobox + listbox pattern for language selector, role=switch on glossary toggle, role=status live region on notifications, aria-labels on password toggles, visible-on-focus close buttons, RTL logical positioning (start-*). Trust: third-party Memento promo removed from translate page, sidebar and all 13 locales; fabricated stats (99.9%, Turbo, computed layout-integrity bars) replaced with real measurements incl. API estimated remaining time; silent download failure now surfaces an error notification. Honesty: fake 100-byte file injections removed (format chips are now informational); cancel-that-doesn't renamed 'Back to start' with hint; Enterprise contact placeholder replaced with contact@wordly.art. i18n: t() no longer returns raw keys (empty string + defaultValue support, ~30 dead || fallbacks now work); ~170 new keys EN+FR across new reviews/ teams namespaces, glossaries context tab, translate monitor, settings, services, pricing, landing, fileUploader; split-key italic titles replace lastIndexOf() surgery (zh/ja-safe); key-audit script added 0 missing. Flow: active job persisted across refresh with polling resume (24h TTL); client-side recent-jobs history with review links; review page linked from complete state; settings/services added to dashboard nav; Business/ Enterprise regain glossary access (tier gate unified). Typeset (sober-tool direction): 7.5-9px labels raised to 10-12px, /30 opacity to /45-/55, uppercase tracking reduced, trust footer legible, country flags removed from language switcher, localized dates. Cleanup: 5 orphaned translate components, dead site header/footer, fossil tailwind.config.js, PipelineStepper, duplicate pill+H1 titles, two-step confirm for cache clear, dead landing footer links. Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD (no regression, -3 warnings), detector 4 -> 3 findings.
This commit is contained in:
@@ -24,6 +24,7 @@ function Combobox({
|
||||
autoLabel,
|
||||
placeholder,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
options: Language[];
|
||||
@@ -31,12 +32,14 @@ function Combobox({
|
||||
autoLabel: string;
|
||||
placeholder: string;
|
||||
onChange: (code: string) => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
@@ -50,6 +53,12 @@ function Combobox({
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const closeAndRefocus = () => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const allOptions = includeAuto
|
||||
? [{ code: 'auto', name: autoLabel }, ...options]
|
||||
: options;
|
||||
@@ -62,20 +71,37 @@ function Combobox({
|
||||
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative text-left">
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative text-left"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
e.stopPropagation();
|
||||
closeAndRefocus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'w-full py-2.5 px-3.5 bg-brand-muted/60 dark:bg-white/5 rounded-xl border text-xs font-bold uppercase tracking-wider text-brand-dark dark:text-white flex items-center justify-between hover:border-brand-accent/50 transition-all select-none cursor-pointer',
|
||||
open ? 'border-brand-accent/50' : 'border-brand-accent/20 dark:border-white/10'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label || placeholder}</span>
|
||||
<span className="truncate normal-case">{label || placeholder}</span>
|
||||
<ChevronDown className={cn('size-3.5 shrink-0 text-brand-accent transition-transform ms-2', open && 'rotate-180')} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-[102%] right-0 left-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-2 z-50 max-h-48 overflow-y-auto animate-fade-in">
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className="absolute top-[102%] right-0 left-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-2 z-50 max-h-48 overflow-y-auto animate-fade-in"
|
||||
>
|
||||
<div className="border-b border-black/5 dark:border-white/5 px-2 py-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -83,6 +109,7 @@ function Combobox({
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('langSelector.search')}
|
||||
aria-label={t('langSelector.search')}
|
||||
className="w-full bg-transparent px-1 py-1 text-xs outline-none placeholder:text-brand-dark/30 dark:placeholder:text-white/30 text-brand-dark dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
@@ -94,7 +121,9 @@ function Combobox({
|
||||
<button
|
||||
key={lang.code}
|
||||
type="button"
|
||||
onClick={() => { onChange(lang.code); setOpen(false); setQuery(''); }}
|
||||
role="option"
|
||||
aria-selected={value === lang.code}
|
||||
onClick={() => { onChange(lang.code); closeAndRefocus(); }}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer',
|
||||
value === lang.code
|
||||
@@ -152,6 +181,7 @@ export default function LanguageSelector({
|
||||
autoLabel={t('dashboard.translate.language.autoDetect') || 'Auto-détecté'}
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder') || 'Langue...'}
|
||||
onChange={onSourceChange}
|
||||
ariaLabel={`${t('langSelector.source')} — ${t('dashboard.translate.language.autoDetect') || 'Auto-détecté'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +191,7 @@ export default function LanguageSelector({
|
||||
type="button"
|
||||
onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
|
||||
disabled={!canSwap}
|
||||
aria-label={t('langSelector.swap')}
|
||||
className={cn(
|
||||
'flex size-7 items-center justify-center rounded-xl transition-all cursor-pointer',
|
||||
canSwap
|
||||
@@ -183,6 +214,7 @@ export default function LanguageSelector({
|
||||
autoLabel=""
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder') || 'Langue...'}
|
||||
onChange={onTargetChange}
|
||||
ariaLabel={t('langSelector.target')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user