npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

gpr-ui

v0.5.3

Published

GPR Design System — componentes e tokens compartilhados entre os produtos da GPR (Brand Book 2026).

Readme

gpr-ui

Sistema de componentes e tokens compartilhados entre os produtos da GPR. Materializa o Brand Book 2026 e o UI Kit 2026 em uma biblioteca React pronta para consumo em qualquer produto.


Sumário

  1. O que tem aqui
  2. Passo a passo — projeto novo do zero
  3. Setup em projeto existente
  4. API dos componentes
  5. Tokens
  6. Dark mode
  7. Regras do design system
  8. Desenvolvimento local da lib

O que tem aqui

Primitivos: Button (com loading e asChild), Input, PasswordInput, Label, Badge, StatusBadge, Card (+ subcomponentes).

Overlays: Dialog (genérico) e ConfirmDialog (com isLoading + variantes).

Formulário: Select (seleção única, wrapper do Radix), MultiSelect (seleção múltipla com chips, busca opcional e ações rápidas).

Dados: Table (primitivos) e DataTable (opinado, com loading/empty/sort/paginação).

Layout: AppShell (com GprLogo default), SidebarItem, SidebarSection, useSidebar, UserMenu, ThemeToggle (+ useTheme), PageHeader, AuthLayout.

Telas prontas: LoginPage (tela de login completa) e LoginForm (só o formulário).

Formatação: MaskedInput + MASKS — CPF, CNPJ, telefone, CEP, data e qualquer formato que você escrever.

Marca / Logos: GprLogo (wordmark "GPR"), LogoDark / LogoWhite (logo completo "GPR Educação" — símbolo + wordmark) e LogoIcon (só o símbolo/mandala).

Preset Tailwind com toda a paleta GPR, tipografia Inter e escalas de radius/elevation/z-index/motion.

Tokens semânticos em CSS variables (light + dark) — --primary, --foreground, --sidebar, etc.

Helpers: cn() e Slot.


Passo a passo — projeto novo do zero

Comece um produto GPR novo em ~5 minutos.

1. Criar o projeto

pnpm create vite@latest meu-app -- --template react-ts
cd meu-app
pnpm install

2. Instalar Tailwind 3 + gpr-ui

pnpm add -D tailwindcss@^3 postcss autoprefixer
pnpm dlx tailwindcss@^3 init -p
pnpm add gpr-ui

# Se for usar roteamento (exemplo do passo 6):
pnpm add react-router-dom

Importante: o preset foi escrito para Tailwind 3. Em Tailwind 4 o tailwind.config.js e a sintaxe de presets mudam — mantenha a v3 por ora. Note o @^3 também no pnpm dlx pra garantir que o binário do init seja da v3.

Se pnpm add gpr-ui falhar (pacote não publicado no npm público ainda), use o fallback local:

// package.json do projeto consumidor
{ "dependencies": { "gpr-ui": "file:../caminho/para/gpr-ui" } }

Ajuste o path e rode pnpm install. Veja também a seção Desenvolvimento local.

3. Configurar tailwind.config.js

Substitua o conteúdo gerado por:

import gprPreset from 'gpr-ui/tailwind-preset'

export default {
  presets: [gprPreset],
  content: [
    './index.html',
    './src/**/*.{ts,tsx}',
    './node_modules/gpr-ui/dist/**/*.{js,cjs}',
  ],
}

A última linha é crítica — sem ela, Tailwind faz purge das classes usadas dentro dos componentes da lib.

4. Importar os estilos (uma única vez, no entry)

// src/main.tsx
import 'gpr-ui/styles.css'   // tokens CSS + base GPR
import './index.css'         // seu CSS com @tailwind directives

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

E no seu src/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

5. Primeiro componente

// src/App.tsx
import { Button, Card, CardHeader, CardTitle, CardContent } from 'gpr-ui'

export default function App() {
  return (
    <main className="min-h-screen bg-background text-foreground p-8">
      <Card className="max-w-md mx-auto">
        <CardHeader>
          <CardTitle>Olá, GPR</CardTitle>
        </CardHeader>
        <CardContent className="flex gap-2">
          <Button>Salvar</Button>
          <Button variant="outline">Cancelar</Button>
        </CardContent>
      </Card>
    </main>
  )
}
pnpm dev

6. Montar o app com layout completo (quando for produto real)

import { Link, useLocation } from 'react-router-dom'
import {
  AppShell,
  SidebarItem,
  SidebarSection,
  UserMenu,
  ThemeToggle,
} from 'gpr-ui'
import { HomeIcon, SettingsIcon } from 'lucide-react'

export function App({ children }) {
  const { pathname } = useLocation()

  return (
    <AppShell
      // logo opcional — default é <GprLogo /> (wordmark oficial inline)
      navbarRight={
        <>
          <ThemeToggle />
          <UserMenu
            name="Fábio Garcia"
            email="[email protected]"
            onLogout={() => { /* ... */ }}
          />
        </>
      }
      sidebar={
        <>
          <SidebarItem asChild icon={HomeIcon} active={pathname === '/'}>
            <Link to="/">Início</Link>
          </SidebarItem>

          <SidebarSection label="Configurações">
            <SidebarItem
              asChild
              icon={SettingsIcon}
              active={pathname.startsWith('/settings')}
            >
              <Link to="/settings">Preferências</Link>
            </SidebarItem>
          </SidebarSection>
        </>
      }
    >
      {children}
    </AppShell>
  )
}

