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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions e2e/theme-color.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return expect
.poll(
() => page.locator('meta[name="theme-color"]').getAttribute('content'),
{ message: 'ThemeColorSync deve criar o <meta name="theme-color"> 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 <meta media="(prefers-color-scheme)"> 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)
})
20 changes: 11 additions & 9 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <head> nodes and browser-accent removes/replaces them, React's <head>
// 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 <meta> 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 <head> nodes and the
// runtime removes/replaces them, React's <head> 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',
Expand All @@ -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 }) {
Expand Down
19 changes: 13 additions & 6 deletions src/app/manifest.json/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
26 changes: 17 additions & 9 deletions src/app/manifest.json/route.ts
Original file line number Diff line number Diff line change
@@ -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
* <link rel="manifest"> — 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 <link rel="manifest">
* — 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 <meta name="theme-color"> 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()
Expand All @@ -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) => ({
Expand Down
2 changes: 2 additions & 0 deletions src/components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -76,6 +77,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
<MotionConfig reducedMotion="never">
<SyncProvider>
<PreferencesHydrator />
<ThemeColorSync />
{children}
<CommandPalette />
<Toaster richColors position="bottom-center" closeButton />
Expand Down
64 changes: 64 additions & 0 deletions src/components/theme-color-sync.tsx
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion src/hooks/use-theme-toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
setThemeRevealGeometry,
startThemeViewTransition,
THEME_REVEAL_DURATION_MS,
THEME_REVEAL_EASING,
THEME_SWITCH_SUPPRESSION_MS,
} from '@/lib/theme-transition'

Expand Down Expand Up @@ -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)',
}
Expand Down
29 changes: 20 additions & 9 deletions src/lib/__tests__/review-feedback-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'")
Expand Down Expand Up @@ -106,14 +105,15 @@ describe('review feedback regressions', () => {
expect(existsSync(join(process.cwd(), 'src/hooks/use-route-prefetch.ts'))).toBe(false)
})

it('keeps <head> icon/theme-color out of React metadata so browser-accent owns them (removeChild freeze regression)', () => {
// browser-accent.ts manages <link rel="icon">, apple-touch-icon and the
// theme-color <meta> imperatively at runtime (to track the accent color).
// If layout.tsx ALSO declares them via `metadata.icons` / `viewport.themeColor`,
// React 19 owns those same <head> nodes; when browser-accent removes them React's
// <head> 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 <head> icon/theme-color out of React metadata so the runtime owns them (removeChild freeze regression)', () => {
// browser-accent.ts manages <link rel="icon">, apple-touch-icon and the manifest
// link imperatively at runtime (accent-driven); theme-color.ts manages the
// theme-color <meta> (theme-background-driven). If layout.tsx ALSO declares them
// via `metadata.icons` / `viewport.themeColor`, React 19 owns those same <head>
// nodes; when the runtime removes them React's <head> 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)
Expand All @@ -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"')
Expand Down
39 changes: 39 additions & 0 deletions src/lib/__tests__/theme-color.test.ts
Original file line number Diff line number Diff line change
@@ -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 <meta name="theme-color"> 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')
})
})
8 changes: 0 additions & 8 deletions src/lib/browser-accent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading