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

@@ -6,6 +6,13 @@ import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@/components/ui/button";
const STORAGE_KEY = "cookie-consent";
const REOPEN_EVENT = "wordly:reopen-cookie-consent";
/** Reopen the banner after a choice was made (footer link uses this). */
export function reopenCookieConsent() {
localStorage.removeItem(STORAGE_KEY);
window.dispatchEvent(new Event(REOPEN_EVENT));
}
export function CookieConsent() {
const { t } = useI18n();
@@ -13,6 +20,9 @@ export function CookieConsent() {
useEffect(() => {
setVisible(!localStorage.getItem(STORAGE_KEY));
const onReopen = () => setVisible(true);
window.addEventListener(REOPEN_EVENT, onReopen);
return () => window.removeEventListener(REOPEN_EVENT, onReopen);
}, []);
function acceptAll() {
@@ -38,7 +48,7 @@ export function CookieConsent() {
<div className="mx-auto max-w-3xl rounded-t-2xl border border-border bg-background/95 backdrop-blur-md shadow-2xl p-6">
<div className="flex items-start gap-3">
<span className="text-2xl shrink-0" aria-hidden="true">
🍪
<CookieIcon />
</span>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground mb-1">
@@ -63,3 +73,16 @@ export function CookieConsent() {
</AnimatePresence>
);
}
/** Drawn icon instead of an emoji — keeps one consistent icon system. */
function CookieIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-foreground/70" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<circle cx="9" cy="9" r="0.5" fill="currentColor" />
<circle cx="14" cy="13" r="0.5" fill="currentColor" />
<circle cx="10" cy="15" r="0.5" fill="currentColor" />
<circle cx="16" cy="8" r="0.5" fill="currentColor" />
</svg>
);
}

View File

@@ -3,6 +3,7 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { Search, List, Loader2, Check } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/lib/i18n";
export interface ModelOption {
id: string;
@@ -31,6 +32,7 @@ export function ModelCombobox({
providerLabel,
placeholder = "nom-du-modele",
}: ModelComboboxProps) {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const [filter, setFilter] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
@@ -108,17 +110,17 @@ export function ModelCombobox({
type="button"
onClick={handleBrowse}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 h-10 rounded-lg border border-border-subtle bg-surface text-sm text-muted-foreground hover:bg-surface-elevated hover:text-foreground transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 px-3 h-10 rounded-lg border border-border bg-card text-sm text-muted-foreground hover:bg-card hover:text-foreground transition-colors disabled:opacity-50"
>
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <List className="size-4" />}
<span className="hidden sm:inline">Parcourir</span>
<span className="hidden sm:inline">{t('common.browse')}</span>
</button>
</div>
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border-subtle bg-card shadow-xl max-h-72 overflow-hidden flex flex-col">
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-xl max-h-72 overflow-hidden flex flex-col">
{/* Filter */}
<div className="p-2 border-b border-border-subtle">
<div className="p-2 border-b border-border">
<Input
placeholder={`Filtrer...`}
value={filter}
@@ -132,7 +134,7 @@ export function ModelCombobox({
<ul ref={listRef} className="overflow-y-auto flex-1">
{filtered.length === 0 ? (
<li className="px-3 py-4 text-xs text-muted-foreground text-center">
{models.length === 0 ? "Aucun modele recupere" : "Aucun modele ne correspond"}
{models.length === 0 ? t('common.noModels') : t('common.noModelMatch')}
</li>
) : (
filtered.map((model, i) => (
@@ -141,7 +143,7 @@ export function ModelCombobox({
onClick={() => handleSelect(model)}
onMouseEnter={() => setHighlightIndex(i)}
className={`flex items-center justify-between px-3 py-2 text-xs cursor-pointer transition-colors ${
i === highlightIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-surface-elevated"
i === highlightIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-card"
} ${value === model.id ? "bg-primary/5" : ""}`}
>
<div className="flex-1 min-w-0">
@@ -160,7 +162,7 @@ export function ModelCombobox({
</ul>
{models.length > 0 && (
<div className="px-3 py-1.5 border-t border-border-subtle text-xs text-muted-foreground text-center bg-card">
<div className="px-3 py-1.5 border-t border-border text-xs text-muted-foreground text-center bg-card">
{filtered.length} modele{filtered.length !== 1 ? "s" : ""}
{filter ? ` sur ${models.length}` : ""} nom libre possible
</div>

View File

@@ -4,6 +4,7 @@ import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { useI18n } from "@/lib/i18n"
const notificationVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pe-8 shadow-lg transition-all duration-300 ease-out",
@@ -74,6 +75,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
children,
...props
}, ref) => {
const { t } = useI18n()
const [visible, setVisible] = React.useState(true)
const [progress, setProgress] = React.useState(100)
@@ -181,7 +183,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
type="button"
onClick={handleClose}
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label="Close notification"
aria-label={t("common.closeNotification")}
>
<X className="h-4 w-4" />
</button>