7. Checklist final

  • [ ] gpr-ui/styles.css importado uma vez no entry
  • [ ] presets: [gprPreset] no tailwind.config.js
  • [ ] ./node_modules/gpr-ui/dist/**/*.{js,cjs} no content
  • [ ] Peer deps instaladas: react ≥18, react-dom ≥18, tailwindcss ≥3
  • [ ] Se usa dark mode: ThemeToggle montado ou adicionar classe .dark no <html> manualmente

Setup em projeto existente

Se você já tem React + Tailwind configurado, pule pros passos 2→4 acima:

pnpm add gpr-ui
// tailwind.config.js
import gprPreset from 'gpr-ui/tailwind-preset'

export default {
  presets: [gprPreset],
  content: [
    './index.html',
    './src/**/*.{ts,tsx}',
    './node_modules/gpr-ui/dist/**/*.{js,cjs}',
  ],
}
// entry
import 'gpr-ui/styles.css'

Peer deps: react ≥18, react-dom ≥18, tailwindcss ≥3.


API dos componentes

Button

<Button>Salvar</Button>
<Button variant="outline" size="sm">Cancelar</Button>
<Button variant="destructive">Excluir</Button>

// loading: desabilita + spinner
<Button loading={mutation.isPending}>Salvar</Button>

// asChild: aplica as classes no filho único (ideal pra Link)
<Button asChild>
  <Link to="/planos">Ver planos</Link>
</Button>

| Prop | Valores | |---|---| | variant | default • destructive • outline • secondary • ghost • link | | size | sm (32px) • default (36px) • lg (40px) • icon (36×36) | | loading | boolean — prepende spinner e desabilita | | asChild | boolean — clona o filho aplicando classes |

Input / Label

Altura universal de 44px. aria-invalid="true" aciona ring destructive.

<Label htmlFor="email">E-mail</Label>
<Input id="email" type="email" placeholder="[email protected]" />

PasswordInput

Mesmo visual do Input, com botão olho pra alternar visibilidade da senha. Herda todos os comportamentos do Input (aria-invalid, disabled, altura 44px).

<Label htmlFor="password">Senha</Label>
<PasswordInput id="password" placeholder="••••••••" />

// Com validação
<PasswordInput aria-invalid={hasError} />

// Disabled (desabilita o toggle junto)
<PasswordInput disabled />

// Labels de acessibilidade customizáveis (default: "Mostrar senha" / "Ocultar senha")
<PasswordInput showLabel="Show password" hideLabel="Hide password" />

MaskedInput

Um input pra toda formatação do sistema. Em vez de CpfInput, CnpjInput, TelefoneInput e CepInput, você escreve o formato:

import { MaskedInput, MASKS } from 'gpr-ui'

<MaskedInput mask="000.000.000-00" />          {/* CPF */}
<MaskedInput mask="(00) 00000-0000" />         {/* celular */}
<MaskedInput mask={MASKS.cpfCnpj} />           {/* aceita os dois */}

Tokens do formato

| Token | Aceita | |---|---| | 0 | dígito (0-9) | | A | letra (a-z A-Z) | | * | alfanumérico | | \ | escapa o próximo caractere (\0 = zero literal) |

Qualquer outro caractere é separador literal.

Pegando o valor

<MaskedInput
  mask={MASKS.cpf}
  onValueChange={({ masked, raw, complete }) => {
    // masked   → "123.456.789-00"
    // raw      → "12345678900"    ← o que vai pra API
    // complete → true quando a máscara encheu
    if (complete) validarCpf(raw)
  }}
/>

onChange nativo também funciona — event.target.value já vem formatado. No controlado, value aceita cru ou formatado; o campo normaliza:

<MaskedInput mask={MASKS.cep} value={cep} onValueChange={(v) => setCep(v.raw)} />

Várias máscaras no mesmo campo

Passe uma lista e ele escolhe a menor que ainda comporta o que foi digitado — trocando de formato sozinho conforme a pessoa digita:

<MaskedInput mask={['000.000.000-00', '00.000.000/0000-00']} />
// 11 dígitos → 123.456.789-00
// 12 dígitos → 12.345.678/9001

Formatos prontos (MASKS)

| Chave | Formato | |---|---| | cpf | 000.000.000-00 | | cnpj | 00.000.000/0000-00 | | cpfCnpj | CPF ou CNPJ, troca sozinho | | cep | 00000-000 | | telefone | fixo ou celular | | data | 00/00/0000 | | hora | 00:00 | | placa | AAA0A00 (Mercosul) | | cartao | 0000 0000 0000 0000 | | agencia | 0000-0 |

| Prop | Tipo | Default | Descrição | |---|---|---|---| | mask | string \| string[] | — | Obrigatória. Formato, ou lista deles | | value | string | — | Controlado; aceita cru ou formatado | | defaultValue | string | '' | Não-controlado; aceita cru ou formatado | | onValueChange | (v: MaskedValue) => void | — | { masked, raw, complete } | | onChange | (e) => void | — | Nativo; e.target.value já formatado |

Aceita todo o resto de <input> (id, placeholder, disabled, aria-invalid…). inputMode="numeric" entra sozinho quando a máscara só tem dígitos, pra abrir o teclado numérico no celular.

