fix(ui): wave 2 from critique re-run — AA contrast, unified PageHeader, checkout confirm
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s

Nav: teams/settings/services back out of the nav until product-ready (user
decision); teams page was already half-finished (UUID member display is a
backend limitation).

Payment: explicit confirmation dialog (plan, amount, billing period, Stripe
note) before any redirect; ?plan= URL now pre-opens the dialog instead of
triggering a silent checkout.

Contrast AA sweep (71 replacements): functional micro-labels raised to
>=60-65% opacity and >=10px across sidebar, header, pricing, landing;
decorative all-caps 'interface' label removed.

Design unification: new PageHeader component (accent pill + serif
base/accent title) applied to settings, services, reviews, teams; pricing
header returned to the editorial voice (serif + accent pill); dead
GlossaryCard deleted.

i18n residuals: suggestion chips, 'Standard' provider label, notification
close, model-combobox strings extracted (+broken bg-surface/border-border-
subtle tokens fixed); ~45 new keys EN+FR; 13 dead keys removed; zero
missing keys verified.

Reviews: icon-only row actions now carry visible text; 'Approve all' is
two-step armed-confirm.

Accelerators: Ctrl/Cmd+Enter submits; arrow-key navigation in the language
combobox; recent-jobs history cap 8->20 with per-job download; source=target
config rejected; empty 'Master Quality' badge removed.

Cookie consent: reopenable via footer link, emoji replaced with drawn icon.

Verified: build exit 0, vitest 9/9, eslint 64 errors = previous level,
0 missing i18n keys.
This commit is contained in:
2026-08-30 22:02:35 +02:00
parent 50047ea8a2
commit 111f3cb69d
41 changed files with 470 additions and 280 deletions

View File

@@ -70,16 +70,34 @@ function Combobox({
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
const handleListboxKeys = (e: React.KeyboardEvent) => {
if (e.key === 'Escape' && open) {
e.stopPropagation();
closeAndRefocus();
return;
}
if (!open || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
e.preventDefault();
const options = Array.from(
ref.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? []
);
if (options.length === 0) return;
const idx = options.indexOf(document.activeElement as HTMLButtonElement);
const next = e.key === 'ArrowDown'
? options[Math.min(options.length - 1, idx + 1)]
: options[Math.max(0, idx - 1)];
// from the search input, ArrowDown goes to the first option
(idx === -1 && document.activeElement === inputRef.current
? options[e.key === 'ArrowDown' ? 0 : options.length - 1]
: next
).focus();
};
return (
<div
ref={ref}
className="relative text-left"
onKeyDown={(e) => {
if (e.key === 'Escape' && open) {
e.stopPropagation();
closeAndRefocus();
}
}}
onKeyDown={handleListboxKeys}
>
<button
ref={triggerRef}

View File

@@ -155,10 +155,10 @@ export function ProviderSelector({
>
<div className="flex items-center justify-between">
<span className={cn(
"text-[9.5px] font-black uppercase tracking-tight truncate flex-1",
isSelected ? 'text-brand-accent' : 'text-brand-dark/40 dark:text-white/40'
"text-[10px] font-black uppercase tracking-tight truncate flex-1",
isSelected ? 'text-brand-accent' : 'text-brand-dark/60 dark:text-white/60'
)}>
Standard
{t('providerSelector.standard')}
</span>
{isSelected && <div className="w-1.5 h-1.5 rounded-full bg-brand-accent animate-pulse shrink-0 ml-1" />}
</div>

View File

@@ -142,13 +142,13 @@ export default function TranslatePage() {
};
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
const handleDownload = async () => {
if (!submit.jobId) return;
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
if (!jobId) return;
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
if (!response.ok) {
showError({ title: t('dashboard.translate.error.title'), description: t('translate.downloadFailed') });
return;
@@ -180,6 +180,19 @@ export default function TranslatePage() {
const isCompleted = submit.status === 'completed';
const isFailed = submit.status === 'failed';
// Ctrl/Cmd+Enter launches the translation from anywhere on the page
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && upload.file && config.isConfigValid && !submit.isSubmitting && !isProcessing) {
e.preventDefault();
handleTranslate();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [upload.file, config.isConfigValid, submit.isSubmitting, isProcessing]);
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
const showConfiguring = isConfiguring;
const showProcessing = isProcessing;
@@ -311,18 +324,27 @@ export default function TranslatePage() {
{t('translate.recent.title')}
</h4>
<ul className="divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{recentJobs.slice(0, 4).map((job) => (
{recentJobs.slice(0, 6).map((job) => (
<li key={job.jobId} className="flex items-center justify-between gap-3 py-3">
<span className="flex min-w-0 items-center gap-2.5 text-xs font-semibold text-brand-dark dark:text-white">
<FileText className="size-3.5 shrink-0 text-brand-accent" />
<span className="truncate">{job.fileName || job.jobId}</span>
</span>
<Link
href={`/dashboard/reviews/${job.jobId}`}
className="shrink-0 rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/25 hover:bg-brand-accent/10 transition-colors"
>
{t('translate.recent.review')}
</Link>
<span className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => handleDownload(job.jobId)}
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-black/10 dark:border-white/10 hover:bg-brand-muted/50 transition-colors"
>
<Download size={11} className="inline" />
</button>
<Link
href={`/dashboard/reviews/${job.jobId}`}
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/25 hover:bg-brand-accent/10 transition-colors"
>
{t('translate.recent.review')}
</Link>
</span>
</li>
))}
</ul>
@@ -446,21 +468,18 @@ export default function TranslatePage() {
</div>
<div className="text-left">
<p className="text-[13px] font-bold uppercase tracking-[0.1em] text-brand-dark dark:text-white">
{t('translate.header.completedTitle')}
{t('translate.header.completedTitleBase')} {t('translate.header.completedTitleAccent')}
</p>
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase mt-1 tracking-wider max-w-[300px] truncate">
{submit.fileName}
</p>
</div>
</div>
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/20 shadow-sm shrink-0">
{t('translate.complete.masterQuality')}
</span>
</div>
<div className="flex-1 flex flex-col items-center justify-center py-16 bg-brand-muted/20 dark:bg-white/5 rounded-[40px] border border-black/5 dark:border-white/5">
<button
onClick={handleDownload}
onClick={() => handleDownload()}
className="premium-button px-24 py-6 text-xl !rounded-full flex items-center gap-6 mb-8 group cursor-pointer hover:scale-[1.02] active:scale-95"
>
<Download size={28} className="group-hover:translate-y-1 transition-transform" />

View File

@@ -233,8 +233,10 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
const isConfigValid = useMemo(() => {
if (!hasFile || !targetLang) return false;
if (!provider) return false;
// Translating a language into itself is always a mistake
if (sourceLang !== 'auto' && sourceLang === targetLang) return false;
return true;
}, [hasFile, targetLang, provider]);
}, [hasFile, targetLang, provider, sourceLang]);
const getConfig = useCallback((): TranslationConfig => ({
sourceLang,

View File

@@ -15,7 +15,7 @@ const MAX_POLLING_FAILURES = 3;
/* ── Job persistence: survive a refresh mid-translation ─────────── */
const ACTIVE_JOB_KEY = 'wordly:activeJob';
const RECENT_JOBS_KEY = 'wordly:recentJobs';
const MAX_RECENT_JOBS = 8;
const MAX_RECENT_JOBS = 20;
const ACTIVE_JOB_TTL_MS = 24 * 60 * 60 * 1000; // discard stale entries after 24h
interface StoredJob { jobId: string; fileName: string | null; savedAt: number }