Como o Impeccable e o GitHub Copilot deram movimento e tema escuro ao meu dashboard Vue
O Impeccable é uma skill de design para agentes de código, e roda dentro do GitHub Copilot no VS Code em agent mode. No artigo: "Como usei o Impeccable com GitHub Copilot para melhorar o UI/UX", mostrei como a skill é organizada e usei o comando critique para avaliar o CarStore. Agora o foco é outro: como o Impeccable usa o contexto do projeto para tomar decisões de design. Para isso, aplico dois comandos de refinamento, /impeccable animate e /impeccable colorize, no dashboard do CarStore, uma SPA em Vue 3 com PrimeVue e Tailwind CSS servida por um back-end .NET 10 que segue os guias de arquitetura .NET.
O que torna o resultado diferente de um pedido genérico ao GitHub Copilot são dois arquivos na raiz do repositório. O PRODUCT.md descreve o produto, os usuários e as restrições. O DESIGN.md registra o sistema visual: a escala de superfícies slate, a regra de um único acento de ação e o princípio de separar superfícies com bordas, não com sombras. Antes de qualquer comando, o Impeccable executa o script de contexto, que carrega esses dois arquivos e entrega ao agente as diretrizes da tela em questão.
.github/skills/impeccable/scripts/impeccable contextCom esse contexto, o agente classifica a tela pelo que o usuário precisa fazer nela. O dashboard do CarStore cai no modo que o Impeccable chama de Operate, o de ferramentas de trabalho, em que leitura rápida e consistência vêm antes de efeitos chamativos. Antes de editar a interface, o agente também lê o craft floor da skill. É uma lista de checagens de contraste, estados, foco e movimento, mais padrões que ele deve evitar, como texto com gradiente e sombras duras sem desfoque. Foi esse conjunto que guiou cada decisão a seguir.
O primeiro pedido no GitHub Copilot foi para a camada de movimento, descrevendo o que se esperava em linguagem natural.
/impeccable animate Contagem dos KPIs, barras, transição de rota e Loader com o carrinho, revelação dos textos, efeitos no CRUD
Antes de editar qualquer arquivo, o Impeccable escreve uma tese de movimento. Ela define o momento principal da tela, o que é continuidade entre estados e o que é feedback de ação. Aqui, o momento principal é a abertura do dashboard: os dados chegam da API e a tela se acomoda em vez de saltar. Para os números dos KPIs, o agente criou um composable que interpola o valor exibido até o valor real.
import { onBeforeUnmount, ref, watch, type Ref } from "vue";
const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));
export function useTweenedNumber(source: Ref<number>, duration = 600, delay = 0) {
const display = ref(source.value);
let frame = 0;
let timer = 0;
watch(source, (target) => {
cancelAnimationFrame(frame);
clearTimeout(timer);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
display.value = target;
return;
}
timer = window.setTimeout(() => {
const from = display.value;
const start = performance.now();
const step = (now: number) => {
const t = Math.min((now - start) / duration, 1);
display.value = from + (target - from) * easeOutExpo(t);
if (t < 1) frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
}, delay);
});
onBeforeUnmount(() => {
cancelAnimationFrame(frame);
clearTimeout(timer);
});
return display;
}
useTweenedNumber()
Observa uma referência numérica e, a cada mudança, anima o valor exibido até o novo alvo com uma curva exponencial, respeitando um atraso opcional.
easeOutExpo()
Desacelera no fim do movimento, o que faz o número parecer se acomodar no valor final.
A checagem de prefers-reduced-motion aplica o valor na hora para quem pediu menos movimento no sistema. Se o valor muda no meio de uma animação, a nova parte do número que está na tela, sem pular de volta para zero.
A contagem só faz sentido se começar quando o usuário pode vê-la. Por isso o agente criou uma tela de carregamento que acompanha as quatro requisições do Home. O carrinho animado do logo da sidebar anda sobre uma barra de porcentagem, e os KPIs, as barras e o gráfico esperam ela sair para começar.
const showLoader = ref(!introPlayed);
const ready = ref(introPlayed);
const loadsDone = ref(0);
const startupLoads = 4;
const loaderProgress = computed(() => (loadsDone.value / startupLoads) * 100);
function track(request: Promise<void>) {
return request.finally(() => loadsDone.value++);
}
onMounted(async () => {
const minimumShow = new Promise((resolve) => setTimeout(resolve, showLoader.value ? 1100 : 0));
await Promise.all([
track(loadCars(1, 100)),
track(loadBrands(1, 100)),
track(loadCustomersForActivity(1, 100)),
track(loadRecentCustomers(1, recentRows.value)),
minimumShow,
]);
if (!showLoader.value) return;
// Let the bar visibly land on 100% before the car drives off.
await new Promise((resolve) => setTimeout(resolve, 500));
introPlayed = true;
showLoader.value = false;
});
track()
Envolve cada requisição e incrementa o contador quando ela termina, com sucesso ou erro, o que alimenta a porcentagem da barra.
onMounted()
Dispara as quatro cargas em paralelo, garante um tempo mínimo de exibição e deixa a barra parada em 100% por meio segundo antes de liberar a revelação.
A variável introPlayed fica no escopo do módulo, fora do componente. Por isso o loader aparece só na primeira visita da sessão, e as próximas navegações para o Home vão direto para a revelação.
Imagem: Tela de carregamento com o carrinho do logo sobre a barra de progresso em 100%
Quando o loader sai, a classe home-ready entra no container da página e dispara a revelação. O título "Fleet command" sobe palavra por palavra de dentro de uma máscara, e o subtítulo, os cards e os títulos das seções entram em seguida, em ordem de leitura. Os quatro KPIs contam de zero até o valor em 1,8 segundo, um card após o outro.
const countDuration = 1800;
const gated = (value: () => number) => computed(() => (ready.value ? value() : 0));
const fleetValueShown = useTweenedNumber(gated(() => fleetValue.value), countDuration, 200);
const carsInStockShown = useTweenedNumber(gated(() => carsInStock.value), countDuration, 300);
const recentTotalShown = useTweenedNumber(gated(() => recentTotal.value), countDuration, 400);
const availabilityRateShown = useTweenedNumber(gated(() => availabilityRate.value), countDuration, 500);
gated()
Devolve zero enquanto a página não foi revelada e o valor real depois, de modo que a contagem começa exatamente quando o loader termina.
Imagem: Dashboard no meio da revelação, com o valor da frota ainda subindo até o total
Os efeitos nas operações de CRUD resolveram um problema que os toasts sozinhos não resolvem: mostrar qual linha mudou. O agente criou um pequeno registro reativo de efeitos por linha, que os composables de criação, edição e exclusão alimentam e que a tabela converte em classes CSS.
const effects = reactive(new Map<string, RowEffect>());
const key = (scope: string, id: unknown) => `${scope}:${id}`;
export function flagRows(scope: string, ids: unknown[], effect: RowEffect, duration: number) {
for (const id of ids) effects.set(key(scope, id), effect);
setTimeout(() => {
for (const id of ids) if (effects.get(key(scope, id)) === effect) effects.delete(key(scope, id));
}, duration);
}
export function rowEffectClass(scope: string | undefined, id: unknown): string | undefined {
if (!scope) return undefined;
const effect = effects.get(key(scope, id));
return effect ? `row-fx-${effect}` : undefined;
}
export function playRowExit(scope: string, ids: unknown[]): Promise<void> {
flagRows(scope, ids, "leaving", 2000);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return new Promise((resolve) => setTimeout(resolve, reduceMotion ? 250 : 380));
}
flagRows()
Marca as linhas de uma entidade com um efeito temporário, usando a URL da API como escopo para que cada tabela veja só as próprias linhas.
rowEffectClass()
É chamada pelo row-class do DataTable do PrimeVue e devolve a classe row-fx-created, row-fx-updated ou row-fx-leaving.
playRowExit()
Toca a animação de saída antes que a tabela recarregue os dados, para que a linha excluída suma diante do usuário em vez de desaparecer de repente.
Na prática, a linha criada desce para o lugar com um brilho que se apaga, a linha editada recebe uma faixa de luz que a atravessa e a linha excluída ganha um tom avermelhado, desliza e desfoca. A exclusão em lote usa o mesmo caminho para todas as linhas removidas.
Imagem: Tabela de Cars com a linha recém-criada destacada pelo brilho e o toast de confirmação;
Imagem: Linha excluída com tom avermelhado saindo da tabela enquanto o toast confirma a exclusão;
Com o movimento pronto, o segundo pedido foi sobre cor. É aqui que o DESIGN.md mais pesa. Ele exigia uma interface monocromática com um único acento, e o Impeccable trata esse tipo de regra como compromisso da marca. Em vez de quebrá-la, ele trouxe a cor como significado dos dados.
/impeccable colorize Paleta de pinturas na dashboard e Tema claro/escuro e o botão na sidebarA paleta escolhida usa cores de pintura automotiva: Gulf, Racing Green, Bronze, Burgundy e Graphite. O agente calculou o contraste de cada cor contra o branco e contra o próprio fundo tingido antes de usá-la. Com a configuração CSS-first do Tailwind CSS v4, a paleta vira tokens declarados direto no CSS, e utilitários como bg-paint-gulf e text-paint-gulf/12 passam a existir automaticamente.
@theme {
--color-paint-gulf: #2f6f9f;
--color-paint-green: #2e6b4f;
--color-paint-bronze: #9a6a1c;
--color-paint-burgundy: #8e2f52;
--color-paint-graphite: #475569;
}
Cada card de KPI ganhou uma cor de identidade, com um fundo levemente tingido e o ícone na mesma cor. O card de Fleet by brand ganhou uma barra segmentada com a composição da frota, e o gráfico de novos clientes deixou o verde padrão do Aura e passou a usar o azul Gulf. O slate continua sendo a única cor de ação, e o agente registrou essa decisão no DESIGN.md como uma nova regra: as cores de pintura representam só dados, nunca botões ou navegação. Assim, a próxima pessoa ou o próximo agente que mexer no projeto herda a decisão.
Imagem: Dashboard no tema claro com os cards de KPI tingidos e a barra de composição da frota por marca;

