Arquitetura de estilo do eQuantic.UIO eQuantic.UI entrega uma experiência de desenvolvimento (DX) inspirada no Flutter enquanto aproveita a força inteira de cada alvo de renderização. A arquitetura de estilo se apoia em três pilares:
1.
Abstração: componentes autoram _o que_ estilizar, como valores C# tipados, nunca strings CSS.
2.
Uma semântica, dois alvos: o mesmo vocabulário de estilo comanda valores em dp no nativo (Photon) e CSS no web.
3.
Performance: geração e deduplicação de classes em tempo de build, sem custo de runtime CSS-in-JS.
1. Estilizando componentes: C# tipado, sem plano CSS
O estilo de um componente é autorado inteiramente em C# tipado (BoxStyle, StyleDiff, tokens de design) e rebaixado pelo motor de estilo atômico: cada declaração comum vira UMA classe atômica deduplicada, idêntica byte a byte entre o SSR (C#) e a hidratação (TS); cores de tema referenciam as custom properties var(--eq-color-*). Estados de hover/foco, adaptação por classe de tamanho de janela, posicionamento sticky e transforms fazem todos parte do vocabulário, com zero JavaScript envolvido.
O hover é derivado, e é só para ponteiro
Desde 0.2.0-preview.24
A paleta de variante continua com cinco sub-tokens; o hover resolve por DERIVAÇÃO: o VariantColors.Hover é o ponto médio canal a canal de Base → Pressed (ColorToken.MidpointWith, a mesma cor onde color-mix(in srgb, Base 50%, Pressed) cai), e variantes discretas fazem hover em SurfaceSubtle. Componentes autoram isso como um diff comum de BoxStyle.Hover, então os dois realizadores já sabiam pintá-lo: o web como uma regra atômica :hover, o Photon pelo pipeline de ponteiro.
Duas regras acompanham a própria emissão. Toda regra atômica :hover é embrulhada em @media (hover: hover), para que o hover emulado e grudento de um browser de toque não ache regra nenhuma, e a cerca embrulha a regra, nunca o hash, então SSR e hidratação continuam comparando strings de classe idênticas. E no Photon, o hover é rastreado por CAMINHO de layout, como uma CADEIA de tudo que está sob o ponteiro, porque o :hover do CSS casa com todo ancestral: passar sobre o gatilho de um tooltip também faz hover no tooltip, e uma reconstrução de componente, que troca cada instância de nó, deixou de perder o hover que ela mesma repintou.
Uma cor que o componente deriva roda no browser
Desde 0.2.0-preview.60
O browser guarda um Color como dado simples, { r, g, b, a }, porque os estilos do runtime, o design system gerado e um payload de hidratação criam um sem construtor. O código do componente que roda no browser continua escrevendo em C#: Brand.WithOpacity(0.8f), a.MidpointWith(b), new Color(…), default(Color), $"{color}", e um grupo de método como Func<float, Color> fade = Brand.WithOpacity. O eqc traduz cada um para o Color do runtime, e o texto é o texto de record que o .NET escreve, Color { R = 248, G = 113, B = 113, A = 204 }.
Antes da 0.2.0-preview.60 isso renderizava no servidor e quebrava no browser depois da hidratação (withOpacity is not a function), e um tipo do próprio app chamado Color era construído como o do vocabulário.
Uma curva que o componente cria roda no browser
Desde 0.2.0-preview.61
Um Curve também é dado simples no browser, { x1, y1, x2, y2 }, onde quer que o browser encontre um: um preset, a curva de um papel de movimento, o easing de uma transição, uma curva que o componente cria, uma curva no estado de uma página. Por isso o código do componente que roda no browser lê e cria uma curva como o C#: Curve.Standard.X1, Motion.Press.Curve, new Curve(0.2f, 0.9f, 0.3f, 1.25f) como Easing de um TransitionSpec, ==, with, uma desconstrução, e $"{curve}", que escreve Curve { X1 = 0.2, Y1 = 0, X2 = 0, Y2 = 1 } como o .NET.
var easing = new Curve(0.2f, 0.9f, 0.3f, 1.25f);
Transition = new TransitionSpec(StyleChannels.Colors, 150) { Easing = easing },
O avaliador fica no servidor e nos shells nativos. Uma transição na web é uma função de tempo do CSS, então o próprio browser avalia a curva, e um componente que chama curve.Ease(t) ou CurveEvaluator.Ease(curve, t) falha o build com EQ2010. Entregue a curva a um TransitionSpec, ou chame o avaliador de código de servidor.
Antes da 0.2.0-preview.61 um preset era um array no browser e a curva de um papel de movimento era um nome, então um ponto lia undefined, new Curve(…) quebrava com "is not a constructor" depois da hidratação, e curve.Ease(t) compilava e quebrava.
Um estado muda qualquer membro do estilo
Desde 0.2.0-preview.60
Um StyleDiff é a aparência de uma caixa enquanto um estado está ativo: BoxStyle.Hover sob o ponteiro, e BoxStyle.Focus e BoxStyle.Pressed enquanto o controle dela está focado ou pressionado (abaixo). Todo membro que ele define vale, na web e no Photon, e todo membro que ele deixa sem valor mantém o da base. Até esta release, um hover no Photon aplicava só o fundo e a borda, e na web não conseguia mover uma caixa nem mudar as próprias sombras.
Background = theme.Surface,
Shadows = [new ShadowSpec(12, 30, -12, glow)],
Transition = new TransitionSpec(StyleChannels.Transform | StyleChannels.Shadow),
Transform = Transform2D.Translate(0, -2),
Shadows = [new ShadowSpec(16, 36, -12, glow)],
Esse card sobe 2dp e aprofunda o brilho sob o ponteiro, e desliza nos dois sentidos. O que cada membro faz enquanto o estado está ativo:
•
Background, BorderColor, BorderWidth, Opacity e BackdropBlur substituem os da base.
•
Transform substitui o Transform da base, como uma regra CSS substitui transform: um card girado em repouso e erguido no hover diz as duas coisas no hover, Transform2D.Rotate(2).WithTranslate(0, -2), e Transform2D.Scale(1) desfaz o de repouso.
•
Elevation e Shadows mudam a sua parte de UMA lista de sombras, e o resto da lista fica: a sombra da elevação, depois as sombras próprias (as do estado substituem tanto o Shadow quanto o Shadows da base), depois o realce interno. Um estado que não deixa nada para desenhar escreve uma lista que não desenha nada.
•
Gradient substitui o gradiente da base e mantém por baixo o brilho e o padrão de grade dela.
Uma sombra sem deslocamento, desfoque nem expansão não desenha nada, então fica fora da lista nos dois alvos. Sob um Transition, os dois alvos deslizam a opacidade, o transform, as cores e todas as sombras, inclusive as próprias, e uma sombra que um estado acrescenta ou tira entra ou sai esmaecendo (no Photon desde a 0.2.0-preview.61). Uma caixa erguida também leva a sua área de clique junto na web, como qualquer transform de CSS, então um ponteiro parado na borda de onde a caixa se afasta pode erguê-la e soltá-la de volta. O Photon ainda testa o clique de uma caixa transformada onde ela foi disposta, inclusive o transform de repouso (#513). Mantenha o deslocamento pequeno, ou ponha o hover numa caixa que não se move. O StyleDiff é uma classe desde esta release (era um struct): Hover!.Value.Background passa a ser Hover!.Background, e uma caixa que não declara estado carrega um nulo no lugar dele.
O pressionado e o foco de um controle
Desde 0.2.0-preview.61
O hover é o estado da própria caixa: o ponteiro está sobre ela. Um pressionar e um foco de teclado acontecem a um CONTROLE, o Pressable, então Pressed e Focus valem para toda caixa dentro do controle enquanto ele está pressionado ou focado: a superfície, e qualquer coisa nela que diga como fica nesse momento.
Pressable(Box(new BoxStyle
Background = theme.Surface,
Transition = new TransitionSpec(StyleChannels.Transform, Motion.BaseMs),
Pressed = new StyleDiff { Transform = Transform2D.Scale(0.985f) },
Focus = new StyleDiff { BorderColor = theme.FocusRing, BorderWidth = 2 },
}, label), onPressed: save)
Esse controle afunda para 98,5% e volta, e ganha uma borda de foco pelo teclado, nos dois alvos. As regras:
•
Quando estados se encontram, o pressionado vence o foco e o foco vence o hover, membro a membro, na ordem do §10 do handoff. O Pressable.PressedBackground continua sendo o preenchimento mais forte.
•
O anel de foco é desenhado ao lado das sombras da caixa: um controle elevado ou com brilho mantém a elevação e o brilho enquanto está focado. Na web, o anel as substituía.
•
Um pressionar ou um foco de Simulated desenha o mesmo que um real, inclusive o anel.
•
Um controle sem PressedBackground mantém o próprio preenchimento enquanto pressionado. Na web, ele ficava transparente.
•
Uma transição que a superfície declara é dela: o esmaecer do preenchimento pressionado é um padrão que um Transition na caixa substitui, então um pressionar que escala desliza. Na web, o esmaecer a substituía.
•
Um controle desabilitado não mostra pressionado nem foco, nos dois alvos: nem quando foi desabilitado enquanto estava seguro, nem numa imagem de Simulated, nem quando um controle habilitado em volta dele é pressionado.
Onde os alvos diferem: o Photon não tem tecla segura, uma tecla ativa o controle na descida, então um diff de Pressed aparece com uma tecla segura só na web. E para um controle dentro de outro, a web pressiona também o de fora enquanto o de dentro está pressionado, como o :active do CSS sobe pelos ancestrais, onde o Photon pressiona só o controle que o ponteiro atingiu.
O Focus mudou de sentido nesta release, do :focus-visible da própria caixa, que uma caixa nunca tem, para o foco do controle dela: um Focus escrito antes não valia nem na web nem no Photon, e agora vale.
Um cabeçalho sob o qual o conteúdo rola
Desde 0.2.0-preview.61
ScrolledStyle = new StyleDiff
Background = theme.Surface,
Elevation = 2, // o scrolledUnderElevation do Flutter
BorderColor = theme.Border, // o fio ao longo da aresta de baixo do cabeçalho
Transition = new TransitionSpec(StyleChannels.Colors | StyleChannels.Shadow),
Todo membro de ScrolledStyle se aplica, na web e no Photon, enquanto a superfície em que o cabeçalho se fixa tiver rolado além de Pinned.ScrolledThreshold (8dp). O diff é aplicado sobre Pinned.ScrolledBase: um cabeçalho não desenha nada próprio, e a borda dele é o fio ao longo da aresta de baixo, a aresta sob a qual o conteúdo rola. Sob a Transition do cabeçalho ele entra e sai deslizando nos dois alvos.
A superfície é aquela em que o position: sticky se fixa: o ScrollView mais próximo em volta do cabeçalho, ou a página quando não há nenhum. Um cabeçalho dentro de um painel que rola segue o painel, não a janela. No Photon a página em si nunca rola, então um cabeçalho fora de todo ScrollView nunca fica rolado lá.
Antes disso a web escrevia quatro dos sete membros e uma borda sozinha, então um Elevation, um Gradient, um Transform e as Shadows não faziam nada, e o Photon não desenhava nada disso.
Desde 0.2.0-preview.23
O Photon pinta em ORDEM DE FILHO: um irmão posterior fica por cima, e não é preciso dizer nada. O CSS para de funcionar assim no momento em que caixas adquirem contextos de empilhamento, então o realizador web soletra o que o nativo dá de graça, nas palavras do design system, nunca nas do CSS.
Elevation decide o que está acima, não só quão profunda é a sombra. Ela desenhava uma sombra e nada mais, o que é meia frase: uma superfície elevada que qualquer coisa pintada depois cobre não está elevada. Os níveis 1–5 são o plano de CONTEÚDO, e são os únicos números que um autor escreve.
Chrome é um plano, não um cartão elevado. Qualquer coisa fixada (um cabeçalho Pinned, flutuante ou no fluxo) fica acima de toda elevação de conteúdo. Antes ela ficava um passo acima de nada, o que é um número competindo com outros números: dê à elevação a faixa 1–5 e um cartão meramente elevado passaria a empilhar acima do cabeçalho e rolaria por cima dele.
É por isso que Pinned não tem Layer e nunca vai ter. Um z-index é vocabulário CSS, e um autor que precisa escolher um está sendo obrigado a saber quais números todo o resto escolheu. A elevação diz quão alto, o realizador decide como isso se soletra, e chrome não está na mesma escala que conteúdo.
Uma exceção ao uma declaração, uma classe, e ela deixa de ser exceção assim que é nomeada: um prefixo de fabricante não é uma declaração própria, é a mesma escrita para outro motor. O par divide uma classe cuja regra nomeia os dois, com a propriedade padrão por último. Sozinho numa regra, o -webkit-backdrop-filter é descartado inteiro por um motor que só aceita o nome padrão (o insertRule deixa uma regra VAZIA no Chromium, medido), então a classe era computada, hasheada, emitida, posta no elemento, e não fazia nada. Desde 0.2.0-preview.21.
Uma classe atômica é o seletor mais fraco que existe. Uma classe é especificidade (0,1,0), portanto toda regra da folha gerada que nomeie um elemento (.eq-spinner rect é (0,1,1)) passa por cima da classe que o nó carrega. A restrição é de mão única: a folha gerada nunca pode declarar uma propriedade que um nó consiga carregar atomicamente, e um shorthand conta, porque ele escreve todo longhand que omite.
O spinner é o caso medido. Cada uma das oito barras carrega o seu animation-delay negativo, e essa fase É a rotação; o fade ao lado delas estava escrito como animation: eq-spinner-fade 800ms linear infinite, que escrevia animation-delay: 0s por cima das oito em silêncio. Renderizado no servidor, o anel pulsava no lugar em vez de girar, enquanto o cliente girava certo pelo motivo errado, com um estilo inline a vencer todo seletor. Aquela regra agora é de longhands, e uma guarda afirma que nada além do Reduce Motion declara o delay de uma barra. A mesma forma do prefixo de fabricante acima: a classe era computada, hasheada, emitida, posta no elemento, e outra coisa vencia.
Desde 0.2.0-preview.7
Um trecho monoespaçado (Text com Mono, um trecho rico, um CodeBlock) desenha numa pilha que o framework possui, e ela cai numa classe atômica, que vence uma regra body { font-family }, e cujo nome é um hash de conteúdo que muda entre builds. Então não havia onde pendurar a sua própria face de código. O gancho é uma variável:
:root { --eq-font-mono: 'JetBrains Mono', ui-monospace, monospace; }
Todo trecho mono a segue, com a pilha da plataforma como fallback quando ninguém a define.
O medidor lê a mesma variável, e isso não é um detalhe de implementação que dá para ignorar se você publica um editor de código: o CodeEditor posiciona o cursor a partir de um avanço de coluna medido, então medir numa face e desenhar em outra põe o cursor ao lado do caractere em que ele está, em vez de embaixo dele.
O motor completo (leis, pseudo-estados, classes de tamanho, a folha de estilo gerada) está documentado em DesignSystem. O vocabulário abstrato em si (Box, Row/Column, Grid, Stack, …) está documentado em Componentes write-once. Tematizar = fornecer um IAppTheme
Temas também são C# tipado (cores como pares ColorToken claro/escuro, papéis de tipo, escala de forma, elevação) selecionados numa linha e ponteados do SSR para o cliente:
builder.Services.AddUI(options =>
options.ScanAssembly(typeof(Program).Assembly)
.UseTheme(PhotonTheme.Instance); // ou MaterialTheme.FromSeed(color)
Duas implementações vêm na caixa: PhotonTheme (o padrão) e MaterialTheme (Material 3 de verdade, incluindo cor dinâmica a partir de uma semente). Troque o tema e todo componente se rebranda, servidor e cliente, web e nativo. A troca claro/escuro em tempo de execução é um serviço (IThemeController); veja DesignSystem. Um tema também responde com que cores um gráfico desenha os dados: IAppTheme.Data é uma DataPalette: oito cores de série numa ordem fixa (identidade), uma rampa sequencial de um só matiz (magnitude), um par divergente sobre um ponto médio neutro (polaridade), quatro degraus de estado reservados (estado) e um cinza de desênfase. Vem com uma paleta de referência validada por omissão, então um tema que nunca pensou em gráficos ainda serve um corretamente; um tema com rampas da marca sobrescreve Data e submete o resultado à mesma auditoria, PaletteAudit (luminosidade e croma em OKLab, separação para daltonismo, contraste), que é o que torna uma paleta segura de mudar. Uma nona série é recusada pelo nome, nunca recebe um matiz gerado. O plano por trás disso é o docs/CHARTS-PLAN.md no repositório.
E quando NÃO usar um token
Um token INVERTE com o modo, e é esse o objetivo dele, e é por isso que quase tudo devia ser um. A exceção é, portanto, tudo aquilo cujo significado não inverte.
O caso que descobriu isto: um brilho especular numa bolha, pintado com TextInverse porque no modo claro esse token é branco e um brilho é branco. No modo escuro o mesmo token é navy, então a app desenhou uma mancha escura exatamente onde devia estar um reflexo. Luz é branco explícito, nunca um token: um reflexo é luz e não primeiro plano, e a luz não fica escura quando a interface fica. O mesmo raciocínio cobre uma sombra que tem de continuar escura, e a cor de uma marca que a empresa não muda ao anoitecer.
Uma borda em algumas arestas
Desde 0.2.0-preview.13
BorderColor = theme.Border,
BorderSides = BorderSides.Top, // um filete acima de uma seção
BorderColor = theme.Colors(Variant.Primary).Base,
BorderSides = BorderSides.Start, // uma barra de acento ao lado de um destaque
BorderSides.All por padrão, então toda caixa escrita antes disso renderiza byte por byte igual: uma borda completa continua emitindo o atalho de uma declaração e a sua classe atômica única.
A borda fica dentro da caixa, e o filho também: a caixa guarda o filho dentro do padding e da borda nos lados em que a borda é desenhada, e uma caixa que abraça o filho conta os dois no seu tamanho, nos dois alvos (o Photon punha o filho por cima da borda até a 0.2.0-preview.61).
A borda de um estado segue as mesmas arestas: um Hover com BorderWidth = 2 numa caixa que desenha a aresta de baixo engrossa só essa aresta, na cor da base quando o estado não nomeia nenhuma. A web a desenhava nas quatro arestas, e não escrevia nada para uma largura sozinha (desde 0.2.0-preview.61).
Nenhuma dessas é um Divider. Um Divider é um irmão entre duas coisas; estas pertencem à própria caixa, e o único jeito de desenhar uma era um Row embrulhando um Box de um dp, o que é uma mentira de layout sobre o que o design queria dizer.
Start e End em vez de Left e Right, casando com EdgeInsets: eles espelham numa leitura da direita para a esquerda, e uma barra de acento que fica à esquerda quando o texto flui para o outro lado está do lado errado dele.
CERCA Com raio de canto, uma borda parcial difere levemente entre os alvos no canto onde uma aresta presente encontra uma ausente: o web faz meia-esquadria, o Photon deixa quadrado. Em raio 0 (um filete, uma barra de acento, uma célula de tabela, que é para o que bordas parciais servem) os dois são idênticos. Use BorderSides.All para um contorno arredondado.
2. A saída de emergência do HTML
A camada web de baixo nível (eQuantic.UI.Web) espelha o DOM 1:1. Todo HtmlElement expõe os atributos que o browser tem:
public abstract class HtmlElement : IComponent {
/// <summary>Classes CSS cruas (separadas por espaço).</summary>
public string? ClassName { get; set; }
/// <summary>Estilos inline para valores dinâmicos (ex.: coordenadas, cores vindas do banco).</summary>
public HtmlStyle? Style { get; set; }
Esse contrato significa que a camada web não impõe nenhum framework CSS, ela renderiza atributos HTML. O DynamicElement vai além e renderiza qualquer tag com quaisquer atributos. Classes CSS padrão de qualquer folha de estilo funcionam nativamente:
new DynamicElement("aside") { ClassName = "my-sidebar" }
O framework publica exatamente um motor de estilo, o pipeline C# tipado acima. Não há framework CSS embutido nem camada de adaptação: uma página que precisa de markup escrito à mão usa a saída de emergência (HtmlElement/DynamicElement + ClassName), e qualquer folha de estilo que o app traga (o CSS dele, um framework utilitário que ele mesmo constrói) é referenciada como em qualquer projeto web:
builder.Services.AddUI(options =>
options.ConfigureHtmlShell(shell => shell.AddStylesheet("/css/app.css"));
O framework não gera nem processa esse CSS; é assunto do build do próprio app.
eQuantic.UI.Primitives
Estilos tipados, tokens de design, o vocabulário abstrato: a fonte da verdade do estilo.
eQuantic.UI.Web
O realizador web + o motor de estilo atômico (StyleAtomizer) + a folha de estilo gerada., e a saída de escape espelho do DOM: HtmlElement, ClassName, HtmlStyle, DynamicElement.