Duas decisões de comportamento

Separador preguiçoso. Digitou 123 no CPF, o campo mostra 123 — não 123.. O ponto entra junto com o 4º dígito. Máscara que emite o separador na frente trava o backspace: você apaga o ponto, a máscara devolve o ponto, e a tecla não fez nada.

Backspace em cima de separador apaga o dado anterior. Sem isso a primeira tecla só removeria o ponto (que volta na hora) e seriam necessárias duas teclas pra apagar um caractere.

Badge

<Badge>Ativo</Badge>
<Badge variant="success">Verificado</Badge>
<Badge variant="warning">Atenção</Badge>
<Badge variant="destructive">Expirado</Badge>

Variantes: default, secondary, destructive, outline, success, warning.

StatusBadge

Variante especializada do Badge para estado de um dado (ativo/pendente/falhou). Tem ponto colorido indicador e aceita ícone customizado (ex.: spinner pra "processando").

<StatusBadge tone="success">Ativo</StatusBadge>
<StatusBadge tone="destructive">Falhou</StatusBadge>
<StatusBadge tone="warning">Pendente</StatusBadge>
<StatusBadge tone="info">Novo</StatusBadge>
<StatusBadge tone="neutral">Arquivado</StatusBadge>

// Com ícone no lugar do dot
<StatusBadge tone="info" icon={<Loader2 className="w-3 h-3 animate-spin" />}>
  Processando
</StatusBadge>

// Só texto
<StatusBadge tone="neutral" hideIndicator>Rascunho</StatusBadge>

Tons: success, destructive, warning, info, neutral.

Card

Composição via subcomponentes — nunca adicione mt-* manual entre eles.

<Card>
  <CardHeader>
    <CardTitle>Faturamento</CardTitle>
    <CardDescription>Último trimestre</CardDescription>
  </CardHeader>
  <CardContent>R$ 48.000</CardContent>
  <CardFooter>
    <Button variant="outline">Ver detalhes</Button>
  </CardFooter>
</Card>

O padding vertical (py-6) mora no <Card>; os subcomponentes cuidam só do horizontal (px-6). É o que garante respiro simétrico em qualquer combinação — inclusive card sem CardFooter, que é o caso mais comum.

Pra conteúdo sangrado (tabela ou imagem ocupando a largura toda):

<Card className="py-0">
  <CardContent className="p-0">
    <DataTable … />
  </CardContent>
</Card>

Dialog

Wrapper do Radix com tokens do DS. Use para qualquer modal que não seja de confirmação (pra confirmação, prefira ConfirmDialog abaixo).

<Dialog>
  <DialogTrigger asChild>
    <Button>Editar cliente</Button>
  </DialogTrigger>
  <DialogContent size="md">
    <DialogHeader>
      <DialogTitle>Editar cliente</DialogTitle>
      <DialogDescription>Atualize os dados abaixo.</DialogDescription>
    </DialogHeader>

    {/* form aqui */}

    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancelar</Button>
      </DialogClose>
      <Button onClick={save}>Salvar</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Props de DialogContent: size (sm • md • lg • xl), hideCloseButton.

ConfirmDialog

<ConfirmDialog
  open={confirmOpen}
  title="Apagar cliente?"
  message="Esta ação é irreversível."
  variant="danger"
  isLoading={deleteMutation.isPending}
  onConfirm={() => deleteMutation.mutate()}
  onCancel={() => setConfirmOpen(false)}
/>

Variantes: danger, warning, info.

Select

Wrapper do Radix Select com tokens GPR. aria-invalid aciona anel destructive.

<Select value={value} onValueChange={setValue}>
  <SelectTrigger>
    <SelectValue placeholder="Escolha um plano" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectLabel>Planos</SelectLabel>
      <SelectItem value="basic">Básico</SelectItem>
      <SelectItem value="pro">Pro</SelectItem>
      <SelectItem value="enterprise">Enterprise</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>

// Altura reduzida (inputs inline em listagens)
<SelectTrigger size="sm">...</SelectTrigger>

MultiSelect

Seleção múltipla com chips removíveis no trigger, busca opcional e ações rápidas. Construído sobre Radix Popover.

const [selected, setSelected] = useState<string[]>([])

<MultiSelect
  options={[
    { value: 'sp', label: 'São Paulo' },
    { value: 'rj', label: 'Rio de Janeiro' },
    { value: 'mg', label: 'Minas Gerais' },
    { value: 'rs', label: 'Rio Grande do Sul' },
  ]}
  value={selected}
  onValueChange={setSelected}
  placeholder="Selecione estados..."
/>

// Com busca (default: desligada)
<MultiSelect
  options={options}
  value={selected}
  onValueChange={setSelected}
  searchable
/>

// Customizando ações / tamanho
<MultiSelect
  options={options}
  value={selected}
  onValueChange={setSelected}
  size="sm"
  maxChips={5}           // default: 3
  showSelectAll={false}
  showClear
  emptyMessage="Nenhuma opção disponível."
/>

