@rivocode/ui-native
v0.9.0
Published
As peças do design system da RivoCode em React Native: o mesmo vocabulário de classes do web, via NativeWind, sobre os mesmos tokens.
Readme
@rivocode/ui-native
As peças do design system da RivoCode em React Native: o mesmo vocabulário
de classes do web (bg-bg, text-fg-muted, rounded-pill) via
NativeWind, sobre os mesmos tokens. Nenhum componente conhece a cor da marca:
ele pede um papel semântico e o tema responde. Entre os dois temas de casa a
troca acontece em runtime; vestir a cor de um cliente é decisão de build, e a
seção abaixo diz exatamente o que isso muda.
A documentação inteira vive em https://ds.rivocode.com.br; o guia de uso nativo em https://ds.rivocode.com.br/react-native.md.
Instalação
npx expo install nativewind@preview react-native-css react-native-reanimated react-native-keyboard-controller
npm install -D tailwindcss @tailwindcss/postcss postcss
npm install @rivocode/ui-native
npx rivocode-ui-native-initÉ nativewind@preview, e não nativewind. A tag latest do NativeWind
ainda aponta para a 4.2.6, e o peer deste pacote é >=5.0.0-preview.1: npx
expo install nativewind instala a v4, o aviso de peer rola para fora da tela
junto com o resto da saída do npm, e o que aparece depois é a v4 tentando
compilar um CSS escrito para a v5. A tag preview é a que o NativeWind publica
para a linha 5.
O react-native-reanimated não é enfeite nem peer opcional: o
react-native-css o exige em tempo de bundle, e sem ele o metro para em
Unable to resolve module react-native-reanimated a partir de um arquivo que
você nunca importou. Ele traz junto o react-native-worklets, que o
babel-preset-expo liga sozinho. As peças também o usam direto, e por isso ele
é peer declarado (>=4): é por ele que o Button e o Toggle afundam no
toque, o Toast sobe e desce, o Accordion abre com a seta girando, o fundo
da aba ativa desliza, as barras do Progress, do Meter e do Steps andam, o
erro do Field entra por fade, a marca do Checkbox e do RadioGroup cresce
e o Skeleton pulsa.
Toda animação usa as durações e a curva do web (tokens.scales["duration-*"]
e tokens.easings) e respeita o "reduzir movimento" do sistema, lido em tempo
real: com ele ligado nada anima, e o Dialog e o Sheet abrem sem transição.
O react-native-keyboard-controller também é peer obrigatório, e é ele que
impede o teclado de cobrir o campo. Não há nada para montar: o RivoProvider
já traz o KeyboardProvider dentro, e se o seu app já tinha um por fora, ele
reaproveita o seu em vez de montar o segundo. Com ele, o Sheet (e o que abre
nele: Select, Combobox, Menu, DatePicker, TimePicker, TreeSelect)
e o Dialog sobem com o teclado quadro a quadro, e a tela de formulário é o
ScrollArea, que rola até o campo em foco e prende o botão de enviar num
footer que sobe junto. Com o "reduzir movimento" ligado a folha e o rodapé
pulam direto para o lugar final, sem acompanhar o teclado. Ele vem incluído no
Expo Go do SDK 57, e o npx expo install escolhe a versão do seu SDK. Não use
o KeyboardAvoidingView do React Native por cima das peças: ele desconta o
teclado uma segunda vez.
Os sete arquivos, e o que cada um segura
O npx rivocode-ui-native-init escreve a receita inteira e imprime o que fez:
Receita do @rivocode/ui-native em meu-app/:
= babel.config.js nao existe, e e assim que tem que ser
+ postcss.config.mjs criado
+ metro.config.js criado
+ global.css criado
+ nativewind-env.d.ts criado
~ app.json expo.userInterfaceStyle: "light" -> "automatic"
+ package.json browserslist adicionado
= peers obrigatorios os 5 estao no package.json+ é arquivo novo, ~ é uma chave de JSON trocada com o valor antigo à vista,
= é o que já estava certo, e ! é o que ele não tocou. Arquivo que nasce
inteiro e já existe com outro conteúdo nunca é reescrito calado: ele sai como
!, o comando termina com código 1, e só --force faz a receita vencer —
porque reescrever um babel.config.js ou um postcss.config.mjs apaga a
configuração de outra biblioteca e o app quebra num lugar que não parece ter
relação com este comando. --dry-run mostra o plano sem escrever nada.
Depois dos arquivos ele confere os peers obrigatórios no package.json do app
(os que o manifesto deste pacote não marca como opcionais: react,
react-native, nativewind, react-native-reanimated e
react-native-keyboard-controller). Faltando algum, ele imprime o
npx expo install com os nomes que faltam e termina com código 1: sem o
react-native-keyboard-controller o RivoProvider não monta.
São sete, e cada um por um motivo que morde:
babel.config.js. O certo é não existir. Sem arquivo de Babel nenhum, o@expo/metro-configcai nobabel-preset-expopor conta própria e liga o plugin de worklets junto. Escrever um à mão compresets: ["babel-preset-expo"]derruba o app: no SDK 57 esse preset mora emnode_modules/expo/node_modulese não resolve da raiz do projeto, e o bundle morre comMODULE_NOT_FOUNDantes do primeiro módulo, numa pilha que só cita o@babel/core. Se o seu app já tem um, ele fica — mas duas linhas da receita v4 do NativeWind, que continua sendo o primeiro resultado de busca, não valem mais aqui e o comando as acusa pelo nome:jsxImportSource: "nativewind", que faz todo JSX exigirnativewind/jsx-runtime— arquivo que a v5 não tem —, e o presetnativewind/babel, que adiciona pela segunda vez o plugin de worklets.postcss.config.mjs. O plugin@tailwindcss/postcss, e nada mais. É ele que faz o Tailwind rodar no passe de CSS; sem ele o arquivo entra cru no bundle, com as variáveis do tema e nenhum utilitário gerado, e a tela renderiza sem estilo, sem erro e sem pista — o mesmo silêncio do@sourceesquecido no web.metro.config.js.withNativewind(config), que troca o transformador do metro pelo doreact-native-css.app.json."userInterfaceStyle": "automatic", senão o iOS prende a aparência no claro e o tema escuro nunca chega. O template do Expo nasce em"light", então esta é a chave que o comando quase sempre troca.package.json."browserslist": ["chrome 130", "safari 18", "firefox 130"]. Sem isso o passe web que o Expo roda antes do compilador nativo reescreve olight-dark()dos tokens num polyfill de vars órfãs, e a compilação morre com "Specifier, found ()". É esse arquivo que sustenta a troca entre os dois temas de casa em runtime.global.css. A fonte do CSS:@import "tailwindcss/theme.css" layer(theme); @import "@rivocode/ui-native/theme.css"; @import "tailwindcss/utilities.css"; @source "./App.tsx"; @source "./node_modules/@rivocode/ui-native/src"; @source not inline("shadow"); @source not inline("invert"); @source not inline("filter"); @source not inline("transform");As quatro últimas linhas não são higiene: o scanner do Tailwind lê o código como texto, e
shadow,invert,filteretransformaparecem no TypeScript das peças como chave de configuração e nome de prop, nunca comoclassName..shadowredeclara--tw-shadow, que o pré-compilado já declarou no:root, e var declarada duas vezes derruba o compilador nativo com "expected an object-like struct named Specifier, found ()".nativewind-env.d.ts. Duas linhas, e a primeira é a que otscdo seu app cobra:/// <reference types="nativewind/types" /> declare module "*.css";Este pacote publica fonte, e não
dist: otscdo seu app compila as nossas peças junto com o seu código. Sem essa referência,classNamenão existe nas props deView,TextePressable, e otscreprova o catálogo inteiro com dezenas de erros dentro donode_modules— eskipLibCheck: truenão salva, porque ele só pula.d.ts, e o que está sendo compilado aqui é.tsx. É exigência do NativeWind, e vale para qualquer biblioteca que publique fonte comclassName. A segunda linha é dogenerated.cssque oApp.tsximporta no topo. O arquivo precisa estar noincludedo seutsconfig.json; o template do Expo já alcança**/*.ts.Do nosso lado a outra metade é medida: o
native/tsconfig.check.jsoncompila a fonte publicada comstrictenoUncheckedIndexedAccessligados, para que um app que ligue essas flags não trombe na rigidez da nossa biblioteca.
O examples/native do repositório roda essa mesma receita, e
bun run check:receita fica vermelho no dia em que as duas se separarem.
O CSS pré-compilado
O app não importa o global.css: importa o pré-compilado. Gere-o com
npx rivocode-ui-native-css # lê global.css, escreve generated.csse rode de novo sempre que usar uma classe nova. O pipeline do metro tropeça em
@import e em @property dentro do compilador nativo; o comando entrega um
arquivo já resolvido e falha com o nome da var quando algo não traduziria.
import "./generated.css";
import { RivoProvider, Button } from "@rivocode/ui-native";
export default function App() {
return (
<RivoProvider theme="rivocode-dark">
{/* rivocode-light e system também; entre os temas de casa, trocar a prop troca a tela em runtime */}
<Button onPress={() => {}}>Começar</Button>
</RivoProvider>
);
}Tokens derivados, nunca editados
A fonte única dos tokens é o CSS do repositório (src/tokens/): é lá que os
guards de contraste mordem. tokens.json, tokens.ts e theme.css são
gerados por bun run gen:native, e o bun run check falha se divergirem.
Cada cor sai como light-dark(claro, escuro): o compilador nativo transforma
isso em regra de prefers-color-scheme, e o RivoProvider troca o tema com
um Appearance.setColorScheme().
O que não traduz fica de fora de propósito: sombra de caixa (no RN é
elevation/shadow*, decisão da peça), clamp() de marketing, z-index, e
a densidade compacta, porque alvo de toque não encolhe em tela de dedo: não
há density na API nativa, e comfortable é a única altura.
Medir o contraste do seu tema
A conta da WCAG que morde os tokens da casa viaja no pacote, e não só o
resultado dela. @rivocode/ui-native/contrast exporta o mesmo motor que o
bun run check usa — checkThemeMap, contrastRatio e compose — com a
tabela de pares inteira: os pares que carregam texto, a fronteira de controle
de 3:1, o alfa sobre alfa do Calendar, a camada achatada por opacity do
botão destrutivo e o trilho do Switch ligado.
import { checkThemeMap } from "@rivocode/ui-native/contrast";
for (const { ok, line } of checkThemeMap("acme", acmeTheme)) {
if (!ok) console.error(line);
}O arquivo é gerado de src/lib/contrast.ts do repositório e versionado aqui,
como tokens.ts e theme.css: um espelho, e não uma segunda cópia que
envelhece sozinha. Quem preferir a linha de comando mede o mesmo mapa com
npx rivocode-ui check-theme acme.theme.ts, do pacote web, que chama este
mesmo motor.
Tema de cliente: hoje é decisão de build, não prop de runtime
Três fatos, e o primeiro explica os outros dois.
1. A cor de classe só muda em build. O compilador do react-native-css
resolve o token e crava o valor dentro da regra: .bg-accent vira
{"backgroundColor":"#d4f34a"}, literal, e nos 56 KB de CSS compilado não sobra
uma ocorrência de --. Não existe variável viva no aparelho, então
nenhum objeto de tema passado em runtime jamais trocou cor de classe.
Tema de cliente aqui é geração de CSS, e não troca em runtime. O que troca em
runtime são os dois temas de casa, que nasceram dentro do light-dark() que o
compilador entende.
2. O mapa de tema SAIU do provider. O objeto de tema alcançava
só quem lê a cor por JS, do contexto: ChartDonut, ChartRadial, o giro do
Button e do Spinner, o trilho do Switch, a Sparkline, o texto de dica
dos campos. Fundo, cartão, botão, selo e borda são classe, e continuavam com a
cor da RivoCode: na tela isso era donut de um tema e botão de outro, lado a
lado, sem nada vermelho no console além do aviso em __DEV__.
Uma metade que discorda da outra é pior do que nenhuma. O provider passou a
resolver os 45 papéis lendo o CSS compilado, uma classe bg- por papel, e
publica no contexto que as peças já liam: contexto e classe dizem sempre a mesma
cor. Com isso o mapa deixou de ter o que fazer e foi removido: a prop theme
aceita só rivocode-dark, rivocode-light e system, e a prop scheme saiu
junto, porque era ela que escolhia o esquema do mapa.
3. O teto é de dois temas por build. Cada papel sai como
light-dark(claro, escuro), e light-dark() tem duas vagas: uma clara e uma
escura. App de cliente único cabe folgado, e é o caso normal. Uma vitrine de
cinco temas, como a do web, não cabe sem cinco bundles. É teto de
arquitetura, e não pendência.
O caminho que funciona
Sobrescreva os papéis no @theme do CSS do app, antes de compilar. É a mesma
camada 3 do web, no vocabulário --color-* que o compilador nativo lê:
@import "tailwindcss/theme.css" layer(theme);
@import "@rivocode/ui-native/theme.css";
@import "tailwindcss/utilities.css";
@theme {
--color-accent: #2563eb;
--color-accent-hover: #3b82f6;
--color-accent-fg: #ffffff;
--color-bg: light-dark(#f7f8fa, #0d1220);
--color-surface: light-dark(#ffffff, #141b2d);
/* …e os outros papéis que a marca troca. */
}
@source "./App.tsx";
@source "./node_modules/@rivocode/ui-native/src";Rode npx rivocode-ui-native-css de novo e a tela vira do cliente inteira:
a classe pinta a cor nova, e a peça que lê cor por JS lê a mesma cor do mesmo
CSS, porque é dali que o provider a tira. Não passe mapa nenhum na prop theme.
O passo a passo está em https://ds.rivocode.com.br/temas.md.
Escreva só a paleta: rivocode-ui-native-theme
O @theme do app tem 45 papéis para preencher, e escrever os 45 à mão é
onde o tema de cliente começa a envelhecer. Os nomes de papel, os pares de contraste, os
mínimos, a composição de alfa e o formato que o compilador nativo aceita são
conhecimento da biblioteca, e, antes deste comando, eles moravam no app de quem
vestia o cliente. O segundo binário do pacote traz essa conta de volta para
dentro:
npx rivocode-ui-native-theme acme.ts # lê a paleta, escreve acme.theme.css
npx rivocode-ui-native-theme acme.ts saida.css
npx rivocode-ui-native-theme --papeis # o que você escreve, o que ele derivaVocê escreve oito papéis por esquema, e mais nada:
export const acme = {
light: {
bg: "#ffffff",
surface: "#ffffff",
fg: "#111111",
accent: "#1d4ed8",
success: "#0f6b52",
warning: "#7a4a00",
danger: "#b3261e",
info: "#1d4ed8",
},
dark: {
bg: "#101314",
surface: "#191d1f",
fg: "#f2f3f0",
accent: "#8ab4f8",
success: "#3ddc97",
warning: "#f2b21c",
danger: "#ff8a8a",
info: "#8ab4f8",
},
};O arquivo pode ser .ts, .js, .mjs ou .json; qualquer papel dos 45 se
escreve a mão ali e o comando para de derivar aquele. A saída entra no
global.css depois do tema do pacote, e o pré-compilado sai como sempre:
@import "tailwindcss/theme.css" layer(theme);
@import "@rivocode/ui-native/theme.css";
@import "./acme.theme.css";
@import "tailwindcss/utilities.css";npx rivocode-ui-native-cssEle recusa escrever tema que não passa no contraste. A medida é a do
@rivocode/ui-native/contrast, o mesmo motor do bun run check: os pares de
texto, a fronteira de 3:1, o alfa sobre alfa do Calendar, a camada achatada
por opacity do botão destrutivo e o trilho do Switch ligado:
Guarda de contraste:
claro: 1 falha(s)
accent-fg sobre accent 2.45:1 (min 4.5)
escuro: passa
Nada foi escrito: conserte o contraste antes de gerar o CSS.Quatro coisas que vale saber antes de rodar:
- Ele nunca inventa matiz nova. Derivar é reusar cor que você escreveu, ou
compor alfa dela:
accent-subtleé o seuaccenta 22%,fg-mutedé o seufgpuxado 30% para obg,accent-fgé o tom defg/bgque pesa mais sobre o botão. Papel derivado errado é pior que papel pedido, então onde reusar não passa na medida ele recusa e diz o valor que passaria.accent-texte os quatro*-textsão os casos típicos, porque são a cor que se lê. A única exceção declarada échart-1achart-8, que caem na série da RivoCode: série de gráfico é escala categórica, e não identidade de marca. Ela é medida sobre o seu fundo, e reprova se não couber. - Dois temas por build, e o comando explica o teto em vez de o ignorar. Cada
papel sai como
light-dark(claro, escuro), que tem duas vagas. Um terceiro esquema no arquivo é recusado com o motivo: é um terceiro bundle, e não uma terceira vaga. - Papel novo em versão nova acusa. A lista de papéis sai do
tokens.jsondo pacote instalado, e não de uma cópia dentro do comando. Quando a 0.4.0 trouxer um papel, o comando o cobra pelo nome na primeira vez que você rodar, em vez de o tema sair pela metade e a peça herdar a cor da RivoCode. Nome errado na paleta também é acusado, com sugestão do papel que você quis dizer. oklch()entra direto, e a paleta do Tailwind 4 com ele. A conta lê hexadecimal de 3, 4, 6 e 8 dígitos,rgb(),rgba(),hsl(),hsla(),hwb(),lab(),lch(),oklab(),oklch()ecolor()nos espaços predefinidos do CSS, e converte tudo para sRGB antes de medir. O CSS que o comando escreve continua sRGB literal, porque é o que o compilador nativo crava. Recusadas seguemcolor-mix()— que é conta, e não cor — e semente com alfa. Cor fora do gamut do sRGB é medida no pixel que o aparelho mostra, e o comando diz quais papéis caíram ali.
Quatro subcaminhos, e um peer por porta
O formulário, o gráfico, o copiar e o anexar não saem do índice da raiz:
import { Form, FormField, forText, useZodForm } from "@rivocode/ui-native/form";
import { ChartContainer, ChartDonut, ChartRadial } from "@rivocode/ui-native/chart";
import { Clipboard } from "@rivocode/ui-native/clipboard";
import { FileUpload, FileUploadItem, FileUploadList } from "@rivocode/ui-native/file-upload";Cada porta tem um peer opcional atrás, e o metro resolve import por
arquivo: dentro do índice principal, um app que só quer um Button teria de
instalar os quatro para o bundle fechar. Nos três de baixo o preço é maior que
bytes: são módulos nativos, que o app liga ao projeto de iOS e Android e
reconstrói.
npx expo install react-native-svg # só quem desenha gráfico
npx expo install expo-clipboard # só quem copia
npx expo install expo-document-picker # só quem anexaÉ um subcaminho por peer, e não um por assunto. O Clipboard e o
FileUpload dividiriam bem uma porta chamada /expo, e a conta de quem
instala diz que não: quem põe um botão de copiar ao lado da chave de acesso de
uma NF-e não anexa arquivo nenhum, e um índice comum cobraria dele o seletor
de documentos. scripts/check-fronteira-do-chart.ts, na raiz do repositório,
guarda as quatro fronteiras: nada alcançável pelo índice da raiz pode
importar de dentro delas.
A fonte é do app, e o provider só passa o nome adiante
No web as três famílias chegam pelo CSS de tokens. No celular não há CSS de
fonte: o arquivo .ttf/.otf entra no bundle do app e é o app quem registra a
família, com o expo-font. Por isso a biblioteca não carrega fonte nenhuma:
ela recebe os nomes já registrados e os aplica ao catálogo inteiro.
Sem configuração, tudo sai na fonte do sistema e nada quebra. mono é a única
com padrão de casa, porque o sistema já a tem: Menlo no iOS, monospace no
Android.
import { useFonts, isLoaded } from "expo-font";
import { RivoProvider } from "@rivocode/ui-native";
export default function App() {
const [ready] = useFonts({
Manrope: require("./assets/Manrope.ttf"),
Poppins: require("./assets/Poppins.ttf"),
JetBrainsMono: require("./assets/JetBrainsMono.ttf"),
});
if (!ready) return null;
return (
<RivoProvider
fonts={{ sans: "Manrope", display: "Poppins", mono: "JetBrainsMono" }}
isFontLoaded={isLoaded}
>
{/* … */}
</RivoProvider>
);
}sans veste o texto corrido, display os títulos (Card, Dialog, Sheet,
PageHeader, Stat, Steps, Fieldset e o miolo dos gráficos), e mono o que
alinha por largura fixa: Code, o carimbo da Timeline, as iniciais de dia do
Calendar, o campo hexadecimal do ColorPicker. Declarar só sans é legítimo:
display cai nela, como a pilha do web faz.
Para a sua própria tela usar as mesmas famílias, useRivoFonts() devolve as
três já resolvidas.
Isto não é um subcaminho, e a regra de cima continua valendo. Um subcaminho
existe para conter um peer; aqui não há peer: a biblioteca nunca importa
expo-font, nem em tipo. O app importa, o app carrega, e o que atravessa a
fronteira é uma string.
Nome errado falha calado: o provider grita por você
O React Native ignora família que o aparelho não tem: o texto sai na fonte
padrão, sem erro e sem aviso. Foi assim que font-mono viveu meses compilada
para ui-monospace, que é genérica de CSS e não existe instalada em celular
nenhum.
Em __DEV__, o RivoProvider acusa o que consegue ver sozinho: pilha de CSS
com vírgula ("Manrope, system-ui, sans-serif": o RN lê a linha inteira como
um nome só), aspas herdadas do CSS, var(--…), nome vazio, família genérica, e
monospace fora do Android. O que ele não consegue ver sozinho é a tabela
de fontes do aparelho. Daí o isFontLoaded: passe o isLoaded do expo-font
e cada nome declarado que não chegou ao aparelho sai nomeado no aviso.
O catálogo
O catálogo do web atravessa por tradução e não por porte: DataTable vira
DataList, Sheet só conhece o comportamento de baixo, Select abre numa
folha, e Sidebar, Menubar e Tooltip não portam (são idiomas de desktop).
A tabela completa de tradução está no guia, e ela é gerada: quantas atravessam
e quantas não portam se lê lá, e não aqui.