O tema escuro é a parte mais interessante da implementação. Em vez de espalhar variantes dark: por todos os componentes, o agente percebeu que os tokens semânticos do preset Aura do PrimeVue apontam para a escala de superfícies. O fundo dos componentes usa surface-0, o texto usa surface-700 e as bordas usam surface-200 e surface-300. Ao redefinir essa escala sob a classe app-dark, componentes, utilitários do Tailwind e a própria paleta mudam juntos.
html.app-dark {
color-scheme: dark;
--p-surface-0: #0d1117;
--p-surface-50: #131922;
--p-surface-100: #19202b;
--p-surface-200: #232b37;
--p-surface-300: #2f3846;
--p-surface-400: #4d5868;
--p-surface-500: #8b96a8;
--p-surface-600: #aab3c2;
--p-surface-700: #dbe1ea;
--p-surface-800: #edf1f6;
--p-surface-900: #f5f7fa;
--p-surface-950: #fbfcfd;
/* ... */
}
A escala não é uma inversão mecânica. O fundo fica num grafite quase preto, os cards e a sidebar sobem um degrau para criar elevação, o texto secundário fica com contraste de 5,9:1 e o principal com 14:1. O seletor html.app-dark vence o bloco :root que o PrimeVue injeta em tempo de execução, independente da ordem dos estilos. As cores de pintura ganharam versões mais claras para o fundo escuro, e o gráfico do ECharts reconstrói as cores e o tooltip sempre que o tema muda.
Imagem: Dashboard no tema escuro com os KPIs, o gráfico em azul claro e a barra de composição da frota

