Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions e2e/appearance-qa.spec.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 65 additions & 35 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 *,
Expand Down Expand Up @@ -674,47 +664,71 @@ 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 {
--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-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
ficam com os tokens neutros de :root/.dark, nunca com um --background inválido.
═══════════════════════════════════════════════════════════════ */
@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);
Expand Down Expand Up @@ -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);
}
22 changes: 0 additions & 22 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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){
Expand All @@ -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);
Expand Down
Loading
Loading