| Prop | Tipo | Default | Descrição | |---|---|---|---| | options | MultiSelectOption[] | — | { value, label, disabled? }[] | | value | string[] | — | Valores selecionados (controlado) | | onValueChange | (value: string[]) => void | — | Callback de mudança | | placeholder | string | "Selecione..." | Texto quando nada selecionado | | searchable | boolean | false | Mostra campo de busca | | searchPlaceholder | string | "Buscar..." | Placeholder da busca | | showSelectAll | boolean | true | Botão "Selecionar todos" | | showClear | boolean | true | Botão "Limpar" | | maxChips | number | 3 | Chips no trigger antes de virar "+N" | | size | 'default' \| 'sm' | 'default' | Altura do trigger | | emptyMessage | ReactNode | "Nenhum resultado." | Texto quando busca vazia |

Table (primitivos)

Sem lógica — só apresentação. Use quando precisar de controle total (colunas expansíveis, seleção múltipla, virtualização).

<Table>
  <TableHeader>
    <TableRow>
      <TableHead>Nome</TableHead>
      <TableHead>E-mail</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell>Fábio</TableCell>
      <TableCell>[email protected]</TableCell>
    </TableRow>
  </TableBody>
</Table>

DataTable

Listagem opinada com loading, empty state, sort e paginação prontos. Cobre 90% dos casos.

type Client = { id: string; name: string; status: string }

const columns: DataTableColumn<Client>[] = [
  { key: 'name', header: 'Nome', sortable: true },
  {
    key: 'status',
    header: 'Status',
    render: (c) => <StatusBadge tone="success">{c.status}</StatusBadge>,
  },
  { key: 'actions', header: '', align: 'right', render: (c) => (
    <Button size="sm" variant="outline">Abrir</Button>
  )},
]

<DataTable
  columns={columns}
  data={clients}
  loading={query.isPending}
  emptyMessage="Nenhum cliente cadastrado."
  rowKey={(c) => c.id}
  onRowClick={(c) => navigate(`/clientes/${c.id}`)}

  // Sort controlado (opcional)
  sort={sort}
  onSortChange={setSort}

  // Paginação controlada (opcional)
  pagination={{ page, pageSize: 20, total: query.data?.total ?? 0 }}
  onPageChange={setPage}
/>

Props principais:

| Prop | Descrição | |---|---| | columns | Array de DataTableColumn<T> com key, header, render?, sortable?, align? | | data | Array de itens | | loading | Mostra skeleton (N linhas = loadingRows, default 5) | | emptyMessage | Mensagem quando data é vazio | | rowKey | (item, i) => key — default usa o índice | | onRowClick | Callback na linha (cursor pointer automático) | | sort + onSortChange | Estado controlado de ordenação | | pagination + onPageChange | Controles inferiores (só aparecem se total > pageSize) |

AppShell + Sidebar

Layout completo com navbar (77px) + sidebar (220/76px) + drawer mobile. Persiste estado colapsado em cookie por 7 dias.

<AppShell
  // logo default: <GprLogo /> — passe algo só se quiser sobrescrever
  navbarRight={<>{/* ThemeToggle, UserMenu, etc */}</>}
  sidebar={<>{/* SidebarItem, SidebarSection */}</>}
  defaultSidebarOpen={true}        // opcional
  cookieName="meuapp.sidebar_open" // opcional
>
  {children}
</AppShell>

SidebarItem — item clicável da nav.

// Com router Link (Slot pattern)
<SidebarItem asChild icon={HomeIcon} active={isHome}>
  <Link to="/">Início</Link>
</SidebarItem>

// Botão com onClick
<SidebarItem icon={BellIcon} onClick={() => openNotifications()}>
  Notificações
</SidebarItem>

SidebarSection — agrupa items com label (vira divisor quando colapsado).

<SidebarSection label="Administração">
  <SidebarItem ...>Usuários</SidebarItem>
  <SidebarItem ...>Permissões</SidebarItem>
</SidebarSection>

useSidebar() — hook pra ler/controlar estado.

const { expanded, toggle, mobileOpen, setMobileOpen } = useSidebar()

UserMenu

Avatar circular com iniciais. Clique abre dropdown com info + logout.

<UserMenu
  name="Fábio Garcia"
  email="[email protected]"
  onLogout={() => logoutFn()}
  extraActions={/* opcional — slot antes do logout pra Perfil/Settings */}
/>

PageHeader

Título de página com descrição opcional e ação alinhada à direita. Use no topo de páginas inteiras (pra títulos dentro de Card, use CardHeader).

import { PageHeader, Button } from 'gpr-ui'
import { PlusIcon } from 'lucide-react'

<PageHeader
  title="Usuários"
  description="Gerencie usuários e seus acessos aos produtos."
  action={
    <Button onClick={openCreate}>
      <PlusIcon className="h-4 w-4" />
      Criar usuário
    </Button>
  }
/>

// Só título e ação (sem descrição)
<PageHeader
  title={user.name}
  action={<Button variant="secondary">Editar</Button>}
/>

// Sem linha decorativa
<PageHeader title="Dashboard" divider={false} />

// Com wrapper externo (ex.: back button) — anule o mb-8 padrão:
<div className="flex items-center gap-4 mb-6">
  <Button variant="ghost" size="icon" asChild>
    <Link to="/users"><ArrowLeftIcon className="h-5 w-5" /></Link>
  </Button>
  <PageHeader title={user.name} className="mb-0" />
</div>