A troca de tema fica num botão na sidebar, desenhado como mais uma linha do menu, com lua no tema claro, sol no escuro e um mini interruptor quando a sidebar está aberta. A lógica fica num composable que salva a escolha no navegador e usa a View Transitions API para revelar o novo tema num círculo que cresce a partir do botão.
function toggle(event?: MouseEvent) {
const next: Theme = theme.value === "dark" ? "light" : "dark";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!document.startViewTransition || reduceMotion) {
apply(next);
return;
}
const target = event?.currentTarget as HTMLElement | undefined;
const rect = target?.getBoundingClientRect();
const x = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
const y = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
const radius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
const transition = document.startViewTransition(() => apply(next));
transition.ready.then(() => {
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{ duration: 550, easing: "cubic-bezier(0.16, 1, 0.3, 1)", pseudoElement: "::view-transition-new(root)" },
);
});
}
toggle()
Calcula o centro do botão clicado e o raio até o canto mais distante da tela, e anima o clip-path do novo snapshot de um círculo de zero até cobrir a página.
apply()
Alterna a classe app-dark no elemento raiz e salva a escolha no localStorage.
Quando o navegador não suporta View Transitions ou o usuário pediu menos movimento, a troca é instantânea. Para evitar que a página pisque no tema errado ao carregar, o agente também adicionou um script curto no _Layout.cshtml do ASP.NET Core que aplica a classe antes da primeira pintura.
Imagem: Página de Cars no tema escuro com a sidebar aberta e o interruptor de tema ligado

Um detalhe mostra bem como a skill trabalha com o agent mode do GitHub Copilot. O Impeccable manda verificar o resultado em uma única rodada, com desktop e mobile juntos, corrigi tudo de uma vez e para. Depois de implementar o tema escuro, o agente capturou as telas nos dois temas e encontrou dois problemas. O texto dos botões primários ficava branco sobre o novo fundo claro do botão, e os cards se confundiam com o fundo da página. Ele corrigiu os dois antes de entregar: o botão primário passou a usar text-surface-0, e os cards ganharam um degrau de elevação. Ao final, rodou o detector determinístico da skill, que confere o código contra padrões conhecidos sem usar um modelo de IA, e não houve apontamentos.
No fim, foram dois comandos do Impeccable dentro do GitHub Copilot, sem tocar na API do back-end. O dashboard do CarStore ganhou uma abertura com loader e contagem, efeitos que mostram o que mudou em cada operação, uma paleta de dados com regra própria e um tema escuro projetado. O principal aprendizado é que a qualidade dessas decisões vem do contexto. Com um PRODUCT.md e um DESIGN.md bem escritos, o agente deixa de decidir no gosto e passa a seguir o sistema do projeto, atualizando o próprio DESIGN.md quando cria uma regra nova.
O código completo deste projeto está disponível no meu repositório no GitHub.
Docs e Links:






Não esqueça de me seguir no LinkedIn para mais conteúdos.
Até a próxima!!!


