diff --git a/e2e/appearance-qa.spec.ts b/e2e/appearance-qa.spec.ts new file mode 100644 index 0000000..1259c99 --- /dev/null +++ b/e2e/appearance-qa.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, type Page } from '@playwright/test' + +// Harness de captura visual das correções de aparência (accent custom em OKLCH, +// tinte de fundo proporcional, react-colorful, trilhas da Distribuição). Somente +// leitura: o accent é forçado via localStorage ANTES do load — nenhuma interação +// dispara persistência — e o carimbo local recente impede a hidratação remota de +// reverter a cor durante a captura. Opt-in via QA_SHOTS=1; saída em QA_SHOTS_DIR. + +const DIR = process.env.QA_SHOTS_DIR ?? 'e2e/screenshots' + +test.beforeEach(() => { + test.skip(!process.env.QA_SHOTS, 'Captura sob demanda: rode com QA_SHOTS=1') +}) + +async function forceAppearance( + page: Page, + opts: { dark: boolean; accent?: string; customHex?: string } +) { + await page.addInitScript((o) => { + try { + localStorage.setItem('theme', o.dark ? 'dark' : 'light') + if (o.accent) localStorage.setItem('archtime-accent', o.accent) + if (o.customHex) localStorage.setItem('archtime-accent-custom', o.customHex) + localStorage.setItem('archtime-bg-tint', 'on') + // Dentro da janela de graça: a hidratação remota não sobrescreve o forçado. + localStorage.setItem('archtime-preferences-updated-at', String(Date.now())) + } catch {} + }, opts) +} + +async function gotoDashboard(page: Page) { + await page.goto('/dashboard') + await expect(page.getByRole('heading', { name: 'Ponto' })).toBeVisible({ timeout: 30_000 }) + await page.waitForTimeout(900) +} + +test('amarelo vivo (light): sidebar, logo, distribuição e fundo', async ({ page }) => { + await page.setViewportSize({ width: 1536, height: 900 }) + await forceAppearance(page, { dark: false, accent: 'custom', customHex: '#eab308' }) + await gotoDashboard(page) + await page.screenshot({ path: `${DIR}/qa-yellow-light.png`, fullPage: false }) +}) + +test('amarelo vivo (dark): sidebar, logo, distribuição e fundo', async ({ page }) => { + await page.setViewportSize({ width: 1536, height: 900 }) + await forceAppearance(page, { dark: true, accent: 'custom', customHex: '#eab308' }) + await gotoDashboard(page) + await page.screenshot({ path: `${DIR}/qa-yellow-dark.png`, fullPage: false }) +}) + +test('vermelho vivo vs pastel (light): tinte de fundo acompanha o croma', async ({ page }) => { + await page.setViewportSize({ width: 1536, height: 900 }) + await forceAppearance(page, { dark: false, accent: 'custom', customHex: '#dc2626' }) + await gotoDashboard(page) + await page.screenshot({ path: `${DIR}/qa-red-vivid-light.png`, fullPage: false }) +}) + +test('rosa pastel (light): tinte de fundo mais sutil que o vivo', async ({ page }) => { + await page.setViewportSize({ width: 1536, height: 900 }) + await forceAppearance(page, { dark: false, accent: 'custom', customHex: '#e8a2b8' }) + await gotoDashboard(page) + await page.screenshot({ path: `${DIR}/qa-pink-pastel-light.png`, fullPage: false }) +}) + +test('seletor react-colorful no menu mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await forceAppearance(page, { dark: false, accent: 'custom', customHex: '#0ea5e9' }) + await gotoDashboard(page) + await page.getByRole('button', { name: 'Abrir menu' }).click() + await expect(page.getByText('Cor de destaque')).toBeVisible({ timeout: 10_000 }) + await page.waitForTimeout(400) + await page.screenshot({ path: `${DIR}/qa-picker-mobile.png`, fullPage: false }) +}) + +test('preset índigo (light): baseline sem regressão', async ({ page }) => { + await page.setViewportSize({ width: 1536, height: 900 }) + await forceAppearance(page, { dark: false, accent: 'indigo' }) + await gotoDashboard(page) + await page.screenshot({ path: `${DIR}/qa-indigo-baseline-light.png`, fullPage: false }) +}) diff --git a/package-lock.json b/package-lock.json index 6a458aa..bb1609a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "radix-ui": "^1.4.3", "react": "19.2.3", "react-activity-calendar": "^3.2.0", + "react-colorful": "^5.8.0", "react-dom": "19.2.3", "recharts": "^3.8.0", "serwist": "^9.5.6", @@ -11290,6 +11291,16 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-colorful": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz", + "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", diff --git a/package.json b/package.json index a5cdd2a..7484393 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "radix-ui": "^1.4.3", "react": "19.2.3", "react-activity-calendar": "^3.2.0", + "react-colorful": "^5.8.0", "react-dom": "19.2.3", "recharts": "^3.8.0", "serwist": "^9.5.6", diff --git a/src/app/globals.css b/src/app/globals.css index de4ca22..ba6ce86 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -166,39 +166,29 @@ html { scrollbar-gutter: stable; } -/* ─── Theme circular reveal — animação CSS no snapshot do novo tema ───────────── - O snapshot do tema atual (old) fica estático por baixo; o do novo tema (new) é - revelado por um círculo que cresce do ponto tocado. A animação é CSS (não um WAAPI - agendado em requestAnimationFrame/transition.ready): assim ela já está no primeiro - frame em que o pseudo-elemento nasce. Um WAAPI agendado abre, no mobile, um gap de - 1-2 frames em que o novo tema aparece sem clip — um flash de tela cheia. - - Easing = "emphasized decelerate" do Material 3: o círculo parte do raio 0, então a - curva acelera de imediato e assenta no fim (decelerate). Um ease-in-out faria o - reveal hesitar nos primeiros quadros e passaria sensação de lentidão. */ +/* ─── Theme circular reveal — clip estático no CSS + WAAPI agendado no ready ──── + O snapshot do tema atual (old) fica estático por baixo; o do novo tema (new) nasce + JÁ recortado num círculo de raio 0: o clip-path estático abaixo vale desde o + primeiro frame do pseudo-elemento, então não existe o flash de tela cheia do gap + entre a criação do snapshot e o transition.ready (1-2 frames no Chrome mobile). + O crescimento do círculo é uma animação WAAPI criada em transition.ready + (animateThemeReveal em theme-transition.ts): ela só começa com os snapshots + prontos — sem frames perdidos durante a captura no mobile — e o desktop, cujo + ready resolve em menos de um frame, mantém o reveal de sempre. A duração e a + curva (emphasized decelerate do M3) vivem junto do WAAPI em theme-transition.ts. */ html.theme-switching::view-transition-old(root) { animation: none; z-index: 1; } html.theme-switching::view-transition-new(root) { - animation: theme-reveal 320ms cubic-bezier(0.05, 0.7, 0.1, 1) both; + animation: none; + clip-path: circle(0px at var(--theme-reveal-x, 50vw) var(--theme-reveal-y, 50vh)); mix-blend-mode: normal; will-change: clip-path; z-index: 2; } -@keyframes theme-reveal { - from { - clip-path: circle(0px at var(--theme-reveal-x, 50vw) var(--theme-reveal-y, 50vh)); - } - to { - clip-path: circle( - var(--theme-reveal-radius, 150vmax) at var(--theme-reveal-x, 50vw) var(--theme-reveal-y, 50vh) - ); - } -} - /* ─── Stable theme switch — bloqueia transições concorrentes durante troca de tema ─── */ html.theme-switching, html.theme-switching *, @@ -674,15 +664,25 @@ html.theme-switching *::after { --ring: oklch(0.72 0.14 200); } -/* ─── Custom accent color (color picker) ─── */ +/* ─── Custom accent color (color picker) ───────────────────────────────────── + Os tokens suaves são derivados do hex escolhido DIRETO no CSS, em OKLCH, com a + mesma estrutura dos presets: L fixo por papel e croma proporcional ao da cor com + teto igual ao dos presets. Assim QUALQUER cor de entrada — inclusive amarelos e + cianos claros e saturados, cuja luminância enganava a heurística em sRGB — + produz superfícies suaves e legíveis, no MESMO matiz perceptual do --primary + (logo, aba ativa e trilhas de progresso ficam na mesma família de cor). + Prova de contraste: src/lib/__tests__/custom-accent-tokens.test.ts. + O JS fornece só o hex e a decisão de contraste de foreground/borda do primary + (accent-color-provider + script anti-flash do layout). + Fallback sem relative color: color-mix perceptual em OKLCH (matiz preservado). */ [data-accent="custom"] { --primary: var(--custom-accent-hex); --primary-foreground: var(--custom-accent-foreground); --primary-border: var(--custom-accent-border, transparent); --ring: var(--custom-accent-hex); - --accent: var(--custom-accent-soft-light, color-mix(in oklch, var(--custom-accent-hex) 12%, white)); - --accent-foreground: var(--custom-accent-soft-foreground-light, oklch(0.20 0 0)); - --muted: var(--custom-accent-muted-light, color-mix(in oklch, var(--custom-accent-hex) 6%, white)); + --accent: color-mix(in oklch, var(--custom-accent-hex) 12%, white); + --accent-foreground: oklch(0.20 0 0); + --muted: color-mix(in oklch, var(--custom-accent-hex) 6%, white); --muted-foreground: oklch(0.556 0 0); } [data-accent="custom"].dark { @@ -690,18 +690,32 @@ html.theme-switching *::after { --primary-foreground: var(--custom-accent-foreground); --primary-border: var(--custom-accent-border, transparent); --ring: var(--custom-accent-hex); - --accent: var(--custom-accent-soft-dark, color-mix(in oklch, var(--custom-accent-hex) 20%, black)); - --accent-foreground: var(--custom-accent-soft-foreground-dark, oklch(0.96 0 0)); - --muted: var(--custom-accent-muted-dark, color-mix(in oklch, var(--custom-accent-hex) 10%, black)); + --accent: color-mix(in oklch, var(--custom-accent-hex) 20%, black); + --accent-foreground: oklch(0.96 0 0); + --muted: color-mix(in oklch, var(--custom-accent-hex) 10%, black); --muted-foreground: oklch(0.708 0 0); } +@supports (color: oklch(from red l c h)) { + [data-accent="custom"] { + --accent: oklch(from var(--custom-accent-hex) 0.95 clamp(0, calc(c * 0.28), 0.045) h); + --accent-foreground: oklch(from var(--custom-accent-hex) 0.25 clamp(0, calc(c * 0.35), 0.06) h); + --muted: oklch(from var(--custom-accent-hex) 0.97 clamp(0, calc(c * 0.12), 0.012) h); + } + [data-accent="custom"].dark { + --accent: oklch(from var(--custom-accent-hex) 0.26 clamp(0, calc(c * 0.35), 0.055) h); + --accent-foreground: oklch(from var(--custom-accent-hex) 0.96 clamp(0, calc(c * 0.12), 0.02) h); + --muted: oklch(from var(--custom-accent-hex) 0.23 clamp(0, calc(c * 0.15), 0.025) h); + } +} /* ═══════════════════════════════════════════════════════════════ FUNDO TINGIDO PELA COR DE DESTAQUE (accent-driven background tint) Uma única regra deriva a matiz do --primary via relative color, servindo os 14 presets de accent E a cor personalizada (cujo --primary é o hex escolhido). - L fixo no valor do tema neutro + chroma clampado ⇒ WCAG AA preservado em toda - matiz (prova: src/lib/__tests__/background-tint.test.ts). Gated por + L fixo no valor do tema neutro + croma PROPORCIONAL ao da cor (calc(c * fator), + com teto) ⇒ o fundo acompanha as variantes do seletor — vívido tinge mais, + pastel tinge menos, cinza não tinge — e o WCAG AA fica preservado em toda matiz + (prova: src/lib/__tests__/background-tint.test.ts). Gated por [data-bg-tint="on"] (toggle "Tingir o fundo", ligado por padrão); inativo sob um preset arquitetônico — o preset tem precedência sobre o fundo. O @supports é o fallback seguro: engines sem relative color ignoram o bloco e @@ -709,12 +723,12 @@ html.theme-switching *::after { ═══════════════════════════════════════════════════════════════ */ @supports (color: oklch(from red l c h)) { [data-bg-tint="on"][data-accent]:not([data-preset]) { - --background: oklch(from var(--primary) 0.98 clamp(0, c, 0.012) h); - --muted-foreground: oklch(from var(--primary) 0.5 clamp(0, c, 0.02) h); + --background: oklch(from var(--primary) 0.98 clamp(0, calc(c * 0.15), 0.03) h); + --muted-foreground: oklch(from var(--primary) 0.5 clamp(0, calc(c * 0.15), 0.02) h); } .dark[data-bg-tint="on"][data-accent]:not([data-preset]) { - --background: oklch(from var(--primary) 0.145 clamp(0, c, 0.01) h); - --card: oklch(from var(--primary) 0.205 clamp(0, c, 0.008) h); + --background: oklch(from var(--primary) 0.145 clamp(0, calc(c * 0.18), 0.03) h); + --card: oklch(from var(--primary) 0.205 clamp(0, calc(c * 0.15), 0.025) h); /* O escurecimento do muted-foreground é só para o fundo claro; no escuro o valor neutro (0.708) já passa AA com folga, então é restaurado aqui. */ --muted-foreground: oklch(0.708 0 0); @@ -883,3 +897,19 @@ html.dark[data-blueprint="true"] body { .print-body { font-size: var(--print-font-body); line-height: var(--print-line-height); } .print-small { font-size: var(--print-font-small); color: var(--print-color-muted); } } + +/* ─── Accent color picker (react-colorful) ─── */ +.accent-picker .react-colorful { width: 100%; height: auto; gap: 8px; } +.accent-picker .react-colorful__saturation { + height: 96px; border-radius: 6px; border-bottom: none; + border: 1px solid var(--border); box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.08); +} +.accent-picker .react-colorful__hue { height: 8px; border-radius: 9999px; border: 1px solid var(--border); } +.accent-picker .react-colorful__pointer { height: 12px; width: 12px; border-width: 2px; } +/* Foco de teclado visível nos sliders internos: sem isto o único sinal seria o + pointer de 12px escalando ~1.1x — imperceptível. */ +.accent-picker .react-colorful__interactive:focus-visible .react-colorful__pointer { + box-shadow: + 0 0 0 3px color-mix(in oklch, var(--ring) 55%, transparent), + 0 0 0 1px rgb(0 0 0 / 0.45); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b643f96..f77b617 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -70,17 +70,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) if(h.length===3) h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2]; return '#'+h; } - function rgb(hex){ - var h=norm(hex)||'#6366f1'; - return [parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)]; - } - function hex(rgb){ - return '#'+rgb.map(function(v){return Math.round(Math.max(0,Math.min(255,v))).toString(16).padStart(2,'0')}).join(''); - } - function mix(a,b,w){ - var x=rgb(a),y=rgb(b),m=Math.max(0,Math.min(1,w)); - return hex([x[0]*(1-m)+y[0]*m,x[1]*(1-m)+y[1]*m,x[2]*(1-m)+y[2]*m]); - } function lum(hex){ var h=norm(hex)||'#6366f1'; function c(i){ @@ -106,20 +95,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) if(a==='custom'){ var c=norm(localStorage.getItem('archtime-accent-custom')); if(!c) c='#6366f1'; - var l=lum(c); - var light=l>0.78?mix(c,'#111827',0.12):mix(c,'#ffffff',0.88); - var mutedLight=l>0.78?mix(c,'#111827',0.06):mix(c,'#ffffff',0.94); - var dark=l<0.18?mix(c,'#ffffff',0.18):mix(c,'#000000',0.72); - var mutedDark=l<0.18?mix(c,'#ffffff',0.11):mix(c,'#000000',0.82); document.documentElement.style.setProperty('--custom-accent-hex',c); document.documentElement.style.setProperty('--custom-accent-foreground',fg(c)); document.documentElement.style.setProperty('--custom-accent-border',outline(c)); - document.documentElement.style.setProperty('--custom-accent-soft-light',light); - document.documentElement.style.setProperty('--custom-accent-soft-foreground-light',fg(light)); - document.documentElement.style.setProperty('--custom-accent-muted-light',mutedLight); - document.documentElement.style.setProperty('--custom-accent-soft-dark',dark); - document.documentElement.style.setProperty('--custom-accent-soft-foreground-dark',fg(dark)); - document.documentElement.style.setProperty('--custom-accent-muted-dark',mutedDark); } var p=localStorage.getItem('archtime-preset'); if(p) document.documentElement.setAttribute('data-preset',p); diff --git a/src/components/accent-color-picker.tsx b/src/components/accent-color-picker.tsx index 98451cc..d38b718 100644 --- a/src/components/accent-color-picker.tsx +++ b/src/components/accent-color-picker.tsx @@ -1,7 +1,8 @@ 'use client' -import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import { useState } from 'react' import { Check } from 'lucide-react' +import { HexColorPicker } from 'react-colorful' import { Input } from '@/components/ui/input' import { ACCENTS } from '@/components/accent-color-provider' import { ACCENT_PRESETS, type AccentPreset } from '@/lib/preferences' @@ -9,8 +10,6 @@ import { cn } from '@/lib/utils' import { getColorInputValue, getReadableCustomForeground, - hexToHsl, - hslToHex, normalizeHexColor, } from '@/lib/custom-color' @@ -31,9 +30,9 @@ export function AccentColorPicker({ onCustomColorChange, className, }: AccentColorPickerProps) { - const colorAreaRef = useRef(null) - const currentColor = getColorInputValue(customColor) - const currentHsl = hexToHsl(currentColor) + // Preset ativo semeia o seletor com a própria cor — a caixa "Personalizada" + // vira ponto de partida do preset, nunca um índigo fixo que parece bug. + const currentColor = accent === 'custom' ? getColorInputValue(customColor) : ACCENTS[accent] // Rastreia o ultimo currentColor confirmado para detectar mudanca externa durante render const [draftHex, setDraftHex] = useState(currentColor) // react-doctor-disable-next-line react-doctor/rerender-state-only-in-handlers -- committedColor é lido no render (comparação abaixo) como "prev value" do padrão de ajuste de estado durante o render; precisa ser state (não ref) para o React reagir a uma mudança externa de currentColor. @@ -47,8 +46,6 @@ export function AccentColorPicker({ } const normalizedDraft = normalizeHexColor(draftHex) - const hueColor = hslToHex({ h: currentHsl.h, s: 100, l: 50 }) - function commitCustomColor(value: string) { const normalized = normalizeHexColor(value) setDraftHex(value) @@ -57,35 +54,6 @@ export function AccentColorPicker({ onCustomColorChange(normalized) } - function commitHsl(next: Partial) { - commitCustomColor(hslToHex({ ...currentHsl, ...next })) - } - - function updateAreaFromPointer(clientX: number, clientY: number) { - const rect = colorAreaRef.current?.getBoundingClientRect() - if (!rect) return - const x = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) - const y = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)) - commitHsl({ - s: Math.round(x * 100), - l: Math.round((1 - y) * 100), - }) - } - - function handleAreaPointerDown(event: ReactPointerEvent) { - event.preventDefault() - window.getSelection()?.removeAllRanges() - event.currentTarget.setPointerCapture(event.pointerId) - updateAreaFromPointer(event.clientX, event.clientY) - } - - function handleAreaPointerMove(event: ReactPointerEvent) { - if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - event.preventDefault() - window.getSelection()?.removeAllRanges() - updateAreaFromPointer(event.clientX, event.clientY) - } - return (
@@ -122,32 +90,11 @@ export function AccentColorPicker({
-