| Prop | Tipo | Default | Descrição | |---|---|---|---| | title | ReactNode | — | Título da página (h1, text-display-md/lg) | | description | ReactNode | — | Subtítulo (text-sm text-muted-foreground) | | action | ReactNode | — | Conteúdo alinhado à direita (normalmente Button) | | divider | boolean | true | Linha decorativa de 48px entre título e descrição | | className | string | — | Override das classes (útil pra anular mb-8) |

AuthenticatingSplash

Segura a tela enquanto a app decide se existe sessão (troca de ticket de SSO, validação de token, refresh). Sem ela, o guard de rota roda antes da resolução terminar e a pessoa vê um flash do /login seguido de um pulo pro painel.

Não tem props — a frase e o desenho são iguais em todos os produtos, e é isso que faz a entrada parecer o mesmo sistema.

import { AuthenticatingSplash } from 'gpr-ui'

function App() {
  const { isAuthenticating } = useAuth()

  // Antes de qualquer <Routes> ou guard.
  if (isAuthenticating) return <AuthenticatingSplash />

  return <Routes>…</Routes>
}

O estado fica no produto — e é onde quase todo mundo erra

isAuthenticating precisa nascer true de forma síncrona, no inicializador do useState:

// CERTO — já vale no primeiro render
const [isAuthenticating, setIsAuthenticating] = useState(() => precisaResolver())

// ERRADO — o guard já redirecionou antes deste efeito rodar
const [isAuthenticating, setIsAuthenticating] = useState(false)
useEffect(() => { setIsAuthenticating(true) }, [])

O guard roda no primeiro render, antes de qualquer efeito. Começando em false, a pessoa é mandada pro /login no meio da resolução e a splash nunca aparece — ou pisca por um frame.

Desligue com .finally(), para que uma falha também encerre a espera:

trocarTicket()
  .catch(() => setSsoError('Não foi possível entrar pelo portal.'))
  .finally(() => setIsAuthenticating(false))

Sem o .finally, um erro deixa a tela presa no spinner para sempre.

Não use como loading de página, de fetch ou de tabela — para isso há skeleton (DataTable loading) e Button loading. Esta é só a resolução de sessão no boot. E não a coloque dentro do AppShell: ela substitui a árvore inteira, não é conteúdo de página.

Se a app não tem SSO nem checagem assíncrona de sessão, não use — não existiria momento em que ela apareceria.

LoginPage / LoginForm

Tela de login padrão da GPR. Use LoginPage — é uma linha, e o login fica idêntico em todos os produtos.

import { LoginPage } from 'gpr-ui'
import { useNavigate } from 'react-router-dom'

export function Login() {
  const navigate = useNavigate()
  const [erro, setErro] = useState<string | null>(null)

  return (
    <LoginPage
      productName="Authenticator"
      footer="© 2026 GPR — Uso interno autorizado"
      error={erro}
      onForgotPassword={() => navigate('/recuperar-senha')}
      onSubmit={async ({ email, password }) => {
        setErro(null)
        try {
          await api.login(email, password)
          navigate('/painel')
        } catch {
          setErro('Usuário ou senha inválidos.')
        }
      }}
    />
  )
}

Onde fica a fronteira

O kit é dono da apresentação e da mecânica de formulário; o produto é dono da autenticação.

| O kit resolve | O produto resolve | |---|---| | Campos, rótulos, espaçamento, tema | Chamada de API | | Obrigatoriedade e formato de e-mail | Token, sessão, refresh | | Foco no primeiro campo inválido | Rota pós-login | | Estado de carregando durante o submit | Texto do erro de credencial | | aria-invalid / aria-describedby / role="alert" | Regra de bloqueio, MFA, SSO | | autocomplete pro gerenciador de senhas | |

Autenticação não pode morar num design system: muda por produto e envelheceria a biblioteca junto.

Carregando e erro

Se onSubmit devolver uma Promise, o formulário assume o carregando sozinho — não precisa controlar loading. Passe loading só quando o estado vive fora (React Query, Redux).

Se onSubmit rejeitar, o kit para o carregando, chama onError e mostra uma mensagem genérica. A mensagem crua do erro nunca é exibida: numa tela de login ela vira pista pra quem está sondando. Quem escreve a cópia real é o produto, via error.

Login por CPF ou matrícula

<LoginPage
  identifierType="text"
  emailLabel="CPF"
  emailPlaceholder="000.000.000-00"
  onSubmit={…}
/>

Com identifierType="text" só a obrigatoriedade é checada — o formato fica por conta do produto.

Só o formulário

Quando a moldura precisa ser outra (registro, primeiro acesso, convite):

<AuthLayout title="Bem-vindo de volta" description="Continue de onde parou">
  <LoginForm onSubmit={…}>
    <p className="text-center text-sm text-muted-foreground">
      Não tem conta? <Link to="/cadastro">Criar agora</Link>
    </p>
  </LoginForm>
</AuthLayout>

