fix: cor personalizada em OKLCH, seletor HSV, persistência serial e reveal híbrido#69
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 trackbg-mutedda 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 fallbackcolor-mixem 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 emcustom-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=100vira branco puro, e o roundtrip hex→HSL perdia o matiz em saturação zero (o seletor "travava"). OHexColorPickerdo 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.tsintroduz 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 eventoonline, 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 emsettings-sync.test.ts.Reveal de tema: clip estático no CSS + WAAPI no
readyA 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 oreadyresolve 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 somemNota de contraste (transparência)
O par
muted-foregroundsobre--mutedfica 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.QA_SHOTS=1 npx playwright test --project=chromium appearance-qa.spec.tscaptura o dashboard sob amarelo vivo (light/dark), vermelho vivo vs rosa pastel e o seletor no menu mobile.npm run preview, definir cor personalizada amarela, conferir a Distribuição com projeto de 0%, alternar o tema observando o círculo do reveal.