From eb5e9b7d15c05d2a3a4e2a4c82a66cafb09f0210 Mon Sep 17 00:00:00 2001 From: johnlaff <46091881+johnlaff@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:46:50 -0300 Subject: [PATCH 1/8] fix(appearance): deriva tokens suaves do accent custom em OKLCH no CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A heurística em sRGB (mix + limiar de luminância) gerava --accent/--muted quase puros para cores claras e saturadas (amarelo, ciano): trilhas de progresso com 0% pareciam 100% preenchidas, superfícies neutras herdavam cor viva e o matiz divergia do --primary (logo != aba ativa da sidebar). Os tokens suaves agora são derivados do hex direto no CSS via relative color, com a mesma estrutura L/C dos presets (L fixo por papel, croma proporcional com teto) e matiz perceptual idêntico ao do --primary. O JS fica só com a decisão de contraste (foreground/borda), no provider e no script anti-flash. Prova WCAG dos pares em custom-accent-tokens.test.ts, com sincronia teste<->folha travada por leitura do CSS. --- src/app/globals.css | 36 ++++- src/app/layout.tsx | 22 --- src/components/accent-color-provider.tsx | 12 -- src/lib/__tests__/anti-flash-parity.test.ts | 20 +-- src/lib/__tests__/background-tint.test.ts | 54 +------- .../__tests__/custom-accent-tokens.test.ts | 131 ++++++++++++++++++ src/lib/__tests__/custom-color.test.ts | 10 +- src/lib/__tests__/oklch-wcag.ts | 66 +++++++++ src/lib/custom-color.ts | 49 +------ 9 files changed, 244 insertions(+), 156 deletions(-) create mode 100644 src/lib/__tests__/custom-accent-tokens.test.ts create mode 100644 src/lib/__tests__/oklch-wcag.ts diff --git a/src/app/globals.css b/src/app/globals.css index de4ca22..b364c0d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -674,15 +674,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,11 +700,23 @@ 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) 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-provider.tsx b/src/components/accent-color-provider.tsx index 7505384..6673090 100644 --- a/src/components/accent-color-provider.tsx +++ b/src/components/accent-color-provider.tsx @@ -33,12 +33,6 @@ const CUSTOM_ACCENT_PROPERTIES = [ '--custom-accent-hex', '--custom-accent-foreground', '--custom-accent-border', - '--custom-accent-soft-light', - '--custom-accent-soft-foreground-light', - '--custom-accent-muted-light', - '--custom-accent-soft-dark', - '--custom-accent-soft-foreground-dark', - '--custom-accent-muted-dark', ] interface AccentColorContextValue { @@ -76,12 +70,6 @@ function applyCustomAccentProperties(hex: string) { root.style.setProperty('--custom-accent-hex', tokens.primary) root.style.setProperty('--custom-accent-foreground', tokens.primaryForeground) root.style.setProperty('--custom-accent-border', tokens.primaryBorder) - root.style.setProperty('--custom-accent-soft-light', tokens.accentLight) - root.style.setProperty('--custom-accent-soft-foreground-light', tokens.accentForegroundLight) - root.style.setProperty('--custom-accent-muted-light', tokens.mutedLight) - root.style.setProperty('--custom-accent-soft-dark', tokens.accentDark) - root.style.setProperty('--custom-accent-soft-foreground-dark', tokens.accentForegroundDark) - root.style.setProperty('--custom-accent-muted-dark', tokens.mutedDark) } function clearCustomAccentProperties() { diff --git a/src/lib/__tests__/anti-flash-parity.test.ts b/src/lib/__tests__/anti-flash-parity.test.ts index d4fb6bf..5e5eda9 100644 --- a/src/lib/__tests__/anti-flash-parity.test.ts +++ b/src/lib/__tests__/anti-flash-parity.test.ts @@ -28,25 +28,19 @@ function extractAntiFlashScript(): string { return match[1] } -// CSS custom property <-> CustomAccentTokens field, per the plan's mapping table. +// CSS custom property <-> CustomAccentTokens field. The soft surface tokens +// (--accent/--muted) are derived in CSS via relative color and never touch JS, +// so parity only covers the contrast-decision trio below. const PROPERTY_TO_TOKEN = [ ['--custom-accent-hex', 'primary'], ['--custom-accent-foreground', 'primaryForeground'], ['--custom-accent-border', 'primaryBorder'], - ['--custom-accent-soft-light', 'accentLight'], - ['--custom-accent-soft-foreground-light', 'accentForegroundLight'], - ['--custom-accent-muted-light', 'mutedLight'], - ['--custom-accent-soft-dark', 'accentDark'], - ['--custom-accent-soft-foreground-dark', 'accentForegroundDark'], - ['--custom-accent-muted-dark', 'mutedDark'], ] as const -// Covers the threshold branches in both implementations: luminance > 0.78, -// luminance < 0.18, mid-range luminance, near-black/near-white edge cases, -// pure saturated hues, and the 3-digit shorthand hex normalization path. -// #24ffee sits right at luminance ~0.7807 — inside the narrow (0.78, 0.79) -// band — so this matrix actually exercises the 0.78 threshold boundary -// (verified: flipping 0.78 -> 0.79 in layout.tsx makes this test fail). +// Covers the decision branches in both implementations: the foreground flip +// (dark vs light text over the accent), the outline thresholds for near-white +// and near-black accents, pure saturated hues, and the 3-digit shorthand hex +// normalization path. const COLORS = [ '#6366f1', '#f43f5e', diff --git a/src/lib/__tests__/background-tint.test.ts b/src/lib/__tests__/background-tint.test.ts index 7d463bd..62c66c6 100644 --- a/src/lib/__tests__/background-tint.test.ts +++ b/src/lib/__tests__/background-tint.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { describe, it, expect } from 'vitest' +import { AA, contrast, worstContrast } from './oklch-wcag' /** * Prova de acessibilidade do tint de fundo dinâmico (cor de destaque → fundo). @@ -33,59 +34,6 @@ const FOREGROUND = { light: 0.145, dark: 0.985 } // --foreground / --card-foregr const MUTED_FG_DARK = 0.708 // --muted-foreground no dark (inalterado) const CARD_LIGHT = 1 // --card no light permanece branco -// ── OKLCH → sRGB → luminância WCAG (mesma matemática do runtime CSS) ─────────── -const clamp = (x: number, a: number, b: number) => Math.min(b, Math.max(a, x)) -const linToSrgb = (c: number) => { - const v = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055 - return clamp(v, 0, 1) -} -function oklchToLinearRgb(L: number, C: number, H: number) { - const a = C * Math.cos((H * Math.PI) / 180) - const b = C * Math.sin((H * Math.PI) / 180) - const l_ = L + 0.3963377774 * a + 0.2158037573 * b - const m_ = L - 0.1055613458 * a - 0.0638541728 * b - const s_ = L - 0.0894841775 * a - 1.291485548 * b - const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3 - return { - r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, - g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, - b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, - } -} -/** Luminância relativa WCAG a partir de OKLCH (via sRGB clipado em gamut). */ -function wcagLuminance(L: number, C = 0, H = 0): number { - const lin = oklchToLinearRgb(L, C, H) - // clip de gamut como o browser faz, depois relineariza para luminância - const r = linToSrgb(lin.r), g = linToSrgb(lin.g), b = linToSrgb(lin.b) - const de = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4) - return 0.2126 * de(r) + 0.7152 * de(g) + 0.0722 * de(b) -} -function contrast(aL: number, aC: number, aH: number, bL: number, bC: number, bH: number): number { - const la = wcagLuminance(aL, aC, aH), lb = wcagLuminance(bL, bC, bH) - const hi = Math.max(la, lb), lo = Math.min(la, lb) - return (hi + 0.05) / (lo + 0.05) -} - -const AA = 4.5 -const HUES = Array.from({ length: 360 }, (_, h) => h) -// Chroma de origem do accent: near-cinza até saturação alta. clamp() decide o tint real. -const SOURCE_CHROMAS = [0.004, 0.012, 0.045, 0.1, 0.18, 0.25, 0.37] - -/** Menor contraste do par ao longo de todas as matizes × chromas de origem. */ -function worstContrast( - pairFor: (h: number, srcC: number) => number -): { min: number; at: { h: number; srcC: number } } { - let min = Infinity - let at = { h: 0, srcC: 0 } - for (const srcC of SOURCE_CHROMAS) { - for (const h of HUES) { - const c = pairFor(h, srcC) - if (c < min) { min = c; at = { h, srcC } } - } - } - return { min, at } -} - describe('tint de fundo dinâmico — garantia WCAG AA', () => { it('texto principal sobre o fundo tingido (light) ≥ 4.5', () => { const { min } = worstContrast((h, srcC) => diff --git a/src/lib/__tests__/custom-accent-tokens.test.ts b/src/lib/__tests__/custom-accent-tokens.test.ts new file mode 100644 index 0000000..1fb82dc --- /dev/null +++ b/src/lib/__tests__/custom-accent-tokens.test.ts @@ -0,0 +1,131 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, it, expect } from 'vitest' +import { AA, contrast, worstContrast } from './oklch-wcag' + +/** + * Prova de acessibilidade dos tokens suaves do accent personalizado. + * + * Sob `[data-accent="custom"]`, --accent/--muted (e seus foregrounds) são derivados + * do hex escolhido DIRETO no CSS via relative color, com L fixo por papel e croma + * proporcional ao da cor com teto igual ao dos presets. Este teste reproduz EXATAMENTE + * essas fórmulas (mesmas constantes de globals.css) e varre o círculo de matizes × + * chromas de origem, exigindo WCAG AA em cada par texto/superfície — o critério de + * parada verificável para QUALQUER cor que o seletor produza (amarelos e cianos + * claros e saturados inclusos). + * + * Fonte da verdade das constantes: o bloco `[data-accent="custom"]` dentro de + * `@supports (color: oklch(from red l c h))` em src/app/globals.css. O bloco + * "sincronia com globals.css" casa os valores, travando drift entre teste e folha. + */ + +// ── Constantes dos tokens custom (espelham globals.css) ─────────────────────── +const CUSTOM = { + light: { + accent: { L: 0.95, mult: 0.28, cap: 0.045 }, + accentFg: { L: 0.25, mult: 0.35, cap: 0.06 }, + muted: { L: 0.97, mult: 0.12, cap: 0.012 }, + }, + dark: { + accent: { L: 0.26, mult: 0.35, cap: 0.055 }, + accentFg: { L: 0.96, mult: 0.12, cap: 0.02 }, + muted: { L: 0.23, mult: 0.15, cap: 0.025 }, + }, +} as const + +// Foregrounds neutros herdados do tema (inalterados sob accent custom). +const MUTED_FG = { light: 0.556, dark: 0.708 } +const FOREGROUND = { light: 0.145, dark: 0.985 } + +type Formula = { L: number; mult: number; cap: number } +const derivedC = (f: Formula, srcC: number) => Math.min(srcC * f.mult, f.cap) + +describe('tokens suaves do accent custom — garantia WCAG AA', () => { + it('accent-foreground sobre --accent (light) ≥ 4.5', () => { + const { min, at } = worstContrast((h, srcC) => + contrast( + CUSTOM.light.accentFg.L, derivedC(CUSTOM.light.accentFg, srcC), h, + CUSTOM.light.accent.L, derivedC(CUSTOM.light.accent, srcC), h + ) + ) + expect(min, `pior caso em h=${at.h} srcC=${at.srcC}`).toBeGreaterThanOrEqual(AA) + }) + + it('accent-foreground sobre --accent (dark) ≥ 4.5', () => { + const { min, at } = worstContrast((h, srcC) => + contrast( + CUSTOM.dark.accentFg.L, derivedC(CUSTOM.dark.accentFg, srcC), h, + CUSTOM.dark.accent.L, derivedC(CUSTOM.dark.accent, srcC), h + ) + ) + expect(min, `pior caso em h=${at.h} srcC=${at.srcC}`).toBeGreaterThanOrEqual(AA) + }) + + it('muted-foreground neutro sobre --muted (light) não regride do baseline neutro', () => { + // Este par não é AA nem no tema neutro (cinza 0.556 sobre cinza 0.97 ≈ 4.17); + // quem garante AA nesse papel é o muted-foreground escurecido (L 0.5) do modo + // bg-tint, que vence por especificidade no caso padrão. Aqui o invariante é + // não regredir da vizinhança do baseline — a classe de bug prevenida é o muted + // virar cor viva (contraste ~2) para accents claros e saturados. + const { min, at } = worstContrast((h, srcC) => + contrast( + MUTED_FG.light, 0, 0, + CUSTOM.light.muted.L, derivedC(CUSTOM.light.muted, srcC), h + ) + ) + expect(min, `pior caso em h=${at.h} srcC=${at.srcC}`).toBeGreaterThanOrEqual(4.0) + }) + + it('muted-foreground neutro sobre --muted (dark) ≥ 4.5', () => { + const { min, at } = worstContrast((h, srcC) => + contrast( + MUTED_FG.dark, 0, 0, + CUSTOM.dark.muted.L, derivedC(CUSTOM.dark.muted, srcC), h + ) + ) + expect(min, `pior caso em h=${at.h} srcC=${at.srcC}`).toBeGreaterThanOrEqual(AA) + }) + + it('foreground principal sobre --muted (ambos os temas) ≥ 4.5', () => { + const light = worstContrast((h, srcC) => + contrast(FOREGROUND.light, 0, 0, CUSTOM.light.muted.L, derivedC(CUSTOM.light.muted, srcC), h) + ) + const dark = worstContrast((h, srcC) => + contrast(FOREGROUND.dark, 0, 0, CUSTOM.dark.muted.L, derivedC(CUSTOM.dark.muted, srcC), h) + ) + expect(light.min).toBeGreaterThanOrEqual(AA) + expect(dark.min).toBeGreaterThanOrEqual(AA) + }) +}) + +describe('sincronia com globals.css — o CSS shipado usa as constantes provadas', () => { + const css = readFileSync(resolve(process.cwd(), 'src/app/globals.css'), 'utf8') + const rel = (f: Formula) => + `oklch(from var(--custom-accent-hex) ${f.L} clamp(0, calc(c * ${f.mult}), ${f.cap}) h)` + + it('tokens do light batem com o teste', () => { + expect(css).toContain(`--accent: ${rel(CUSTOM.light.accent)}`) + expect(css).toContain(`--accent-foreground: ${rel(CUSTOM.light.accentFg)}`) + expect(css).toContain(`--muted: ${rel(CUSTOM.light.muted)}`) + }) + + it('tokens do dark batem com o teste', () => { + expect(css).toContain(`--accent: ${rel(CUSTOM.dark.accent)}`) + expect(css).toContain(`--accent-foreground: ${rel(CUSTOM.dark.accentFg)}`) + expect(css).toContain(`--muted: ${rel(CUSTOM.dark.muted)}`) + }) + + it('o JS não injeta mais tokens suaves — só hex, foreground e borda', () => { + const provider = readFileSync( + resolve(process.cwd(), 'src/components/accent-color-provider.tsx'), + 'utf8' + ) + const layout = readFileSync(resolve(process.cwd(), 'src/app/layout.tsx'), 'utf8') + for (const source of [provider, layout]) { + expect(source).not.toContain('--custom-accent-soft-light') + expect(source).not.toContain('--custom-accent-muted-light') + expect(source).not.toContain('--custom-accent-soft-dark') + expect(source).not.toContain('--custom-accent-muted-dark') + } + }) +}) diff --git a/src/lib/__tests__/custom-color.test.ts b/src/lib/__tests__/custom-color.test.ts index 2b8224e..56d66ab 100644 --- a/src/lib/__tests__/custom-color.test.ts +++ b/src/lib/__tests__/custom-color.test.ts @@ -38,17 +38,17 @@ describe('custom accent color helpers', () => { expect(getReadableCustomForeground('#111827')).toBe(CUSTOM_FOREGROUND_LIGHT) }) - it('generates contrast-safe soft accent tokens for extreme custom colors', () => { + it('generates contrast-safe primary tokens for extreme custom colors', () => { const black = getCustomAccentTokens('#000000') const white = getCustomAccentTokens('#ffffff') - expect(black.accentDark).not.toBe('#000000') - expect(getContrastRatio(black.accentDark, black.accentForegroundDark)).toBeGreaterThanOrEqual(4.5) - expect(getContrastRatio(black.accentLight, black.accentForegroundLight)).toBeGreaterThanOrEqual(4.5) + expect(getContrastRatio(black.primary, black.primaryForeground)).toBeGreaterThanOrEqual(4.5) + expect(getContrastRatio(white.primary, white.primaryForeground)).toBeGreaterThanOrEqual(4.5) expect(white.primaryBorder).not.toBe('transparent') expect(getContrastRatio('#ffffff', white.primaryBorder)).toBeGreaterThanOrEqual(1.5) - expect(getContrastRatio(white.accentLight, white.accentForegroundLight)).toBeGreaterThanOrEqual(4.5) + expect(black.primaryBorder).not.toBe('transparent') + expect(getContrastRatio('#000000', black.primaryBorder)).toBeGreaterThanOrEqual(1.5) }) it('builds browser icon URLs that force a refresh for the active accent color', () => { diff --git a/src/lib/__tests__/oklch-wcag.ts b/src/lib/__tests__/oklch-wcag.ts new file mode 100644 index 0000000..12e10c8 --- /dev/null +++ b/src/lib/__tests__/oklch-wcag.ts @@ -0,0 +1,66 @@ +/** + * Matemática OKLCH → sRGB → luminância/contraste WCAG usada pelas provas de + * acessibilidade dos tokens derivados em CSS (tint de fundo e accent custom). + * Reproduz o pipeline do browser: OKLCH → linear RGB → clip de gamut → sRGB. + */ + +const clamp = (x: number, a: number, b: number) => Math.min(b, Math.max(a, x)) + +const linToSrgb = (c: number) => { + const v = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055 + return clamp(v, 0, 1) +} + +export function oklchToLinearRgb(L: number, C: number, H: number) { + const a = C * Math.cos((H * Math.PI) / 180) + const b = C * Math.sin((H * Math.PI) / 180) + const l_ = L + 0.3963377774 * a + 0.2158037573 * b + const m_ = L - 0.1055613458 * a - 0.0638541728 * b + const s_ = L - 0.0894841775 * a - 1.291485548 * b + const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3 + return { + r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + } +} + +/** Luminância relativa WCAG a partir de OKLCH (via sRGB clipado em gamut). */ +export function wcagLuminance(L: number, C = 0, H = 0): number { + const lin = oklchToLinearRgb(L, C, H) + // clip de gamut como o browser faz, depois relineariza para luminância + const r = linToSrgb(lin.r), g = linToSrgb(lin.g), b = linToSrgb(lin.b) + const de = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4) + return 0.2126 * de(r) + 0.7152 * de(g) + 0.0722 * de(b) +} + +export function contrast( + aL: number, aC: number, aH: number, + bL: number, bC: number, bH: number +): number { + const la = wcagLuminance(aL, aC, aH), lb = wcagLuminance(bL, bC, bH) + const hi = Math.max(la, lb), lo = Math.min(la, lb) + return (hi + 0.05) / (lo + 0.05) +} + +export const AA = 4.5 + +export const HUES = Array.from({ length: 360 }, (_, h) => h) + +/** Chromas de origem representativos: near-cinza até saturação alta de gamut sRGB. */ +export const SOURCE_CHROMAS = [0.004, 0.012, 0.045, 0.1, 0.18, 0.25, 0.37] + +/** Menor contraste do par ao longo de todas as matizes × chromas de origem. */ +export function worstContrast( + pairFor: (h: number, srcC: number) => number +): { min: number; at: { h: number; srcC: number } } { + let min = Infinity + let at = { h: 0, srcC: 0 } + for (const srcC of SOURCE_CHROMAS) { + for (const h of HUES) { + const c = pairFor(h, srcC) + if (c < min) { min = c; at = { h, srcC } } + } + } + return { min, at } +} diff --git a/src/lib/custom-color.ts b/src/lib/custom-color.ts index c4b8325..1027690 100644 --- a/src/lib/custom-color.ts +++ b/src/lib/custom-color.ts @@ -37,25 +37,6 @@ function hexToRgb(hex: string | null | undefined): RgbColor { } } -function rgbToHex({ r, g, b }: RgbColor): string { - return `#${[r, g, b].map((channel) => { - const value = Math.round(Math.max(0, Math.min(255, channel))) - return value.toString(16).padStart(2, '0') - }).join('')}` -} - -function mixHex(from: string, to: string, toWeight: number): string { - const a = hexToRgb(from) - const b = hexToRgb(to) - const weight = Math.max(0, Math.min(1, toWeight)) - - return rgbToHex({ - r: a.r * (1 - weight) + b.r * weight, - g: a.g * (1 - weight) + b.g * weight, - b: a.b * (1 - weight) + b.b * weight, - }) -} - function relativeLuminance(hex: string | null | undefined): number { const { r, g, b } = hexToRgb(hex) const channels = [r, g, b].map((channel) => { @@ -93,44 +74,24 @@ export function getVisibleCustomOutline(hex: string | null | undefined): string return 'transparent' } +/** + * Tokens que dependem de decisão de contraste em runtime (JS). As superfícies + * suaves (--accent/--muted) NÃO passam por aqui: são derivadas do hex direto no + * CSS, em OKLCH — ver o bloco [data-accent="custom"] de globals.css. + */ export interface CustomAccentTokens { primary: string primaryForeground: string primaryBorder: string - accentLight: string - accentForegroundLight: string - mutedLight: string - accentDark: string - accentForegroundDark: string - mutedDark: string } export function getCustomAccentTokens(hex: string | null | undefined): CustomAccentTokens { const primary = getColorInputValue(hex) - const luminance = relativeLuminance(primary) - const accentLight = luminance > 0.78 - ? mixHex(primary, '#111827', 0.12) - : mixHex(primary, '#ffffff', 0.88) - const mutedLight = luminance > 0.78 - ? mixHex(primary, '#111827', 0.06) - : mixHex(primary, '#ffffff', 0.94) - const accentDark = luminance < 0.18 - ? mixHex(primary, '#ffffff', 0.18) - : mixHex(primary, '#000000', 0.72) - const mutedDark = luminance < 0.18 - ? mixHex(primary, '#ffffff', 0.11) - : mixHex(primary, '#000000', 0.82) return { primary, primaryForeground: getReadableCustomForeground(primary), primaryBorder: getVisibleCustomOutline(primary), - accentLight, - accentForegroundLight: getReadableCustomForeground(accentLight), - mutedLight, - accentDark, - accentForegroundDark: getReadableCustomForeground(accentDark), - mutedDark, } } From 21138f26da37e7e1f0824856ab645efd0e747a4a Mon Sep 17 00:00:00 2001 From: johnlaff <46091881+johnlaff@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:48:12 -0300 Subject: [PATCH 2/8] fix(appearance): tinte de fundo acompanha o croma da cor de destaque MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O croma do tint era clampado num teto minúsculo (0.012/0.01) alcançado por qualquer cor saturada: só o matiz influenciava o fundo, e mover o seletor de cor personalizada entre variantes claras/saturadas não mudava nada — o fundo parecia travado num tom. O croma agora é proporcional ao da cor (calc(c * fator)) com teto maior: vívido tinge mais, pastel tinge menos, cinza não tinge. L continua fixo no valor do tema neutro, então os pares de contraste seguem WCAG AA em todo o círculo de matizes — prova varrida em background-tint.test.ts. --- src/app/globals.css | 14 ++++--- src/lib/__tests__/background-tint.test.ts | 50 +++++++++++++---------- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index b364c0d..a15475b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -722,8 +722,10 @@ html.theme-switching *::after { 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 @@ -731,12 +733,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); diff --git a/src/lib/__tests__/background-tint.test.ts b/src/lib/__tests__/background-tint.test.ts index 62c66c6..9aa9c5d 100644 --- a/src/lib/__tests__/background-tint.test.ts +++ b/src/lib/__tests__/background-tint.test.ts @@ -7,10 +7,13 @@ import { AA, contrast, worstContrast } from './oklch-wcag' * Prova de acessibilidade do tint de fundo dinâmico (cor de destaque → fundo). * * O tint é aplicado em CSS via relative color: `oklch(from var(--primary) L C h)`, - * com L fixo no valor do tema neutro e chroma clampado. Este teste reproduz EXATAMENTE - * essa fórmula (mesmas constantes de globals.css) e varre todo o círculo de matizes, - * exigindo contraste WCAG AA em cada par texto/superfície. É o critério de parada - * verificável: se qualquer matiz cair abaixo de 4.5:1, o teste falha. + * com L fixo no valor do tema neutro e croma PROPORCIONAL ao da cor de destaque + * (calc(c * fator)) com teto — uma cor vívida tinge mais, uma pastel tinge menos e + * um cinza não tinge, então o fundo acompanha as variantes escolhidas no seletor. + * Este teste reproduz EXATAMENTE essa fórmula (mesmas constantes de globals.css) e + * varre todo o círculo de matizes × chromas de origem, exigindo contraste WCAG AA + * em cada par texto/superfície. É o critério de parada verificável: se qualquer + * combinação cair abaixo de 4.5:1, o teste falha. * * Fonte da verdade das constantes: os blocos `[data-bg-tint="on"]...` em * src/app/globals.css. O bloco "sincronia com globals.css" (no fim deste arquivo) lê @@ -20,15 +23,18 @@ import { AA, contrast, worstContrast } from './oklch-wcag' // ── Constantes do tint (espelham globals.css) ───────────────────────────────── const TINT = { light: { - bg: { L: 0.98, Cmax: 0.012 }, - mutedFg: { L: 0.5, Cmax: 0.02 }, + bg: { L: 0.98, mult: 0.15, cap: 0.03 }, + mutedFg: { L: 0.5, mult: 0.15, cap: 0.02 }, }, dark: { - bg: { L: 0.145, Cmax: 0.01 }, - card: { L: 0.205, Cmax: 0.008 }, + bg: { L: 0.145, mult: 0.18, cap: 0.03 }, + card: { L: 0.205, mult: 0.15, cap: 0.025 }, }, } as const +type Formula = { L: number; mult: number; cap: number } +const derivedC = (f: Formula, srcC: number) => Math.min(srcC * f.mult, f.cap) + // Foregrounds herdados do tema (não mudam no modo tingido). const FOREGROUND = { light: 0.145, dark: 0.985 } // --foreground / --card-foreground (cinza neutro) const MUTED_FG_DARK = 0.708 // --muted-foreground no dark (inalterado) @@ -37,7 +43,7 @@ const CARD_LIGHT = 1 // --card no light permanece branco describe('tint de fundo dinâmico — garantia WCAG AA', () => { it('texto principal sobre o fundo tingido (light) ≥ 4.5', () => { const { min } = worstContrast((h, srcC) => - contrast(FOREGROUND.light, 0, 0, TINT.light.bg.L, Math.min(srcC, TINT.light.bg.Cmax), h) + contrast(FOREGROUND.light, 0, 0, TINT.light.bg.L, derivedC(TINT.light.bg, srcC), h) ) expect(min).toBeGreaterThanOrEqual(AA) }) @@ -45,8 +51,8 @@ describe('tint de fundo dinâmico — garantia WCAG AA', () => { it('texto-muted sobre o fundo tingido (light) ≥ 4.5', () => { const { min, at } = worstContrast((h, srcC) => contrast( - TINT.light.mutedFg.L, Math.min(srcC, TINT.light.mutedFg.Cmax), h, - TINT.light.bg.L, Math.min(srcC, TINT.light.bg.Cmax), h + TINT.light.mutedFg.L, derivedC(TINT.light.mutedFg, srcC), h, + TINT.light.bg.L, derivedC(TINT.light.bg, srcC), h ) ) expect(min, `pior caso em h=${at.h} srcC=${at.srcC}`).toBeGreaterThanOrEqual(AA) @@ -54,7 +60,7 @@ describe('tint de fundo dinâmico — garantia WCAG AA', () => { it('texto-muted sobre card branco (light) ≥ 4.5', () => { const { min } = worstContrast((h, srcC) => - contrast(TINT.light.mutedFg.L, Math.min(srcC, TINT.light.mutedFg.Cmax), h, CARD_LIGHT, 0, 0) + contrast(TINT.light.mutedFg.L, derivedC(TINT.light.mutedFg, srcC), h, CARD_LIGHT, 0, 0) ) expect(min).toBeGreaterThanOrEqual(AA) }) @@ -62,7 +68,7 @@ describe('tint de fundo dinâmico — garantia WCAG AA', () => { it('texto-muted sobre superfície --muted (light, L≈0.97) ≥ 4.5', () => { const { min } = worstContrast((h, srcC) => contrast( - TINT.light.mutedFg.L, Math.min(srcC, TINT.light.mutedFg.Cmax), h, + TINT.light.mutedFg.L, derivedC(TINT.light.mutedFg, srcC), h, 0.97, Math.min(srcC, 0.015), h ) ) @@ -71,21 +77,21 @@ describe('tint de fundo dinâmico — garantia WCAG AA', () => { it('texto principal sobre o fundo tingido (dark) ≥ 4.5', () => { const { min } = worstContrast((h, srcC) => - contrast(FOREGROUND.dark, 0, 0, TINT.dark.bg.L, Math.min(srcC, TINT.dark.bg.Cmax), h) + contrast(FOREGROUND.dark, 0, 0, TINT.dark.bg.L, derivedC(TINT.dark.bg, srcC), h) ) expect(min).toBeGreaterThanOrEqual(AA) }) it('texto-muted sobre o fundo tingido (dark) ≥ 4.5', () => { const { min } = worstContrast((h, srcC) => - contrast(MUTED_FG_DARK, 0, 0, TINT.dark.bg.L, Math.min(srcC, TINT.dark.bg.Cmax), h) + contrast(MUTED_FG_DARK, 0, 0, TINT.dark.bg.L, derivedC(TINT.dark.bg, srcC), h) ) expect(min).toBeGreaterThanOrEqual(AA) }) it('texto-muted sobre o card tingido (dark) ≥ 4.5', () => { const { min } = worstContrast((h, srcC) => - contrast(MUTED_FG_DARK, 0, 0, TINT.dark.card.L, Math.min(srcC, TINT.dark.card.Cmax), h) + contrast(MUTED_FG_DARK, 0, 0, TINT.dark.card.L, derivedC(TINT.dark.card, srcC), h) ) expect(min).toBeGreaterThanOrEqual(AA) }) @@ -93,17 +99,17 @@ describe('tint de fundo dinâmico — garantia WCAG AA', () => { describe('sincronia com globals.css — o CSS shipado usa as constantes provadas', () => { const css = readFileSync(resolve(process.cwd(), 'src/app/globals.css'), 'utf8') - const rel = (L: number, Cmax: number) => - `oklch(from var(--primary) ${L} clamp(0, c, ${Cmax}) h)` + const rel = (f: Formula) => + `oklch(from var(--primary) ${f.L} clamp(0, calc(c * ${f.mult}), ${f.cap}) h)` it('fundo e muted-foreground do light batem com o teste', () => { - expect(css).toContain(rel(TINT.light.bg.L, TINT.light.bg.Cmax)) - expect(css).toContain(rel(TINT.light.mutedFg.L, TINT.light.mutedFg.Cmax)) + expect(css).toContain(rel(TINT.light.bg)) + expect(css).toContain(rel(TINT.light.mutedFg)) }) it('fundo e card do dark batem com o teste', () => { - expect(css).toContain(rel(TINT.dark.bg.L, TINT.dark.bg.Cmax)) - expect(css).toContain(rel(TINT.dark.card.L, TINT.dark.card.Cmax)) + expect(css).toContain(rel(TINT.dark.bg)) + expect(css).toContain(rel(TINT.dark.card)) }) it('o muted-foreground do dark é restaurado ao valor neutro que passa AA', () => { From c4055b7585f88b94f46daa267fba04164369ef32 Mon Sep 17 00:00:00 2001 From: johnlaff <46091881+johnlaff@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:50:48 -0300 Subject: [PATCH 3/8] =?UTF-8?q?fix(theme):=20reveal=20h=C3=ADbrido=20?= =?UTF-8?q?=E2=80=94=20clip=20est=C3=A1tico=20no=20CSS=20+=20WAAPI=20no=20?= =?UTF-8?q?ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A animação CSS no pseudo-elemento começava a contar no frame em que o snapshot nasce, antes de a captura terminar no Chrome mobile: os primeiros quadros caíam e o círculo aparecia já no meio do caminho, com o reveal truncado. No desktop a mesma mudança alterou a textura da animação. O clip-path estático de raio 0 em globals.css passa a valer desde o primeiro frame do pseudo-elemento (sem flash de tela cheia no gap até o ready) e o crescimento do círculo volta a ser WAAPI criado em transition.ready: só anima com os snapshots prontos. No desktop, onde o ready resolve em menos de um frame, isso restaura o comportamento anterior; no mobile elimina flash e truncamento de uma vez. --- src/app/globals.css | 34 ++++++--------- src/hooks/use-theme-toggle.ts | 19 ++++++--- .../__tests__/review-feedback-source.test.ts | 9 ++-- .../__tests__/theme-transition-css.test.ts | 17 ++++---- src/lib/theme-transition.ts | 41 +++++++++++++++++-- 5 files changed, 78 insertions(+), 42 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index a15475b..e94b9b0 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 *, diff --git a/src/hooks/use-theme-toggle.ts b/src/hooks/use-theme-toggle.ts index 3eda73e..e48893e 100644 --- a/src/hooks/use-theme-toggle.ts +++ b/src/hooks/use-theme-toggle.ts @@ -10,6 +10,7 @@ import { persistAppearanceSettings, } from '@/lib/appearance' import { + animateThemeReveal, beginThemeSwitch, clearThemeRevealGeometry, endThemeSwitch, @@ -62,12 +63,18 @@ export function useThemeToggle(): (e?: MouseEvent) => void { if (!transition) { timerRef.current = window.setTimeout(clearSuppression, THEME_SWITCH_SUPPRESSION_MS) } else { - // O reveal é uma animação CSS (@keyframes theme-reveal no snapshot do novo - // tema), não um WAAPI agendado em transition.ready: começa no mesmo frame em - // que o pseudo-elemento nasce. O WAAPI agendado abria um gap de 1-2 frames no - // mobile (o ready resolve mais devagar) em que o novo tema aparecia sem o - // clip-path — um flash de tela cheia. A view transition só resolve `finished` - // quando a animação CSS termina. + // Reveal híbrido: o snapshot do novo tema nasce recortado em raio 0 pelo + // clip-path estático de globals.css (sem flash de tela cheia no gap até o + // ready, que no Chrome mobile leva 1-2 frames), e o círculo cresce via WAAPI + // criado aqui, em transition.ready — com os snapshots prontos, sem jank de + // captura. `finished` só resolve quando a animação termina. + transition.ready.then( + () => { + if (toggleId !== toggleIdRef.current) return + animateThemeReveal(root, origin, radius) + }, + () => {} + ) transition.finished.catch(() => {}).finally(() => { timerRef.current = window.setTimeout(clearSuppression, THEME_SWITCH_SUPPRESSION_MS) }) diff --git a/src/lib/__tests__/review-feedback-source.test.ts b/src/lib/__tests__/review-feedback-source.test.ts index 9f42502..7a88009 100644 --- a/src/lib/__tests__/review-feedback-source.test.ts +++ b/src/lib/__tests__/review-feedback-source.test.ts @@ -74,11 +74,12 @@ describe('review feedback regressions', () => { }) it('keeps the circular reveal final frame filled until the browser removes the snapshot', () => { - // fill-mode `both` na animação CSS mantém o círculo no raio final (tela cheia) até - // o browser remover o snapshot — sem ele, o novo tema sumiria no último frame. - const css = readSource('src/app/globals.css') + // fill 'forwards' no WAAPI mantém o círculo no raio final (tela cheia) até o + // browser remover o snapshot — sem ele, o clip estático de raio 0 (globals.css) + // voltaria a esconder o novo tema no último frame. + const source = readSource('src/lib/theme-transition.ts') - expect(css).toMatch(/animation:\s*theme-reveal[^;]*\bboth\b/) + expect(source).toContain("fill: 'forwards'") }) it('uses plain router.push and avoids mount-time route prefetch storms', () => { diff --git a/src/lib/__tests__/theme-transition-css.test.ts b/src/lib/__tests__/theme-transition-css.test.ts index 1689c6b..76d2e5b 100644 --- a/src/lib/__tests__/theme-transition-css.test.ts +++ b/src/lib/__tests__/theme-transition-css.test.ts @@ -11,16 +11,19 @@ describe('theme transition CSS', () => { expect(globalsCss).not.toMatch(/\bmain\s*\{[^}]*view-transition-name:\s*main-content/) }) - it('reveals the new theme via a CSS keyframe starting at radius 0 (no scheduled WAAPI)', () => { + it('clips the new snapshot from the first frame and animates the reveal via WAAPI on ready', () => { const globalsCss = readFileSync(join(process.cwd(), 'src/app/globals.css'), 'utf8') + const themeTransition = readFileSync(join(process.cwd(), 'src/lib/theme-transition.ts'), 'utf8') - // A animação é CSS: começa no frame em que o snapshot nasce, sem o gap do WAAPI - // agendado que fazia o novo tema piscar em tela cheia no mobile. + // O clip inicial é ESTÁTICO no CSS: vale desde o frame em que o pseudo-elemento + // nasce, então o novo tema nunca pisca em tela cheia no gap até o ready (mobile). expect(globalsCss).toMatch( - /@keyframes theme-reveal\s*{[\s\S]*from\s*{[^}]*clip-path:\s*circle\(\s*0px at var\(--theme-reveal-x/ - ) - expect(globalsCss).toMatch( - /html\.theme-switching::view-transition-new\(root\)\s*{[^}]*animation:\s*theme-reveal/ + /html\.theme-switching::view-transition-new\(root\)\s*{[^}]*clip-path:\s*circle\(\s*0px at var\(--theme-reveal-x/ ) + // O crescimento do círculo é WAAPI agendado em transition.ready — snapshots + // prontos antes de animar (sem jank de captura) e comportamento idêntico ao + // do desktop, que sempre resolveu o ready em menos de um frame. + expect(globalsCss).not.toContain('@keyframes theme-reveal') + expect(themeTransition).toContain("pseudoElement: '::view-transition-new(root)'") }) }) diff --git a/src/lib/theme-transition.ts b/src/lib/theme-transition.ts index 1c7a041..2406e70 100644 --- a/src/lib/theme-transition.ts +++ b/src/lib/theme-transition.ts @@ -1,10 +1,13 @@ import type { ThemeMode } from '@/lib/preferences' -// A duração e a curva do reveal vivem no CSS (@keyframes theme-reveal em globals.css), -// onde a animação roda; aqui fica só a janela em que as transições de cor concorrentes -// seguem suprimidas após a troca. +// Janela em que as transições de cor concorrentes seguem suprimidas após a troca. export const THEME_SWITCH_SUPPRESSION_MS = 180 +export const THEME_REVEAL_DURATION_MS = 320 +// Emphasized decelerate (Material 3): o círculo parte do raio 0, acelera de imediato +// e assenta no fim. Um ease-in-out hesitaria nos primeiros quadros e pareceria lento. +export const THEME_REVEAL_EASING = 'cubic-bezier(0.05, 0.7, 0.1, 1)' + interface ViewportSize { width: number height: number @@ -74,6 +77,38 @@ export function clearThemeRevealGeometry(root: HTMLElement): void { root.style.removeProperty('--theme-reveal-radius') } +/** + * Cresce o círculo do reveal no snapshot do novo tema. Deve ser chamada em + * transition.ready (snapshots capturados): o pseudo-elemento nasce recortado em + * raio 0 pelo clip-path estático de globals.css, então o intervalo até o ready + * não mostra o novo tema — e a animação começa sem frames perdidos de captura. + */ +export function animateThemeReveal( + root: HTMLElement, + origin: RevealOrigin, + radius: number +): Animation | null { + const center = `${origin.x}px ${origin.y}px` + try { + return root.animate( + { clipPath: [`circle(0px at ${center})`, `circle(${radius}px at ${center})`] }, + { + duration: THEME_REVEAL_DURATION_MS, + easing: THEME_REVEAL_EASING, + // fill 'forwards' mantém o raio final até o browser remover o snapshot; + // sem ele o clip estático de raio 0 voltaria a esconder o novo tema. + fill: 'forwards', + pseudoElement: '::view-transition-new(root)', + } + ) + } catch { + // Engine com View Transitions mas sem WAAPI em pseudo-elemento: sem animações + // rodando nos snapshots, a transição resolve em seguida e os pseudo-elementos + // somem — a troca acontece sem o círculo, sem esconder o novo tema. + return null + } +} + export function startThemeViewTransition( doc: ThemeViewTransitionDocument, apply: () => void From 1dde8ce264de075b2921aece99a18fdd96d87c0c Mon Sep 17 00:00:00 2001 From: johnlaff <46091881+johnlaff@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:51:20 -0300 Subject: [PATCH 4/8] =?UTF-8?q?fix(appearance):=20troca=20a=20=C3=A1rea=20?= =?UTF-8?q?do=20seletor=20de=20cor=20por=20react-colorful?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A área custom desenhava o gradiente do modelo HSV (branco→matiz com preto por cima) mas mapeava o ponteiro para HSL: no topo direito l=100 vira branco puro, e o roundtrip hex→HSL perdia o matiz em saturação zero, travando o seletor. O HexColorPicker do react-colorful mantém estado HSV interno (matiz preservado nos extremos), com teclado e pointer capture nativos. Presets, preview e input hex seguem como estavam. --- package-lock.json | 11 +++ package.json | 1 + src/app/globals.css | 9 +++ src/components/accent-color-picker.tsx | 72 ++----------------- .../__tests__/review-feedback-source.test.ts | 10 +-- src/lib/custom-color.ts | 52 -------------- 6 files changed, 31 insertions(+), 124 deletions(-) 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..27d1ac2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -883,3 +883,12 @@ 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; } diff --git a/src/components/accent-color-picker.tsx b/src/components/accent-color-picker.tsx index 98451cc..685c6f8 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,7 @@ export function AccentColorPicker({ onCustomColorChange, className, }: AccentColorPickerProps) { - const colorAreaRef = useRef(null) const currentColor = getColorInputValue(customColor) - const currentHsl = hexToHsl(currentColor) // 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 +44,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 +52,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,29 +88,8 @@ export function AccentColorPicker({
-
diff --git a/src/lib/__tests__/review-feedback-source.test.ts b/src/lib/__tests__/review-feedback-source.test.ts index 9f42502..9d951c5 100644 --- a/src/lib/__tests__/review-feedback-source.test.ts +++ b/src/lib/__tests__/review-feedback-source.test.ts @@ -30,12 +30,14 @@ describe('review feedback regressions', () => { expect(browserAccentSource).toContain('if (architecturalPreset) return ARCHITECTURAL_PRESETS[architecturalPreset].color') }) - it('prevents page text selection while dragging the custom color field', () => { + it('uses the accessible HSV color picker for the custom color field', () => { const source = readSource('src/components/accent-color-picker.tsx') - expect(source).toContain('event.preventDefault()') - expect(source).toContain('select-none') - expect(source).toContain('touch-none') + expect(source).toContain("import { HexColorPicker } from 'react-colorful'") + expect(source).toContain('') + expect(source).toContain('aria-label="Seletor de cor personalizada"') + expect(source).not.toContain('hexToHsl') + expect(source).not.toContain('hslToHex') }) it('uses the computed accent foreground for active sidebar items', () => { diff --git a/src/lib/custom-color.ts b/src/lib/custom-color.ts index c4b8325..ac67b56 100644 --- a/src/lib/custom-color.ts +++ b/src/lib/custom-color.ts @@ -140,55 +140,3 @@ export function getBrowserAccentIconUrl(hex: string | null | undefined, size = 1 const cacheKey = color.replace('#', '') return `/api/icon?size=${iconSize}&color=${encodeURIComponent(color)}&v=${cacheKey}` } - -export interface HslColor { - h: number - s: number - l: number -} - -export function hexToHsl(hex: string | null | undefined): HslColor { - const normalized = getColorInputValue(hex).slice(1) - const r = Number.parseInt(normalized.slice(0, 2), 16) / 255 - const g = Number.parseInt(normalized.slice(2, 4), 16) / 255 - const b = Number.parseInt(normalized.slice(4, 6), 16) / 255 - const max = Math.max(r, g, b) - const min = Math.min(r, g, b) - const delta = max - min - const l = (max + min) / 2 - - if (delta === 0) return { h: 0, s: 0, l: Math.round(l * 100) } - - const s = delta / (1 - Math.abs(2 * l - 1)) - let h = 0 - if (max === r) h = 60 * (((g - b) / delta) % 6) - else if (max === g) h = 60 * ((b - r) / delta + 2) - else h = 60 * ((r - g) / delta + 4) - - return { - h: Math.round((h + 360) % 360), - s: Math.round(s * 100), - l: Math.round(l * 100), - } -} - -export function hslToHex({ h, s, l }: HslColor): string { - const normalizedHue = ((h % 360) + 360) % 360 - const saturation = Math.max(0, Math.min(100, s)) / 100 - const lightness = Math.max(0, Math.min(100, l)) / 100 - const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation - const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1)) - const match = lightness - chroma / 2 - const [r1, g1, b1] = - normalizedHue < 60 ? [chroma, x, 0] : - normalizedHue < 120 ? [x, chroma, 0] : - normalizedHue < 180 ? [0, chroma, x] : - normalizedHue < 240 ? [0, x, chroma] : - normalizedHue < 300 ? [x, 0, chroma] : - [chroma, 0, x] - - return `#${[r1, g1, b1].map((channel) => { - const value = Math.round((channel + match) * 255) - return value.toString(16).padStart(2, '0') - }).join('')}` -} From 5304391c8f334b9d15e0252cdb9c5308f659f481 Mon Sep 17 00:00:00 2001 From: johnlaff <46091881+johnlaff@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:52:11 -0300 Subject: [PATCH 5/8] =?UTF-8?q?fix(settings):=20fila=20serial=20com=20merg?= =?UTF-8?q?e=20e=20retry=20offline=20para=20apar=C3=AAncia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cada ajuste de aparência disparava um PATCH /api/settings imediato — o seletor de cor emitia dezenas por arrasto — e respostas fora de ordem faziam o last-write-wins do servidor gravar valor obsoleto. PATCH que falhava (offline) morria num toast: o localStorage ficava novo, o servidor velho, e a hidratação seguinte sobrescrevia a escolha do usuário. A persistência agora passa por uma fila serial com merge de patches (debounce de 400ms, nunca dois PATCHes em voo), pendência durável em localStorage com flush no boot e ao voltar online, retry com backoff (1s→5s→15s→60s) e toast único por sequência de falhas. A hidratação remota é pulada enquanto houver pendência local — o dado ainda não enviado vence o snapshot antigo do servidor. --- src/components/accent-color-provider.tsx | 8 +- src/components/providers.tsx | 10 +- src/hooks/use-theme-toggle.ts | 7 +- .../__tests__/accent-color-provider.test.tsx | 20 ++- src/lib/__tests__/settings-sync.test.ts | 158 +++++++++++++++++ src/lib/appearance.ts | 8 + src/lib/settings-sync.ts | 167 ++++++++++++++++++ 7 files changed, 361 insertions(+), 17 deletions(-) create mode 100644 src/lib/__tests__/settings-sync.test.ts create mode 100644 src/lib/settings-sync.ts diff --git a/src/components/accent-color-provider.tsx b/src/components/accent-color-provider.tsx index 7505384..02e5a3f 100644 --- a/src/components/accent-color-provider.tsx +++ b/src/components/accent-color-provider.tsx @@ -1,8 +1,8 @@ 'use client' import { createContext, use, useState, useEffect, useMemo } from 'react' -import { toast } from 'sonner' -import { markLocalPreferenceChange, persistAppearanceSettings, type AppearancePatch } from '@/lib/appearance' +import { markLocalPreferenceChange, type AppearancePatch } from '@/lib/appearance' +import { enqueueAppearancePatch } from '@/lib/settings-sync' import { ACCENT_PRESETS, isArchitecturalPreset, @@ -91,9 +91,7 @@ function clearCustomAccentProperties() { } function persist(patch: AppearancePatch) { - persistAppearanceSettings(patch).catch((error) => { - toast.error(error instanceof Error ? error.message : 'Erro ao salvar aparência') - }) + enqueueAppearancePatch(patch) } export function AccentColorProvider({ children }: { children: React.ReactNode }) { diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 83e3fd7..cf4c876 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -15,8 +15,9 @@ import { ThemeColorSync } from '@/components/theme-color-sync' import { getLastLocalPreferenceChange, hasLocalCustomAccentPreference, - shouldApplyRemotePreferences, + shouldApplyRemoteAppearance, } from '@/lib/appearance' +import { hasPendingAppearancePatch, restorePendingAppearanceSync } from '@/lib/settings-sync' function PreferencesHydrator() { const pathname = usePathname() @@ -31,6 +32,7 @@ function PreferencesHydrator() { // react-doctor-disable-next-line react-doctor/no-fetch-in-effect -- sincronização pós-mount de preferências visuais (tema/cor): depende de estado cliente (useTheme, useAccentColor) e já tem flag de cancelamento; não é data fetching para render, não pode ser movido para RSC useEffect(() => { + restorePendingAppearanceSync() if (isAuthRoute) return let cancelled = false @@ -39,7 +41,11 @@ function PreferencesHydrator() { .then((res) => res.ok ? res.json() : null) .then((body) => { if (cancelled || !body?.settings) return - if (!shouldApplyRemotePreferences(startedAt, getLastLocalPreferenceChange())) return + if (!shouldApplyRemoteAppearance( + startedAt, + getLastLocalPreferenceChange(), + hasPendingAppearancePatch() + )) return // Skip overwriting a local custom accent only when the server is a plain // preset — but when both sides are 'custom', the server hex is newer (the // user updated it on another device) so we DO sync it. diff --git a/src/hooks/use-theme-toggle.ts b/src/hooks/use-theme-toggle.ts index 3eda73e..042a3f0 100644 --- a/src/hooks/use-theme-toggle.ts +++ b/src/hooks/use-theme-toggle.ts @@ -3,12 +3,11 @@ import { useCallback, useRef } from 'react' import type { MouseEvent } from 'react' import { useTheme } from 'next-themes' -import { toast } from 'sonner' import { getNextThemeMode, markLocalPreferenceChange, - persistAppearanceSettings, } from '@/lib/appearance' +import { enqueueAppearancePatch } from '@/lib/settings-sync' import { beginThemeSwitch, clearThemeRevealGeometry, @@ -73,9 +72,7 @@ export function useThemeToggle(): (e?: MouseEvent) => void { }) } - persistAppearanceSettings({ themeMode: next }).catch((err) => { - toast.error(err instanceof Error ? err.message : 'Erro ao salvar tema') - }) + enqueueAppearancePatch({ themeMode: next }) }, [resolvedTheme, setTheme] ) diff --git a/src/lib/__tests__/accent-color-provider.test.tsx b/src/lib/__tests__/accent-color-provider.test.tsx index a4eb594..21a5ff1 100644 --- a/src/lib/__tests__/accent-color-provider.test.tsx +++ b/src/lib/__tests__/accent-color-provider.test.tsx @@ -5,6 +5,7 @@ import { AccentColorProvider, useAccentColor, } from '@/components/accent-color-provider' +import { __resetSettingsSyncForTests } from '@/lib/settings-sync' function ProviderHarness() { const { setAccent, setArchitecturalPreset, setCustomColor, setDensity, syncAppearanceFromRemote } = useAccentColor() @@ -47,6 +48,7 @@ describe('AccentColorProvider browser accent sync', () => { beforeEach(() => { vi.useFakeTimers() vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({}) }))) + __resetSettingsSyncForTests() localStorage.clear() document.cookie = '' document.head.innerHTML = '' @@ -60,7 +62,7 @@ describe('AccentColorProvider browser accent sync', () => { afterEach(() => { act(() => root.unmount()) container.remove() - vi.runOnlyPendingTimers() + __resetSettingsSyncForTests() vi.useRealTimers() vi.unstubAllGlobals() localStorage.clear() @@ -75,27 +77,34 @@ describe('AccentColorProvider browser accent sync', () => { return call ? JSON.parse(call[1].body) : null } - it('persists the architectural preset to the server when set', () => { + async function flushAppearancePatch() { + await vi.advanceTimersByTimeAsync(400) + } + + it('persists the architectural preset to the server when set', async () => { act(() => { root.render() }) act(() => { document.querySelector('button:nth-of-type(1)')?.click() }) + await flushAppearancePatch() expect(lastPatch()).toEqual({ architecturalPreset: 'vegetacao' }) }) - it('persists density to the server when set', () => { + it('persists density to the server when set', async () => { act(() => { root.render() }) act(() => { document.querySelector('button:nth-of-type(4)')?.click() }) + await flushAppearancePatch() expect(lastPatch()).toEqual({ density: 'compact' }) }) - it('persists accent AND clears the preset server-side when an accent is chosen (regression: accent still syncs)', () => { + it('persists accent AND clears the preset server-side when an accent is chosen (regression: accent still syncs)', async () => { act(() => { root.render() }) act(() => { document.querySelector('button:nth-of-type(2)')?.click() }) + await flushAppearancePatch() expect(lastPatch()).toEqual({ accentPreset: 'rose', architecturalPreset: null }) }) @@ -138,11 +147,12 @@ describe('AccentColorProvider browser accent sync', () => { expect(document.head.innerHTML).not.toContain('%232d7a4f') }) - it('persists the custom color (and accent=custom, preset cleared) when a custom color is set', () => { + it('persists the custom color (and accent=custom, preset cleared) when a custom color is set', async () => { act(() => { root.render() }) act(() => { document.querySelector('button:nth-of-type(3)')?.click() }) // setCustomColor('#ffffff') + await flushAppearancePatch() expect(lastPatch()).toEqual({ accentPreset: 'custom', customAccentColor: '#ffffff', architecturalPreset: null }) }) diff --git a/src/lib/__tests__/settings-sync.test.ts b/src/lib/__tests__/settings-sync.test.ts new file mode 100644 index 0000000..4cadf2e --- /dev/null +++ b/src/lib/__tests__/settings-sync.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { shouldApplyRemoteAppearance } from '../appearance' +import { + __resetSettingsSyncForTests, + enqueueAppearancePatch, + hasPendingAppearancePatch, + restorePendingAppearanceSync, +} from '../settings-sync' + +const PENDING_APPEARANCE_KEY = 'archtime-pending-appearance' + +function successResponse(): Response { + return new Response(null, { status: 204 }) +} + +function deferred() { + let resolve: (value: T) => void + let reject: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + + return { promise, resolve: resolve!, reject: reject! } +} + +describe('settings appearance sync', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('fetch', fetchMock) + window.localStorage.clear() + __resetSettingsSyncForTests() + fetchMock.mockReset() + }) + + afterEach(() => { + __resetSettingsSyncForTests() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('merges changes made during the debounce window into one PATCH', async () => { + fetchMock.mockResolvedValue(successResponse()) + + enqueueAppearancePatch({ accentPreset: 'blue' }) + await vi.advanceTimersByTimeAsync(399) + enqueueAppearancePatch({ themeMode: 'dark' }) + + await vi.advanceTimersByTimeAsync(400) + + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock).toHaveBeenCalledWith('/api/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accentPreset: 'blue', themeMode: 'dark' }), + }) + }) + + it('waits for an in-flight PATCH before sending a later appearance change', async () => { + const firstRequest = deferred() + fetchMock.mockReturnValueOnce(firstRequest.promise).mockResolvedValueOnce(successResponse()) + + enqueueAppearancePatch({ accentPreset: 'blue' }) + await vi.advanceTimersByTimeAsync(400) + enqueueAppearancePatch({ themeMode: 'dark' }) + await vi.advanceTimersByTimeAsync(400) + + expect(fetchMock).toHaveBeenCalledOnce() + + firstRequest.resolve(successResponse()) + await Promise.resolve() + await Promise.resolve() + + expect(fetchMock).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(0) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenLastCalledWith('/api/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ themeMode: 'dark' }), + }) + }) + + it('keeps the pending patch after a network failure and retries with backoff', async () => { + fetchMock + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(successResponse()) + + enqueueAppearancePatch({ customAccentColor: '#123456' }) + await vi.advanceTimersByTimeAsync(400) + + expect(JSON.parse(window.localStorage.getItem(PENDING_APPEARANCE_KEY) ?? '{}')).toEqual({ + customAccentColor: '#123456', + }) + expect(fetchMock).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(999) + expect(fetchMock).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(1) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(window.localStorage.getItem(PENDING_APPEARANCE_KEY)).not.toBeNull() + + await vi.advanceTimersByTimeAsync(4_999) + expect(fetchMock).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(1) + expect(fetchMock).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(14_999) + expect(fetchMock).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(1) + expect(fetchMock).toHaveBeenCalledTimes(4) + + await vi.advanceTimersByTimeAsync(59_999) + expect(fetchMock).toHaveBeenCalledTimes(4) + + await vi.advanceTimersByTimeAsync(1) + expect(fetchMock).toHaveBeenCalledTimes(5) + + await vi.advanceTimersByTimeAsync(59_999) + expect(fetchMock).toHaveBeenCalledTimes(5) + + await vi.advanceTimersByTimeAsync(1) + expect(fetchMock).toHaveBeenCalledTimes(6) + expect(window.localStorage.getItem(PENDING_APPEARANCE_KEY)).toBeNull() + }) + + it('resends a durable pending patch when the app boots', async () => { + fetchMock.mockResolvedValue(successResponse()) + window.localStorage.setItem(PENDING_APPEARANCE_KEY, JSON.stringify({ density: 'compact' })) + + restorePendingAppearanceSync() + await vi.advanceTimersByTimeAsync(0) + + expect(fetchMock).toHaveBeenCalledWith('/api/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ density: 'compact' }), + }) + }) + + it('keeps local appearance when a durable patch is pending during hydration', () => { + enqueueAppearancePatch({ themeMode: 'dark' }) + + expect(hasPendingAppearancePatch()).toBe(true) + expect(shouldApplyRemoteAppearance(20_000, null, hasPendingAppearancePatch())).toBe(false) + }) +}) diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index f494d53..9006589 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -32,6 +32,14 @@ export function shouldApplyRemotePreferences( return lastLocalChangeAt == null || lastLocalChangeAt < hydrationStartedAt - graceMs } +export function shouldApplyRemoteAppearance( + hydrationStartedAt: number, + lastLocalChangeAt: number | null, + hasPendingPatch: boolean +): boolean { + return !hasPendingPatch && shouldApplyRemotePreferences(hydrationStartedAt, lastLocalChangeAt) +} + export function markLocalPreferenceChange(now = Date.now()): void { if (typeof window === 'undefined') return window.localStorage.setItem(LOCAL_PREFERENCE_UPDATED_AT_KEY, String(now)) diff --git a/src/lib/settings-sync.ts b/src/lib/settings-sync.ts new file mode 100644 index 0000000..3024890 --- /dev/null +++ b/src/lib/settings-sync.ts @@ -0,0 +1,167 @@ +import { toast } from 'sonner' +import { persistAppearanceSettings, type AppearancePatch } from '@/lib/appearance' + +const PENDING_APPEARANCE_KEY = 'archtime-pending-appearance' +const APPEARANCE_DEBOUNCE_MS = 400 +const RETRY_DELAYS_MS = [1_000, 5_000, 15_000, 60_000] + +let pendingPatch: AppearancePatch | null = null +let inFlightPatch: AppearancePatch | null = null +let flushTimer: ReturnType | null = null +let flushPromise: Promise | null = null +let retryAttempt = 0 +let hasShownFailureToast = false +let onlineListenerRegistered = false + +function canUseBrowserStorage(): boolean { + return typeof window !== 'undefined' +} + +function mergePatches( + base: AppearancePatch | null, + override: AppearancePatch | null +): AppearancePatch | null { + if (!base && !override) return null + return { ...base, ...override } +} + +function isAppearancePatch(value: unknown): value is AppearancePatch { + return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0 +} + +function readStoredPendingPatch(): AppearancePatch | null { + if (!canUseBrowserStorage()) return null + + try { + const stored = window.localStorage.getItem(PENDING_APPEARANCE_KEY) + if (!stored) return null + + const patch: unknown = JSON.parse(stored) + if (isAppearancePatch(patch)) return patch + + window.localStorage.removeItem(PENDING_APPEARANCE_KEY) + } catch {} + + return null +} + +function loadPendingPatchFromStorage(): void { + if (pendingPatch || inFlightPatch) return + pendingPatch = readStoredPendingPatch() +} + +function writeStoredPendingPatch(): void { + if (!canUseBrowserStorage()) return + + const durablePatch = mergePatches(inFlightPatch, pendingPatch) + try { + if (durablePatch) { + window.localStorage.setItem(PENDING_APPEARANCE_KEY, JSON.stringify(durablePatch)) + } else { + window.localStorage.removeItem(PENDING_APPEARANCE_KEY) + } + } catch {} +} + +function clearFlushTimer(): void { + if (flushTimer === null) return + clearTimeout(flushTimer) + flushTimer = null +} + +function scheduleFlush(delay: number): void { + if (!canUseBrowserStorage()) return + + clearFlushTimer() + flushTimer = setTimeout(() => { + flushTimer = null + void flushPendingAppearancePatch() + }, delay) +} + +function scheduleNextPendingFlush(): void { + if (!pendingPatch || flushTimer !== null) return + scheduleFlush(0) +} + +export function enqueueAppearancePatch(patch: AppearancePatch): void { + loadPendingPatchFromStorage() + pendingPatch = mergePatches(pendingPatch, patch) + writeStoredPendingPatch() + scheduleFlush(APPEARANCE_DEBOUNCE_MS) +} + +export function flushPendingAppearancePatch(): Promise { + if (!canUseBrowserStorage()) return Promise.resolve() + if (flushPromise) return flushPromise + + loadPendingPatchFromStorage() + if (!pendingPatch) return Promise.resolve() + + const patchToFlush = pendingPatch + pendingPatch = null + inFlightPatch = patchToFlush + + flushPromise = persistAppearanceSettings(patchToFlush) + .then(() => { + inFlightPatch = null + retryAttempt = 0 + hasShownFailureToast = false + writeStoredPendingPatch() + scheduleNextPendingFlush() + }) + .catch((error: unknown) => { + pendingPatch = mergePatches(inFlightPatch, pendingPatch) + inFlightPatch = null + writeStoredPendingPatch() + + if (!hasShownFailureToast) { + toast.error(error instanceof Error ? error.message : 'Erro ao salvar aparência') + hasShownFailureToast = true + } + + const delay = RETRY_DELAYS_MS[Math.min(retryAttempt, RETRY_DELAYS_MS.length - 1)] + retryAttempt += 1 + scheduleFlush(delay) + }) + .finally(() => { + flushPromise = null + }) + + return flushPromise +} + +export function hasPendingAppearancePatch(): boolean { + if (pendingPatch || inFlightPatch) return true + return readStoredPendingPatch() !== null +} + +export function restorePendingAppearanceSync(): void { + if (!canUseBrowserStorage()) return + + loadPendingPatchFromStorage() + if (pendingPatch) scheduleFlush(0) + + if (onlineListenerRegistered) return + window.addEventListener('online', handleOnline) + onlineListenerRegistered = true +} + +function handleOnline(): void { + if (hasPendingAppearancePatch()) scheduleFlush(0) +} + +export function __resetSettingsSyncForTests(): void { + clearFlushTimer() + pendingPatch = null + inFlightPatch = null + flushPromise = null + retryAttempt = 0 + hasShownFailureToast = false + + if (canUseBrowserStorage()) { + window.localStorage.removeItem(PENDING_APPEARANCE_KEY) + if (onlineListenerRegistered) window.removeEventListener('online', handleOnline) + } + onlineListenerRegistered = false +} From 0e7d89e54ffa3f189f69e21fc29d4f4a14782888 Mon Sep 17 00:00:00 2001 From: johnlaff <46091881+johnlaff@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:57:30 -0300 Subject: [PATCH 6/8] =?UTF-8?q?test(e2e):=20harness=20de=20captura=20visua?= =?UTF-8?q?l=20das=20corre=C3=A7=C3=B5es=20de=20apar=C3=AAncia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captura o dashboard sob accent personalizado (amarelo vivo claro/escuro, vermelho vivo vs rosa pastel, baseline índigo) e o seletor no menu mobile. Somente leitura: o accent é forçado via localStorage antes do load 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. --- e2e/appearance-qa.spec.ts | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 e2e/appearance-qa.spec.ts 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 }) +}) From 1d229af44bc1bc652a8a1fa11a8ec59b3c2244de Mon Sep 17 00:00:00 2001 From: johnlaff <46091881+johnlaff@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:23:22 -0300 Subject: [PATCH 7/8] fix(appearance): rotula o input hex e compacta a linha de preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O campo hexadecimal não tinha nome acessível (leitor de tela anunciava só "editar texto") e o preview da cor ficava sozinho numa linha própria. Preview e input dividem a mesma linha; o input ganha aria-label. --- src/components/accent-color-picker.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/accent-color-picker.tsx b/src/components/accent-color-picker.tsx index 685c6f8..5f12a19 100644 --- a/src/components/accent-color-picker.tsx +++ b/src/components/accent-color-picker.tsx @@ -92,7 +92,7 @@ export function AccentColorPicker({
-
+
A
-
- -
# @@ -117,6 +114,7 @@ export function AccentColorPicker({ value={draftHex.replace(/^#/, '')} onChange={(event) => commitCustomColor(event.target.value)} onBlur={() => setDraftHex(currentColor)} + aria-label="Código hexadecimal da cor" aria-invalid={draftHex.length > 0 && !normalizedDraft} spellCheck={false} maxLength={6} From bb2a8337b02a0f7426fe2f60223d17cba7f94a73 Mon Sep 17 00:00:00 2001 From: johnlaff <46091881+johnlaff@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:30:36 -0300 Subject: [PATCH 8/8] =?UTF-8?q?fix(appearance):=20preview=20do=20seletor?= =?UTF-8?q?=20segue=20o=20preset=20ativo=20e=20foco=20vis=C3=ADvel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com um preset ativo, a caixa "Personalizada" mostrava sempre o índigo default — parecia cor não salva. O seletor agora é semeado com a cor do preset ativo, virando ponto de partida da personalização. O foco de teclado nos sliders do react-colorful ganha anel visível; o único sinal era o pointer de 12px escalando ~1.1x. --- src/app/globals.css | 7 +++++++ src/components/accent-color-picker.tsx | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/globals.css b/src/app/globals.css index f4f105a..ba6ce86 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -906,3 +906,10 @@ html.dark[data-blueprint="true"] body { } .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/components/accent-color-picker.tsx b/src/components/accent-color-picker.tsx index 5f12a19..d38b718 100644 --- a/src/components/accent-color-picker.tsx +++ b/src/components/accent-color-picker.tsx @@ -30,7 +30,9 @@ export function AccentColorPicker({ onCustomColorChange, className, }: AccentColorPickerProps) { - const currentColor = getColorInputValue(customColor) + // 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.