| Prop | Tipo | Default | Descrição | |---|---|---|---| | onSubmit | (c: LoginCredentials) => void \| Promise<void> | — | Obrigatória. Promise ⇒ carregando automático | | error | ReactNode | — | Erro de autenticação, em role="alert" | | onError | (e: unknown) => void | — | Chamado quando onSubmit rejeita (telemetria) | | loading | boolean | — | Carregando controlado; omita se onSubmit é async | | onForgotPassword | () => void | — | Sem isso, o link não aparece | | identifierType | 'email' \| 'text' | 'email' | text desliga a validação de formato | | defaultEmail | string | '' | Valor inicial do primeiro campo | | emailLabel | string | 'E-mail' | | | emailPlaceholder | string | '[email protected]' | | | passwordLabel | string | 'Senha' | | | passwordPlaceholder | string | '••••••••' | | | submitLabel | string | 'Entrar' | | | forgotPasswordLabel | string | 'Esqueci minha senha' | | | requiredMessage | string | 'Campo obrigatório.' | | | invalidEmailMessage | string | 'Informe um e-mail válido.' | | | genericErrorMessage | string | 'Não foi possível entrar agora. Tente novamente.' | | | children | ReactNode | — | Conteúdo extra abaixo do botão |

LoginPage aceita tudo isso mais logo, productName, title, description e footer, repassados pro AuthLayout.

AuthLayout

Shell visual pras páginas de autenticação. Fundo gradiente GPR, watermark do logo atrás e card centralizado. Não tem lógica — é só o chrome.

Pra login, prefira o LoginPage acima. Use o AuthLayout direto quando a tela não for login: recuperar senha, definir nova senha, aceitar convite, primeiro acesso.

import { AuthLayout, Button, Input, Label } from 'gpr-ui'

<AuthLayout
  title="Recuperar acesso"
  description="Enviaremos um link de redefinição pro seu e-mail"
  footer="© 2026 GPR"
>
  <form onSubmit={handleSubmit} className="space-y-5">
    <div className="space-y-2">
      <Label htmlFor="email">E-mail</Label>
      <Input id="email" type="email" autoComplete="email" />
    </div>
    <Button type="submit" size="lg" className="w-full">
      Enviar link
    </Button>
  </form>
</AuthLayout>

Sobrescrever o logo (quando o produto tem identidade visual própria):

<AuthLayout
  logo={<img src="/plan-100-logo.svg" alt="Plan 100" className="h-10" />}
  productName="Plan 100"
  title="Entrar"
>
  {/* form */}
</AuthLayout>

| Prop | Tipo | Default | Descrição | |---|---|---|---| | children | ReactNode | — | Corpo do card (o <form> do consumidor) | | logo | ReactNode | <GprLogo height={40} /> | Logo no topo do card | | productName | string | — | Rótulo discreto abaixo do logo (text-label-sm em gpr-700/gpr-400) | | title | ReactNode | "Acesse sua conta" | Título principal do card | | description | ReactNode | "Faça login para acessar o painel" | Subtítulo abaixo do título | | footer | ReactNode | — | Texto abaixo do card (copyright, links) | | className | string | — | Classes extras no wrapper raiz |

Todas as props (menos children) são opcionais — se omitir, simplesmente não renderiza.

GprLogo

Wordmark oficial da GPR inline (não depende de arquivo em /public). É o logo default do AppShell.

// Uso standalone (login, splash, footer, etc.)
<GprLogo />              // 32px, cor primary
<GprLogo height={48} />  // tamanho customizado
<GprLogo className="text-white" />  // inverter cor (fundo escuro)

Aceita todas as props de <svg> (exceto viewBox e xmlns). A cor usa currentColor — por isso className="text-*" funciona.

Altura default 32px; largura é sempre proporcional (ratio ≈ 1.72). Consumidores não devem ter mais gpr.svg no /public — importam daqui.

LogoDark / LogoWhite / LogoIcon

Logo completo "GPR Educação" (símbolo + wordmark) e o ícone isolado, todos inline (não dependem de arquivo em /public). Os nomes são agnósticos à marca (Dark/White = cor do wordmark, não o fundo) — não mudam se a empresa for renomeada.

