Skip to content

fix: cor personalizada em OKLCH, seletor HSV, persistência serial e reveal híbrido#69

Merged
johnlaff merged 10 commits into
mainfrom
fix/appearance-color-system
Jul 24, 2026
Merged

fix: cor personalizada em OKLCH, seletor HSV, persistência serial e reveal híbrido#69
johnlaff merged 10 commits into
mainfrom
fix/appearance-color-system

Conversation

@johnlaff

Copy link
Copy Markdown
Owner

Corrige sete bugs de aparência que compartilhavam poucas causas-raiz: a matemática de cor do accent personalizado (mix sRGB com limiares de luminância), o seletor de cor (HSL sobre gradiente HSV), a persistência de preferências (PATCH por pointermove + perda offline) e o reveal de tema (animação CSS truncada no mobile).

O que muda

Tokens do accent personalizado derivados em OKLCH no CSS

--accent/--muted (e foregrounds) eram calculados em JS por mix sRGB com limiar de luminância. Cores claras e saturadas (amarelo, ciano) enganavam o limiar e as superfícies "suaves" viravam quase a cor pura: o track bg-muted da Distribuição parecia uma barra 100% cheia em projetos com 0%, mudava junto com a cor personalizada, e o matiz divergia do --primary (logo ≠ aba ativa da sidebar). Agora os tokens são derivados do hex direto no CSS via relative color — L/C fixos por papel, com paridade estrutural aos 14 presets e matiz idêntico ao do primary — com fallback color-mix em OKLCH para engines antigas. O JS ficou só com a decisão de contraste (foreground/borda), no provider e no script anti-flash. Prova WCAG varrida em custom-accent-tokens.test.ts (360 matizes × 7 chromas de origem), com sincronia teste↔CSS travada por leitura da folha.

Tinte de fundo acompanha as variantes do seletor

O croma do tinte era clamp(0, c, 0.012) — qualquer cor saturada batia no teto e o fundo "travava num tom", indiferente à posição do seletor. Agora é proporcional (calc(c * fator) com teto): vívido tinge mais, pastel menos, cinza nada. L fixo preserva WCAG AA nos pares texto/superfície em todo o círculo de matizes (background-tint.test.ts).

Seletor de cor: react-colorful

A área custom desenhava o gradiente do modelo HSV mas mapeava o ponteiro em HSL — no topo direito l=100 vira branco puro, e o roundtrip hex→HSL perdia o matiz em saturação zero (o seletor "travava"). O HexColorPicker do react-colorful (2,8 KB, sem dependências) mantém estado HSV interno com matiz preservado nos extremos, teclado e pointer capture nativos. Presets, preview e input hex seguem como estavam; o input hex ganhou nome acessível.

Persistência: fila serial com merge e retry offline

Cada ajuste disparava um PATCH imediato (o seletor emitia dezenas por arrasto) e respostas fora de ordem gravavam valor obsoleto; PATCH falho (offline) morria num toast e a hidratação seguinte restaurava o valor antigo do servidor por cima da escolha local. settings-sync.ts introduz fila serial com merge de patches (debounce 400 ms, nunca dois PATCHes em voo), pendência durável em localStorage com flush no boot e no evento online, retry com backoff (1s→5s→15s→60s) e toast único por sequência de falha. A hidratação remota é pulada enquanto houver pendência local. TDD: merge, serialização, falha de rede, boot e hidratação em settings-sync.test.ts.

Reveal de tema: clip estático no CSS + WAAPI no ready

A animação CSS no pseudo-elemento (fix anterior do flash mobile) começava a contar antes de a captura dos snapshots terminar no Chrome Android — os primeiros quadros caíam e o círculo nascia truncado; no desktop a mesma mudança alterou a textura da animação. O híbrido usa clip-path estático de raio 0 (vale desde o primeiro frame — sem flash) e cria o WAAPI em transition.ready (anima com snapshots prontos — sem jank). No desktop, onde o ready resolve em menos de um frame, isso restaura o comportamento anterior.

sequenceDiagram
    participant U as Toque no toggle
    participant VT as View Transition
    participant CSS as CSS estático
    participant W as WAAPI
    U->>VT: startViewTransition(aplicar tema)
    Note over CSS: ::view-transition-new nasce com<br/>clip-path circle(0) — sem flash
    VT-->>VT: captura de snapshots (1-2 frames no mobile)
    VT->>W: transition.ready
    W->>W: anima circle(0 → raio) 320ms<br/>emphasized decelerate, fill forwards
    W-->>VT: finished → pseudo-elementos somem
Loading

Nota de contraste (transparência)

O par muted-foreground sobre --muted fica em ~4,2–4,3:1 sob accent custom — a mesma margem do tema neutro atual (~4,17) e dos 14 presets. Este PR não regride esse par (o teste trava o piso e a classe real de regressão — muted virando cor viva, contraste ~2 — fica bloqueada); elevar o par a AA estrito é mudança sistêmica do design system, fora do escopo destes fixes. Os pares garantidos (texto principal e accent-foreground sobre as superfícies derivadas) passam AA com prova varrida.

Como verificar

  • npm test · npx tsc --noEmit · npx biome check src/ e2e/ · npx react-doctor@latest --no-telemetry · npm run build — todos verdes neste branch.
  • Visual: QA_SHOTS=1 npx playwright test --project=chromium appearance-qa.spec.ts captura o dashboard sob amarelo vivo (light/dark), vermelho vivo vs rosa pastel e o seletor no menu mobile.
  • Manual (recomendado no aparelho): npm run preview, definir cor personalizada amarela, conferir a Distribuição com projeto de 0%, alternar o tema observando o círculo do reveal.

johnlaff added 10 commits July 24, 2026 09:46
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.
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.
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.
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.
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.
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.
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.
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.
@johnlaff
johnlaff merged commit 4ec1f82 into main Jul 24, 2026
1 check passed
@johnlaff
johnlaff deleted the fix/appearance-color-system branch July 24, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant