From d51cae19ad60c57169e426fc0619f114ec66f802 Mon Sep 17 00:00:00 2001
From: johnlaff <46091881+johnlaff@users.noreply.github.com>
Date: Wed, 22 Jul 2026 14:24:51 -0300
Subject: [PATCH 1/2] fix(pwa): barra de status segue o tema e reveal de troca
mais fluido
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
No PWA a barra de status ficava verde (cor de destaque) em vez de acompanhar
o tema. O e o theme_color do manifest apontavam para
o accent; agora o meta reflete, em runtime, o fundo do tema resolvido (claro/
escuro, já tingido pelo accent/preset) e o manifest abre na cor do fundo escuro.
A cor de destaque continua identificando ícones e manifest.
O circular reveal parecia lento e hesitante: usava ease-in-out, que quase não
cresce nos primeiros quadros. Passa a emphasized decelerate (ease-out) e 320ms,
que abre rápido e assenta suave.
- theme-color.ts: fonte única do ; lê o fundo renderizado
(normaliza oklch via canvas 2D) e mantém sincronizado via ThemeColorSync
(observa tema, accent, preset e tinge do documentElement).
- browser-accent.ts deixa de tocar no theme-color (segue dono só de ícones e
do link do manifest).
---
e2e/theme-color.spec.ts | 42 +++++++++++++
src/app/layout.tsx | 20 +++---
src/app/manifest.json/route.test.ts | 19 ++++--
src/app/manifest.json/route.ts | 24 ++++---
src/components/providers.tsx | 2 +
src/components/theme-color-sync.tsx | 47 ++++++++++++++
src/hooks/use-theme-toggle.ts | 3 +-
.../__tests__/review-feedback-source.test.ts | 29 ++++++---
src/lib/__tests__/theme-color.test.ts | 39 ++++++++++++
src/lib/browser-accent.ts | 8 ---
src/lib/theme-color.ts | 63 +++++++++++++++++++
src/lib/theme-transition.ts | 9 ++-
12 files changed, 262 insertions(+), 43 deletions(-)
create mode 100644 e2e/theme-color.spec.ts
create mode 100644 src/components/theme-color-sync.tsx
create mode 100644 src/lib/__tests__/theme-color.test.ts
create mode 100644 src/lib/theme-color.ts
diff --git a/e2e/theme-color.spec.ts b/e2e/theme-color.spec.ts
new file mode 100644
index 0000000..b849b59
--- /dev/null
+++ b/e2e/theme-color.spec.ts
@@ -0,0 +1,42 @@
+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.
+test('a status bar (theme-color) segue o fundo do tema escuro, não o accent', async ({ page }) => {
+ 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, não o accent', async ({ page }) => {
+ 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..cabc3ce 100644
--- a/src/app/manifest.json/route.ts
+++ b/src/app/manifest.json/route.ts
@@ -1,18 +1,24 @@
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 e
+ * usam o fundo do tema escuro (o app abre escuro por padrão), não o accent — a status
+ * bar acompanha o app. Depois da abertura, o ThemeColorSync atualiza o em runtime para o fundo do tema efetivo (claro/escuro/tingido).
*/
export async function GET(req: NextRequest) {
const cookieStore = await cookies()
@@ -28,8 +34,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..baf85fc
--- /dev/null
+++ b/src/components/theme-color-sync.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import { useEffect } from 'react'
+import { useTheme } from 'next-themes'
+import { syncThemeColorMeta } from '@/lib/theme-color'
+
+/**
+ * Mantém o (status bar do PWA / barra do navegador) alinhado
+ * ao fundo do tema resolvido. Observa o documentElement porque o fundo muda por várias
+ * vias — classe .dark (tema), data-accent/data-preset (tinge o fundo) e data-bg-tint —
+ * e coalesce as mutações num requestAnimationFrame para ler o fundo já recalculado uma
+ * vez por frame, em vez de a cada atributo.
+ */
+export function ThemeColorSync() {
+ const { resolvedTheme } = useTheme()
+
+ // resolvedTheme muda ⇒ o tema já está aplicado no DOM (inclusive quando o SO alterna
+ // e o modo é "system"); sincroniza a status bar com o novo fundo.
+ useEffect(() => {
+ syncThemeColorMeta()
+ }, [resolvedTheme])
+
+ useEffect(() => {
+ const root = document.documentElement
+ let frame = 0
+ const schedule = () => {
+ if (frame) return
+ frame = requestAnimationFrame(() => {
+ frame = 0
+ syncThemeColorMeta()
+ })
+ }
+
+ const observer = new MutationObserver(schedule)
+ observer.observe(root, {
+ attributes: true,
+ attributeFilter: ['class', 'data-accent', 'data-preset', 'data-bg-tint', 'data-blueprint'],
+ })
+
+ return () => {
+ observer.disconnect()
+ if (frame) cancelAnimationFrame(frame)
+ }
+ }, [])
+
+ 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..3b6a471
--- /dev/null
+++ b/src/lib/theme-color.ts
@@ -0,0 +1,63 @@
+// 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).
+
+// oklch(0.145 0 0), o --background do tema escuro neutro. Serve de fallback e de
+// theme_color do manifest (a cor de abertura do PWA, antes do runtime assumir).
+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.
+ */
+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
+}
+
+export function syncThemeColorMeta(): void {
+ if (typeof document === 'undefined') return
+ const color = readResolvedBackgroundColor()
+
+ 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
+}
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
From bf12793b949fc57f353526cbadea11901857b7cc Mon Sep 17 00:00:00 2001
From: johnlaff <46091881+johnlaff@users.noreply.github.com>
Date: Wed, 22 Jul 2026 14:46:08 -0300
Subject: [PATCH 2/2] =?UTF-8?q?refactor(pwa):=20alinha=20o=20sync=20do=20t?=
=?UTF-8?q?heme-color=20ao=20idiom=C3=A1tico=20de=20React=2019?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ajustes após validar as decisões contra o padrão de 2026 (as escolhas centrais
— meta única em runtime, ler o fundo do DOM, canvas para oklch, View Transitions
com ease-out — já estavam alinhadas):
- ThemeColorSync passa a usar useSyncExternalStore, o hook próprio para ler uma
fonte mutável externa (o fundo do documentElement), com o MutationObserver como
subscribe e snapshot cacheado. Tearing-safe sob concurrent rendering; o guia do
React desaconselha o padrão anterior (useEffect + observer disparando o efeito).
- theme-color.ts separa writeThemeColorMeta de readResolvedBackgroundColor.
- Documenta o alcance por plataforma: em runtime a meta só é lida pelo Chromium;
a partir do Safari/iOS 26 o WebKit deriva a cor do fundo do no primeiro
paint (carimbo de frescor), então "não muda no iPhone" não é bug.
- Corrige comentários que afirmavam "o app abre escuro por padrão": com
defaultTheme="system", um usuário com SO claro abre claro. A cor fixa do
manifest é âncora de marca para a splash, com o flash inicial como limitação de
plataforma conhecida.
- e2e força prefers-color-scheme divergente do tema (emulateMedia), provando que
o runtime vence a media query do SO — o motivo de não usar meta media estática.
---
e2e/theme-color.spec.ts | 10 +++-
src/app/manifest.json/route.ts | 10 ++--
src/components/theme-color-sync.tsx | 85 +++++++++++++++++------------
src/lib/theme-color.ts | 28 ++++++++--
4 files changed, 89 insertions(+), 44 deletions(-)
diff --git a/e2e/theme-color.spec.ts b/e2e/theme-color.spec.ts
index b849b59..75551b5 100644
--- a/e2e/theme-color.spec.ts
+++ b/e2e/theme-color.spec.ts
@@ -23,7 +23,12 @@ async function readThemeColor(page: Page): Promise {
// 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.
-test('a status bar (theme-color) segue o fundo do tema escuro, não o accent', async ({ page }) => {
+//
+// 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 })
@@ -32,7 +37,8 @@ test('a status bar (theme-color) segue o fundo do tema escuro, não o accent', a
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, não o accent', async ({ page }) => {
+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 })
diff --git a/src/app/manifest.json/route.ts b/src/app/manifest.json/route.ts
index cabc3ce..28126b0 100644
--- a/src/app/manifest.json/route.ts
+++ b/src/app/manifest.json/route.ts
@@ -15,10 +15,12 @@ const ICON_PURPOSES = ['any', 'maskable'] as const
* 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 e
- * usam o fundo do tema escuro (o app abre escuro por padrão), não o accent — a status
- * bar acompanha o app. Depois da abertura, o ThemeColorSync atualiza o em runtime para o fundo do tema efetivo (claro/escuro/tingido).
+ * 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()
diff --git a/src/components/theme-color-sync.tsx b/src/components/theme-color-sync.tsx
index baf85fc..7a45b95 100644
--- a/src/components/theme-color-sync.tsx
+++ b/src/components/theme-color-sync.tsx
@@ -1,47 +1,64 @@
'use client'
-import { useEffect } from 'react'
-import { useTheme } from 'next-themes'
-import { syncThemeColorMeta } from '@/lib/theme-color'
+import { useEffect, useSyncExternalStore } from 'react'
+import { readResolvedBackgroundColor, THEME_COLOR_DARK, writeThemeColorMeta } from '@/lib/theme-color'
/**
- * Mantém o (status bar do PWA / barra do navegador) alinhado
- * ao fundo do tema resolvido. Observa o documentElement porque o fundo muda por várias
- * vias — classe .dark (tema), data-accent/data-preset (tinge o fundo) e data-bg-tint —
- * e coalesce as mutações num requestAnimationFrame para ler o fundo já recalculado uma
- * vez por frame, em vez de a cada atributo.
+ * 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.
*/
-export function ThemeColorSync() {
- const { resolvedTheme } = useTheme()
- // resolvedTheme muda ⇒ o tema já está aplicado no DOM (inclusive quando o SO alterna
- // e o modo é "system"); sincroniza a status bar com o novo fundo.
- useEffect(() => {
- syncThemeColorMeta()
- }, [resolvedTheme])
+const OBSERVED_ATTRIBUTES = ['class', 'data-accent', 'data-preset', 'data-bg-tint', 'data-blueprint']
- useEffect(() => {
- const root = document.documentElement
- let frame = 0
- const schedule = () => {
- if (frame) return
- frame = requestAnimationFrame(() => {
- frame = 0
- syncThemeColorMeta()
- })
- }
+let cachedColor: string | null = null
- const observer = new MutationObserver(schedule)
- observer.observe(root, {
- attributes: true,
- attributeFilter: ['class', 'data-accent', 'data-preset', 'data-bg-tint', 'data-blueprint'],
- })
+function subscribe(onStoreChange: () => void): () => void {
+ if (typeof document === 'undefined') return () => {}
- return () => {
- observer.disconnect()
- if (frame) cancelAnimationFrame(frame)
+ 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/lib/theme-color.ts b/src/lib/theme-color.ts
index 3b6a471..ad05bd9 100644
--- a/src/lib/theme-color.ts
+++ b/src/lib/theme-color.ts
@@ -4,9 +4,21 @@
// 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. Serve de fallback e de
-// theme_color do manifest (a cor de abertura do PWA, antes do runtime assumir).
+// 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 {
@@ -27,6 +39,10 @@ function parseRgb(color: string): string | null {
* 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 {
@@ -49,9 +65,9 @@ export function readResolvedBackgroundColor(): string {
return parseRgb(background) ?? normalizeViaCanvas(background) ?? THEME_COLOR_DARK
}
-export function syncThemeColorMeta(): void {
+/** Escreve a cor no (cria uma vez, reusa o mesmo nó). */
+export function writeThemeColorMeta(color: string): void {
if (typeof document === 'undefined') return
- const color = readResolvedBackgroundColor()
let meta = document.head.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null
if (!meta) {
@@ -61,3 +77,7 @@ export function syncThemeColorMeta(): void {
}
if (meta.content !== color) meta.content = color
}
+
+export function syncThemeColorMeta(): void {
+ writeThemeColorMeta(readResolvedBackgroundColor())
+}