O símbolo (mandala) é sempre dourado (token --gold, #a48b58). Só a cor do wordmark muda entre as variantes:

// Wordmark navy (#0b183a) — para fundos claros
<LogoDark />              // 32px de altura
<LogoDark height={48} />

// Wordmark branco (#fff) — para fundos escuros
<LogoWhite />

// Só o símbolo/mandala — monocromático via currentColor (default text-gold)
<LogoIcon />                          // dourado da marca
<LogoIcon className="text-white" />   // branco (fundo escuro)
<LogoIcon className="text-primary" /> // qualquer token
<LogoIcon height={24} />

| Componente | Cor do wordmark | Indicado para | |---|---|---| | LogoDark | navy #0b183a (fixo) | fundos claros | | LogoWhite | branco #fff (fixo) | fundos escuros | | LogoIcon | currentColor (default text-gold) | favicon, avatar, espaços reduzidos |

Todos aceitam as props de <svg> (exceto viewBox/xmlns). Altura default 32px; largura proporcional (logos completos ≈ 6.1; LogoIcon é 1:1). O dourado vem do token --gold — exige o preset + gpr-ui/styles.css no consumidor (já no setup padrão). O wordmark navy/branco é fill inline, então renderiza mesmo sem Tailwind.

ThemeToggle

Botão pronto pra alternar light/dark. Persiste em localStorage, respeita prefers-color-scheme na primeira visita.

<ThemeToggle />

Se precisar controlar o tema fora do botão:

import { useTheme } from 'gpr-ui'

const { theme, toggle, setTheme } = useTheme()

Helpers

import { cn, Slot } from 'gpr-ui'

// cn — merge de classes com tailwind-merge
<div className={cn('p-4', isActive && 'bg-primary', className)} />

// Slot — clona o filho aplicando props/classes (usado internamente pelo asChild)

Tokens semânticos

Cores: bg-primary, bg-primary-solid, bg-secondary, bg-muted, bg-accent, bg-destructive, bg-card, bg-popover, bg-sidebar, text-foreground, text-muted-foreground, border-border, border-input, ring, bg-success-*, bg-warning-*, bg-critical-*, e todas as escalas gpr-{100..900} / success-{100..900} / warning-{100..900} / critical-{100..900}.

primary vs primary-solid — --primary é o azul da marca (#008DFF): use em anel de foco, bordas, ícones e texto sobre fundo claro. Com texto branco em cima ele rende 3.36:1 e reprova o WCAG AA, então toda superfície preenchida (Button default, Badge default, avatar) usa bg-primary-solid (#0074D9 — 4.72:1). Para texto azul, text-primary-solid no light e dark:text-gpr-300 no dark.

Tipografia: text-display-lg/md, text-title-xl/lg/md/sm, text-body-lg/md/sm, text-label-md/sm, text-caption.

Radius: rounded-xs (4px) • rounded-sm (8px) • rounded-md (10px) • rounded-lg (12px) • rounded-xl (12px) • rounded-2xl (16px).

Sombras: shadow-elevation-1 a shadow-elevation-4.

Motion: duration-fast (150ms) • duration-base (200ms) • duration-slow (300ms).

Z-index (ordem estrita de empilhamento, sem empates): z-base (0) • z-raised (10) • z-sticky (30, navbar) • z-overlay (40, backdrop do drawer) • z-sidebar (50) • z-modal-backdrop (60) • z-modal (70) • z-dropdown (80) • z-toast (90) • z-tooltip (100).

z-dropdown fica ACIMA de z-modal de propósito. Popover transitório (Select, MultiSelect, DropdownMenu) quase sempre é disparado de dentro de um modal, e o Radix o portaliza pro <body> — fora da árvore do Dialog. Com a camada de popover abaixo da de modal, o menu abre atrás do painel e não dá pra clicar. Foi o bug da 0.5.0/0.5.1, corrigido na 0.5.2.

A ordem da escala não basta sozinha: o Radix envolve o popover num wrapper com transform, que cria contexto de empilhamento, então o z-dropdown do *Content fica preso lá dentro. Quem empilha no nível do <body> é o wrapper, elevado por uma regra global no styles.css: [data-radix-popper-content-wrapper] { z-index: 80 !important }. O !important é obrigatório porque o Radix escreve esse z-index como estilo inline.

O build tem um guarda (scripts/check-z-scale.js) que quebra se a ordem for invertida ou se o valor do wrapper divergir da camada dropdown.


Dark mode

Ativado adicionando a classe .dark no <html>. O ThemeToggle e o hook useTheme() fazem isso automaticamente. Se preferir controle manual:

document.documentElement.classList.toggle('dark')

As cores --primary (#008DFF) e --primary-solid (#0074D9) não mudam entre temas — só os neutros e superfícies (regra do Brand Book cap. DS-06).

Exceção deliberada: tons de texto que precisam inverter pra manter contraste (text-gpr-700 dark:text-gpr-300, text-destructive dark:text-critical-300). Cor de marca é a mesma; a legibilidade é que muda de fundo.


Regras do design system

  1. Neutros dominam, primary é acento — nunca use bg-primary como fundo de página.
  2. Tokens semânticos, nunca hex — bg-primary, não #008DFF.
  3. Uma ação principal por tela — apenas um Button variant="default".
  4. Active ≠ Hover — active muda cor+peso; hover muda bg.
  5. Dimensões fixas — Input 44px, Button default 36px, SidebarItem 40px.
  6. Focus-visible global — já configurado pelo styles.css (anel de 2px em --ring com 2px de respiro). Não escreva focus:outline-none nos componentes: era isso que apagava o anel em metade do kit.
  7. Contraste é requisito, não preferência — texto abaixo de 18px precisa de 4.5:1. Na dúvida, suba um degrau da rampa (gpr-700 no light, gpr-300 no dark) em vez de usar o tom 500.

Desenvolvimento local

pnpm install
pnpm playground   # preview no browser  → http://localhost:5199
pnpm build        # single build
pnpm dev          # watch mode
pnpm typecheck    # lib + playground

Playground

pnpm playground sobe uma página com todos os componentes montados — é o jeito de ver o kit antes de publicar uma versão.

  • Importa direto de src/ (via alias gpr-ui), então editar um componente ou um token recarrega na hora, sem pnpm build.
  • Os imports são escritos igual aos de um produto consumidor, então o playground também serve de documentação executável.
  • Duas views: o kit dentro do AppShell e a tela de login (AuthLayout).
  • Nada dele é publicado — o files do package.json só manda dist e README.md.

Ao adicionar um componente novo ao kit, adicione também ao playground. Um componente que ninguém consegue ver é um componente que ninguém revisa.

Para testar num projeto consumidor sem publicar:

{
  "dependencies": { "gpr-ui": "file:../gpr-ui" }
}

Ou via link simbólico:

# no gpr-ui
pnpm link --global

# no projeto consumidor
pnpm link --global gpr-ui

Publicando

npm version patch    # ou minor / major
pnpm publish --access public

Changelog

  • 0.5.3 — Aditivo, sem quebra.
    • Novo AuthenticatingSplash: tela de "Entrando…" enquanto a sessão é resolvida (SSO, validação de token). Sem props de propósito. Os produtos que já criaram esse componente localmente podem apagar o arquivo e importar do kit.
    • Fix: spinner congelava com prefers-reduced-motion. A regra global neutralizava todas as animações, inclusive animate-spin — o indicador virava um arco parado, que não parece menos movimento, parece componente quebrado. Agora o giro continua, mais devagar (1,5s). Afeta Button loading, o skeleton do DataTable e a splash.
  • 0.5.2 — Fix de verdade: Select / MultiSelect / DropdownMenu dentro de Dialog. A 0.5.1 reordenou a escala (dropdown 80 > modal 70) mas o bug continuou: o Radix envolve o popover num wrapper com transform, que cria contexto de empilhamento, então o z-dropdown do conteúdo ficava preso lá dentro e o wrapper ficava em auto. Agora o styles.css eleva o wrapper direto: [data-radix-popper-content-wrapper] { z-index: 80 !important }. O !important é necessário porque o Radix escreve esse z-index como estilo inline.
    • Ação nos produtos: se você adicionou o remendo [data-radix-popper-content-wrapper] { z-index: … !important } no CSS da app, remova agora — o kit passou a fazer isso. Mantido com valor diferente de 80, ele tira o popover da escala (ficaria acima de toast e tooltip).
    • Exige import 'gpr-ui/styles.css' no entry (já era obrigatório).
    • O guarda do build passou a conferir que o valor do wrapper bate com a camada dropdown.
  • 0.5.1 — Fix (incompleto — veja 0.5.2): Select / MultiSelect / DropdownMenu abriam ATRÁS do Dialog. Regressão introduzida na 0.5.0. A camada de popover estava em 60 e a de modal em 80; como o Radix portaliza o popover pro <body>, o menu ficava embaixo do painel e não dava pra clicar. A escala foi reordenada: modal-backdrop 60, modal 70, dropdown 80. Nada mais mudou.
    • Ação nos produtos: remova o remendo [data-radix-popper-content-wrapper] { z-index: … !important } do CSS da app. Ele deixou de ser necessário e, mantido, atrapalha (fixa o popover num nível que ignora a escala).
    • Se você fixou z-index próprio contando com z-modal = 80, revise: agora é 70.
    • O build passou a ter um guarda (scripts/check-z-scale.js) que falha se a ordem das camadas for invertida de novo.
  • 0.5.0 — Minor, não patch: não entra sozinho em quem está em ^0.4.x. Suba um produto por vez e confira a tela.
    • Novo: LoginPage / LoginForm (tela de login padrão), MaskedInput + MASKS (CPF, CNPJ, telefone, CEP… ou qualquer formato), Logo (troca com o tema sozinha), pnpm playground.
    • Correções de contraste (WCAG AA): novo --primary-solid pras superfícies preenchidas — o azul da marca dava 3.36:1 com texto branco. Também --success, --warning-foreground, --muted-foreground, --destructive do dark, rodapé e nome do produto no AuthLayout, item ativo da sidebar.
    • Card: padding vertical passou pro <Card> (py-6). Card sem CardFooter não termina mais colado na borda de baixo. ⚠️ Se você contornava isso com pb-* no CardContent, remova.
    • cn() agora usa tailwind-merge — o className que você passa finalmente sobrescreve o do kit. ⚠️ Overrides antes ignorados passam a valer.
    • ⚠️ Z-index remapeado (sem empates): z-dropdown 60, z-modal-backdrop 70, z-modal 80, z-toast 90, z-tooltip 100.
    • Sidebar: item sangra a largura toda; ativo virou lavagem da marca + gpr-700/gpr-300; hover deixou de ser azul cheio no dark.
    • Logo nova é o default do AppShell e do AuthLayout.
    • A11y: skip link, foco visível com respiro, nome acessível no menu colapsado, Esc + trava de scroll no drawer mobile, cabeçalho de tabela ordenável sem perder semântica.
    • ⚠️ size="lg" do Button foi de 40px pra 44px. A Inter não carrega mais os pesos 300/800 (nenhum componente usa — confira se o seu app usa font-light/font-extrabold).
  • 0.4.9 — Fix: ícone do input[type="date"] agora fica no fim do campo (removido display:flex do Input/PasswordInput) + regras de posição/contraste (light + dark) no styles.css. Sem necessidade de override no app.
  • 0.4.8 — Logos da marca "GPR Educação": LogoDark e LogoWhite (símbolo + wordmark) e LogoIcon (só o símbolo). Novo token --gold (#a48b58) e cor gold no preset.
  • 0.3.x — Dialog genérico, Select, StatusBadge, Table (primitivos) e DataTable (com loading/empty/sort/paginação).
  • 0.2.0 — AppShell + Sidebar completo, UserMenu, ThemeToggle. Button ganhou loading + asChild. ConfirmDialog ganhou isLoading. Helper Slot exportado.
  • 0.1.0 — Primitivos iniciais (Button, Input, Label, Badge, Card, ConfirmDialog), preset Tailwind, tokens CSS.

Licença

Uso interno GPR.