diff --git a/e2e/theme-color.spec.ts b/e2e/theme-color.spec.ts new file mode 100644 index 0000000..75551b5 --- /dev/null +++ b/e2e/theme-color.spec.ts @@ -0,0 +1,48 @@ +import { test, expect, type Page } from '@playwright/test' +import { applyAppearance } from './helpers/appearance' + +/** Luminância relativa (WCAG) de um hex #rrggbb. */ +function luminance(hex: string): number { + const channel = (start: number) => { + const v = parseInt(hex.slice(start, start + 2), 16) / 255 + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5) +} + +async function readThemeColor(page: Page): Promise { + return expect + .poll( + () => page.locator('meta[name="theme-color"]').getAttribute('content'), + { message: 'ThemeColorSync deve criar o no runtime', timeout: 15_000 } + ) + .toMatch(/^#[0-9a-f]{6}$/i) + .then(async () => (await page.locator('meta[name="theme-color"]').getAttribute('content')) ?? '') +} + +// O accent rosa saturado (#ec4899) tem luminância ~0.30. Se a status bar seguisse o +// accent (o bug), ela cairia nessa faixa média em ambos os temas. Seguindo o fundo, +// ela é escura no dark e clara no light — bem fora dessa faixa. +// +// emulateMedia força o prefers-color-scheme do SO a DIVERGIR do tema escolhido: é o +// cenário que descarta estático (que segue o SO) +// e exige a meta única sincronizada em runtime pelo tema resolvido do app. +test('a status bar (theme-color) segue o fundo do tema escuro mesmo com SO claro', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }) + await applyAppearance(page, { dark: true, pink: true }) + await page.goto('/dashboard') + await expect(page.getByRole('heading', { name: 'Ponto' })).toBeVisible({ timeout: 30_000 }) + + const color = await readThemeColor(page) + expect(luminance(color), `theme-color ${color} deveria ser um fundo escuro`).toBeLessThan(0.05) +}) + +test('a status bar (theme-color) segue o fundo do tema claro mesmo com SO escuro', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }) + await applyAppearance(page, { dark: false, pink: true }) + await page.goto('/dashboard') + await expect(page.getByRole('heading', { name: 'Ponto' })).toBeVisible({ timeout: 30_000 }) + + const color = await readThemeColor(page) + expect(luminance(color), `theme-color ${color} deveria ser um fundo claro`).toBeGreaterThan(0.85) +}) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6e1e8ff..b643f96 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,14 +23,16 @@ export const metadata: Metadata = { title: 'ArchTime', description: 'Time tracking para freelancers e profissionais independentes', appleWebApp: { capable: true, statusBarStyle: 'default', title: 'ArchTime' }, - // Favicon / apple-touch-icon / theme-color / manifest are owned at runtime by - // browser-accent.ts so they track the user's accent color (the manifest link - // carries `?color=` because the browser fetches it without credentials). They are - // deliberately NOT declared here (metadata) nor in `viewport`: if React renders & - // owns these nodes and browser-accent removes/replaces them, React's - // reconciliation on every client navigation throws "Cannot read properties of - // null (reading 'removeChild')" and the page swap freezes (URL changes, UI does - // not). Keeping a single owner (browser-accent) eliminates that conflict. + // Favicon / apple-touch-icon / manifest are owned at runtime by browser-accent.ts + // so they track the user's accent color (the manifest link carries `?color=` + // because the browser fetches it without credentials); the theme-color is + // owned by theme-color.ts (ThemeColorSync) so the status bar tracks the resolved + // theme background. All are created imperatively and deliberately NOT declared here + // (metadata) nor in `viewport`: if React renders & owns these nodes and the + // runtime removes/replaces them, React's reconciliation on every client + // navigation throws "Cannot read properties of null (reading 'removeChild')" and + // the page swap freezes (URL changes, UI does not). Keeping the runtime as the sole + // owner eliminates that conflict. openGraph: { title: 'ArchTime', description: 'Time tracking para freelancers e profissionais independentes', @@ -47,7 +49,7 @@ export const metadata: Metadata = { }, } -// themeColor is set at runtime by browser-accent.ts (see metadata note above). +// themeColor is set at runtime by theme-color.ts / ThemeColorSync (see metadata note above). export const viewport: Viewport = {} export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/src/app/manifest.json/route.test.ts b/src/app/manifest.json/route.test.ts index 39a838f..f208eb1 100644 --- a/src/app/manifest.json/route.test.ts +++ b/src/app/manifest.json/route.test.ts @@ -27,33 +27,40 @@ describe('/manifest.json', () => { withCookie(null) }) - it('prioriza a cor do query param sobre o cookie', async () => { + it('faz os ícones seguirem o accent do query param sobre o cookie', async () => { withCookie('#2d7a4f') const response = await GET(request('?color=%23f43f5e')) const body = await response.json() - expect(body.theme_color).toBe('#f43f5e') expect(body.icons).toHaveLength(4) for (const icon of body.icons) { expect(icon.src).toContain('color=%23f43f5e') } }) - it('cai para o cookie de accent quando não há query param', async () => { + it('cai para o cookie de accent nos ícones quando não há query param', async () => { withCookie('#2d7a4f') const body = await (await GET(request())).json() - expect(body.theme_color).toBe('#2d7a4f') expect(body.icons[0].src).toContain('color=%232d7a4f') }) - it('usa o indigo padrão sem query param nem cookie (e ignora cor inválida)', async () => { + it('usa o indigo padrão nos ícones sem query param nem cookie (e ignora cor inválida)', async () => { const body = await (await GET(request('?color=vermelho'))).json() - expect(body.theme_color).toBe('#6366f1') expect(body.icons[0].src).toContain('color=%236366f1') }) + it('mantém a status bar (theme_color/background) no fundo do tema, independente do accent', async () => { + // A cor de abertura do PWA acompanha o app (fundo escuro), não o accent — senão + // a status bar abriria verde sobre um app escuro. O runtime (ThemeColorSync) + // ajusta depois para o fundo do tema efetivo. + const body = await (await GET(request('?color=%23f43f5e'))).json() + + expect(body.theme_color).toBe('#0a0a0a') + expect(body.background_color).toBe('#0a0a0a') + }) + it('mantém os campos de instalação e headers sem cache', async () => { const response = await GET(request()) const body = await response.json() diff --git a/src/app/manifest.json/route.ts b/src/app/manifest.json/route.ts index 68ef3ba..28126b0 100644 --- a/src/app/manifest.json/route.ts +++ b/src/app/manifest.json/route.ts @@ -1,18 +1,26 @@ import { NextRequest, NextResponse } from 'next/server' import { cookies } from 'next/headers' import { getBrowserAccentIconUrl, normalizeHexColor } from '@/lib/custom-color' +import { THEME_COLOR_DARK } from '@/lib/theme-color' const ICON_SIZES = [192, 512] as const const ICON_PURPOSES = ['any', 'maskable'] as const /** - * Manifest dinâmico: os ícones e o theme_color seguem o accent do usuário. A cor - * chega por query param (`?color=`), escrita pelo browser-accent.ts no href do - * — o fetch do manifest é feito sem credenciais pelo browser, - * então cookie sozinho não funcionaria. O cookie fica como fallback para acessos - * diretos. O manifest era um arquivo estático em public/ e acabava pré-cacheado - * pelo service worker com ícone indigo fixo; como rota dinâmica ele fica fora do - * precache e reflete a cor atual em instalações e atualizações do app. + * Manifest dinâmico: os ícones seguem o accent do usuário. A cor chega por query + * param (`?color=`), escrita pelo browser-accent.ts no href do + * — o fetch do manifest é feito sem credenciais pelo browser, então cookie sozinho + * não funcionaria. O cookie fica como fallback para acessos diretos. O manifest era + * um arquivo estático em public/ e acabava pré-cacheado pelo service worker com ícone + * indigo fixo; como rota dinâmica ele fica fora do precache e reflete a cor atual em + * instalações e atualizações do app. + * + * theme_color/background_color pintam a status bar e o splash na abertura do PWA. O + * manifest não varia por esquema em nenhum browser, então usam uma cor única e fixa — + * o fundo escuro, como âncora de marca (não o accent). Depois da abertura, o + * ThemeColorSync atualiza o em runtime para o fundo do tema + * efetivo (claro/escuro/tingido); um usuário com SO claro e sem preferência salva vê a + * splash escura por um instante antes dessa correção — limitação de plataforma, não bug. */ export async function GET(req: NextRequest) { const cookieStore = await cookies() @@ -28,8 +36,8 @@ export async function GET(req: NextRequest) { description: 'Time tracking para freelancers e profissionais independentes', start_url: '/dashboard', display: 'standalone', - background_color: '#ffffff', - theme_color: color, + background_color: THEME_COLOR_DARK, + theme_color: THEME_COLOR_DARK, orientation: 'portrait', icons: ICON_SIZES.flatMap((size) => ICON_PURPOSES.map((purpose) => ({ diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 8425a34..83e3fd7 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -11,6 +11,7 @@ import { usePerfMonitor } from '@/hooks/use-perf-monitor' import { useThemeToggle } from '@/hooks/use-theme-toggle' import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts' import { CommandPalette } from '@/components/command-palette' +import { ThemeColorSync } from '@/components/theme-color-sync' import { getLastLocalPreferenceChange, hasLocalCustomAccentPreference, @@ -76,6 +77,7 @@ export function Providers({ children }: { children: React.ReactNode }) { + {children} diff --git a/src/components/theme-color-sync.tsx b/src/components/theme-color-sync.tsx new file mode 100644 index 0000000..7a45b95 --- /dev/null +++ b/src/components/theme-color-sync.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useEffect, useSyncExternalStore } from 'react' +import { readResolvedBackgroundColor, THEME_COLOR_DARK, writeThemeColorMeta } from '@/lib/theme-color' + +/** + * O fundo do tema é uma fonte mutável FORA do React: muda por várias vias — classe + * .dark (next-themes), data-accent/data-preset (tinge o fundo) e data-bg-tint — e + * alguns desses atributos são escritos por um script anti-flash pré-hidratação e por + * estado de página não compartilhado. useSyncExternalStore é o hook idiomático para + * ler uma fonte externa dessas (tearing-safe sob concurrent rendering), com o + * MutationObserver como `subscribe`. O snapshot é cacheado porque ler o fundo + * (getComputedStyle + canvas) não deve rodar a cada render — só quando o observer + * dispara, coalescido num requestAnimationFrame para ler o fundo já recalculado. + */ + +const OBSERVED_ATTRIBUTES = ['class', 'data-accent', 'data-preset', 'data-bg-tint', 'data-blueprint'] + +let cachedColor: string | null = null + +function subscribe(onStoreChange: () => void): () => void { + if (typeof document === 'undefined') return () => {} + + const root = document.documentElement + let frame = 0 + const recompute = () => { + frame = 0 + const next = readResolvedBackgroundColor() + if (next !== cachedColor) { + cachedColor = next + onStoreChange() + } + } + const schedule = () => { + if (!frame) frame = requestAnimationFrame(recompute) + } + + cachedColor = readResolvedBackgroundColor() + const observer = new MutationObserver(schedule) + observer.observe(root, { attributes: true, attributeFilter: OBSERVED_ATTRIBUTES }) + + return () => { + observer.disconnect() + if (frame) cancelAnimationFrame(frame) + } +} + +function getSnapshot(): string { + return cachedColor ?? readResolvedBackgroundColor() +} + +function getServerSnapshot(): string { + return THEME_COLOR_DARK +} + +export function ThemeColorSync() { + const color = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + + useEffect(() => { + writeThemeColorMeta(color) + }, [color]) + + return null +} diff --git a/src/hooks/use-theme-toggle.ts b/src/hooks/use-theme-toggle.ts index a121272..9bc755c 100644 --- a/src/hooks/use-theme-toggle.ts +++ b/src/hooks/use-theme-toggle.ts @@ -19,6 +19,7 @@ import { setThemeRevealGeometry, startThemeViewTransition, THEME_REVEAL_DURATION_MS, + THEME_REVEAL_EASING, THEME_SWITCH_SUPPRESSION_MS, } from '@/lib/theme-transition' @@ -78,7 +79,7 @@ export function useThemeToggle(): (e?: MouseEvent) => void { }, { duration: THEME_REVEAL_DURATION_MS, - easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + easing: THEME_REVEAL_EASING, fill: 'both', pseudoElement: '::view-transition-new(root)', } diff --git a/src/lib/__tests__/review-feedback-source.test.ts b/src/lib/__tests__/review-feedback-source.test.ts index f492eaa..b2185c4 100644 --- a/src/lib/__tests__/review-feedback-source.test.ts +++ b/src/lib/__tests__/review-feedback-source.test.ts @@ -66,7 +66,6 @@ describe('review feedback regressions', () => { expect(source).toContain('document.head.appendChild(icon)') expect(source).toContain('clearBrowserAccentTimers') expect(source).toContain('activeBrowserAccentColor === color') - expect(source).toContain("querySelector('meta[name=\"theme-color\"]')") // Sem `color` explícito a resposta depende do cookie de accent → não pode ser // cacheada; com `color` na URL ela é determinística e pode. expect(iconRoute).toContain("requestedColor ? 'public, max-age=86400' : 'no-store, max-age=0'") @@ -106,14 +105,15 @@ describe('review feedback regressions', () => { expect(existsSync(join(process.cwd(), 'src/hooks/use-route-prefetch.ts'))).toBe(false) }) - it('keeps icon/theme-color out of React metadata so browser-accent owns them (removeChild freeze regression)', () => { - // browser-accent.ts manages , apple-touch-icon and the - // theme-color imperatively at runtime (to track the accent color). - // If layout.tsx ALSO declares them via `metadata.icons` / `viewport.themeColor`, - // React 19 owns those same nodes; when browser-accent removes them React's - // reconciliation on the next client navigation throws "Cannot read - // properties of null (reading 'removeChild')" and the page swap freezes - // (URL changes, UI does not). Keep a single owner — do not re-add these here. + it('keeps icon/theme-color out of React metadata so the runtime owns them (removeChild freeze regression)', () => { + // browser-accent.ts manages , apple-touch-icon and the manifest + // link imperatively at runtime (accent-driven); theme-color.ts manages the + // theme-color (theme-background-driven). If layout.tsx ALSO declares them + // via `metadata.icons` / `viewport.themeColor`, React 19 owns those same + // nodes; when the runtime removes them React's reconciliation on the next + // client navigation throws "Cannot read properties of null (reading 'removeChild')" + // and the page swap freezes (URL changes, UI does not). Keep a single owner — do + // not re-add these here. const layout = readSource('src/app/layout.tsx') expect(layout).not.toMatch(/^\s*icons\s*:/m) @@ -122,6 +122,17 @@ describe('review feedback regressions', () => { expect(layout).not.toMatch(/^\s*manifest\s*:/m) }) + it('splits status-bar ownership from accent: theme-color follows the theme background, not the accent', () => { + const browserAccent = readSource('src/lib/browser-accent.ts') + const themeColor = readSource('src/lib/theme-color.ts') + + // browser-accent deixou de tocar no theme-color (agora é do theme-color.ts) — + // senão a status bar voltaria a seguir o accent (verde sobre app escuro). + expect(browserAccent).not.toContain('theme-color') + expect(themeColor).toContain("querySelector('meta[name=\"theme-color\"]')") + expect(themeColor).toContain('getComputedStyle(document.body).backgroundColor') + }) + it('plays animations regardless of the OS reduced-motion setting (deliberate product decision)', () => { const providers = readSource('src/components/providers.tsx') expect(providers).toContain('reducedMotion="never"') diff --git a/src/lib/__tests__/theme-color.test.ts b/src/lib/__tests__/theme-color.test.ts new file mode 100644 index 0000000..ad8c1e7 --- /dev/null +++ b/src/lib/__tests__/theme-color.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + readResolvedBackgroundColor, + syncThemeColorMeta, + THEME_COLOR_DARK, +} from '../theme-color' + +afterEach(() => { + document.head.querySelectorAll('meta[name="theme-color"]').forEach((meta) => meta.remove()) + document.body.removeAttribute('style') +}) + +describe('theme-color', () => { + it('converte o fundo renderizado (rgb) para hex', () => { + document.body.style.backgroundColor = 'rgb(20, 30, 40)' + expect(readResolvedBackgroundColor()).toBe('#141e28') + }) + + it('cai para o fundo escuro quando não há cor computada', () => { + expect(readResolvedBackgroundColor()).toBe(THEME_COLOR_DARK) + }) + + it('cria um único e o mantém alinhado ao fundo', () => { + document.body.style.backgroundColor = 'rgb(10, 10, 10)' + syncThemeColorMeta() + + const metas = document.head.querySelectorAll('meta[name="theme-color"]') + expect(metas).toHaveLength(1) + expect((metas[0] as HTMLMetaElement).content).toBe('#0a0a0a') + + document.body.style.backgroundColor = 'rgb(255, 255, 255)' + syncThemeColorMeta() + + expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1) + expect( + (document.head.querySelector('meta[name="theme-color"]') as HTMLMetaElement).content + ).toBe('#ffffff') + }) +}) diff --git a/src/lib/browser-accent.ts b/src/lib/browser-accent.ts index 8ed4b8b..3092f98 100644 --- a/src/lib/browser-accent.ts +++ b/src/lib/browser-accent.ts @@ -81,14 +81,6 @@ function updateBrowserAccentLinks(color: string) { document.head.appendChild(manifest) } manifest.href = `/manifest.json?color=${encodeURIComponent(color)}` - - let themeColor = document.head.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null - if (!themeColor) { - themeColor = document.createElement('meta') - themeColor.name = 'theme-color' - document.head.appendChild(themeColor) - } - themeColor.content = color } export function syncBrowserAccentColor(hex: string) { diff --git a/src/lib/theme-color.ts b/src/lib/theme-color.ts new file mode 100644 index 0000000..ad05bd9 --- /dev/null +++ b/src/lib/theme-color.ts @@ -0,0 +1,83 @@ +// A status bar do sistema (num PWA standalone) e a barra do navegador são pintadas +// pelo . Ela deve acompanhar o FUNDO do tema resolvido — +// claro ou escuro, já tingido pelo accent/preset — e não a cor de destaque: uma +// status bar verde sobre um app escuro destoa. O accent segue identificando ícones +// e o manifest; a status bar segue o app. Ver [[browser-accent]] (dono dos ícones) +// e o componente ThemeColorSync (dono do runtime). +// +// Alcance por plataforma (verificado 2026-07): quem lê o +// em runtime é o Chromium (Chrome/Edge/Samsung Internet, PWA/aba Android) — é aí que +// este módulo pinta a barra. A partir do Safari/iOS 26 o WebKit ignora a meta e deriva +// a cor da barra do background-color do amostrado no primeiro paint; como o +// já carrega o fundo do tema via CSS, a barra fica correta lá sem esta meta. +// Um PWA instalado no iOS nunca aceitou cor livre (só apple-mobile-web-app-status-bar- +// style). Logo, "o toggle não muda a cor no iPhone" é comportamento de plataforma, +// não bug deste módulo. + +// oklch(0.145 0 0), o --background do tema escuro neutro. Fallback e âncora de marca +// para o theme_color/background_color do manifest — a cor da splash nativa e da barra +// no instante de abertura do PWA, ANTES de haver DOM/JS para o runtime assumir. O +// manifest não varia por esquema em nenhum browser (a spec color_scheme_dark, de +// 2026-04, ainda não tem implementação), então essa é uma cor única e fixa por design. +export const THEME_COLOR_DARK = '#0a0a0a' + +function channelToHex(value: number): string { + return Math.max(0, Math.min(255, Math.round(value))) + .toString(16) + .padStart(2, '0') +} + +/** Caminho rápido para rgb()/rgba() (o formato que a maioria dos ambientes serializa). */ +function parseRgb(color: string): string | null { + const match = color.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)/) + if (!match) return null + return `#${channelToHex(Number(match[1]))}${channelToHex(Number(match[2]))}${channelToHex(Number(match[3]))}` +} + +/** + * O Chromium serializa uma cor definida em oklch como `oklch(...)` (não `rgb(...)`), + * e o não aceita oklch de forma confiável. O canvas 2D usa + * o mesmo parser de cor do browser, então normaliza qualquer espaço (oklch/oklab/ + * color()) para os canais sRGB reais. Indisponível fora do browser ⇒ null. + * + * O '#000' de base detecta cor inválida (fillStyle a ignora e mantém o preto) e + * pressupõe que --background é opaco — verdade por design (os tokens de fundo não têm + * alfa); um fundo translúcido comporia sobre esse preto e mudaria o resultado. + */ +function normalizeViaCanvas(color: string): string | null { + try { + const context = document.createElement('canvas').getContext('2d') + if (!context) return null + context.fillStyle = '#000' + context.fillStyle = color // valor inválido é ignorado e mantém o '#000' anterior + context.fillRect(0, 0, 1, 1) + const [r, g, b] = context.getImageData(0, 0, 1, 1).data + return `#${channelToHex(r)}${channelToHex(g)}${channelToHex(b)}` + } catch { + return null + } +} + +/** Lê o fundo realmente renderizado (resolve oklch, tema, preset e o tinge). */ +export function readResolvedBackgroundColor(): string { + if (typeof document === 'undefined' || !document.body) return THEME_COLOR_DARK + const background = getComputedStyle(document.body).backgroundColor + return parseRgb(background) ?? normalizeViaCanvas(background) ?? THEME_COLOR_DARK +} + +/** Escreve a cor no (cria uma vez, reusa o mesmo nó). */ +export function writeThemeColorMeta(color: string): void { + if (typeof document === 'undefined') return + + let meta = document.head.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null + if (!meta) { + meta = document.createElement('meta') + meta.name = 'theme-color' + document.head.appendChild(meta) + } + if (meta.content !== color) meta.content = color +} + +export function syncThemeColorMeta(): void { + writeThemeColorMeta(readResolvedBackgroundColor()) +} diff --git a/src/lib/theme-transition.ts b/src/lib/theme-transition.ts index ee053ba..1ec19a9 100644 --- a/src/lib/theme-transition.ts +++ b/src/lib/theme-transition.ts @@ -1,7 +1,14 @@ import type { ThemeMode } from '@/lib/preferences' export const THEME_SWITCH_SUPPRESSION_MS = 180 -export const THEME_REVEAL_DURATION_MS = 420 +export const THEME_REVEAL_DURATION_MS = 320 + +// O círculo parte do raio 0: a curva tem de acelerar de imediato e desacelerar no +// fim (decelerate / ease-out). Um ease-in-out faria o reveal "hesitar" no começo — +// nada cresce nos primeiros quadros — e passaria a sensação de lentidão/travamento. +// cubic-bezier(0.05, 0.7, 0.1, 1) é o "emphasized decelerate" (Material 3): abre +// rápido e assenta suave. +export const THEME_REVEAL_EASING = 'cubic-bezier(0.05, 0.7, 0.1, 1)' interface ViewportSize { width: number