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

kaizen-lib

v0.2.22

Published

Tokens y componentes compartidos entre los fronts de Kaizen.

Readme

kaizen-lib

Tokens y componentes compartidos entre los fronts de Kaizen. Un solo paquete, con subpath exports: cada parte se importa por separado y el resto no entra al bundle.

Se publica en TypeScript crudo, sin build. Los consumidores son Next + React 19 + styled-components con moduleResolution: "bundler", así que lo transpilan ellos.

Estado

| Subpath | Qué es | Estado | |---|---|---| | kaizen-lib/tokens | Referencias var(--kz-*) tipadas | listo | | kaizen-lib/tokens.css | Valores por defecto, tema oscuro y claro | listo | | kaizen-lib/ui | Componentes (IconButton, ReactionButton, Medidor, Relieve, LangSelector, Modal, Sheet, Drawer, Toast, Tabs, Tooltip, Popover, Menu, Select, Combobox, Badge, BottomNav, PanelExpandible, Stepper, DiscoHaxball, Skeleton) | en curso | | kaizen-lib/ui | Componentes (IconButton, ReactionButton, Medidor, Relieve, LangSelector, Modal, Badge, BottomNav, PanelExpandible, Stepper, DiscoHaxball, Skeleton) | en curso | | kaizen-lib/efectos | Efectos de landing: Spotlight, Card3D, Beams, StarField, HeroText | en curso |

¿Qué uso para qué?

Antes de escribir un styled.button a mano o un ciclador de idioma propio, revisá esta tabla. Si tu caso está acá, es un defecto reconstruirlo local — usá esto, y si no alcanza, extendé el componente en vez de bypassearlo (ver implement.md del harness: "un duplicado local de algo que ya existe compartido se reemplaza por el compartido").

| Necesito... | Uso | No es... | |---|---|---| | Un botón redondo de sólo ícono en una topbar (notificaciones, perfil, cerrar) | IconButton | — | | Que ese botón tenga fondo teñido cuando está seleccionado (la página actual, un filtro aplicado) | IconButton con active | ReactionButton (ver abajo, distinción a propósito) | | Un ícono que cambia de color al reaccionar (me gusta, guardar, favorito) — sin fondo teñido | ReactionButton | IconButton con active | | El relieve neumórfico (círculo con sombra, sin relleno) alrededor de un IconButton | Relieve envolviendo el IconButton | una prop de IconButton — ver por qué en su docblock | | Un ciclador de idioma con banderas en la topbar | LangSelector | reconstruir el ciclado a mano — ya lo resuelve, con Relieve incluido | | Un panel flotante que bloquea el resto de la página hasta que se resuelve (formulario, confirmación, contenido a pantalla completa) | Modal | un overlay propio — portal, foco atrapado, Escape, scroll bloqueado y anidamiento ya están resueltos ahí | | Un panel que sube desde abajo en el teléfono (elegir de una lista, formulario corto, acciones) | Sheet (alturas auto/media/completa); animado y con cierre por arrastre, AnimatedSheet de kaizen-lib/motion | un overlay propio con keyframes — es el Modal con posicion="abajo" | | Un panel lateral a toda la altura (ficha de detalle, filtros) | Drawer con lado; animado, AnimatedDrawer | un panel deslizante casero sin role ni foco atrapado | | Un arco/medidor que muestra cuánto de un total está ocupado | Medidor | un <progress> o una barra propia | | Una píldora de estado (activo, vencido, un rol, una etiqueta) con fondo lavado y texto del mismo tono | Badge | un styled.span propio por producto — es exactamente lo que ya había triplicado | | La navegación principal fija abajo en el teléfono (ícono + rótulo por destino) | BottomNav, y bottomNavOffset de padding inferior en lo que no debe quedar tapado | una barra propia — ya estaba cuadruplicada | | Un panel de pantalla con título, un número que resume el contenido y un cuerpo que se pliega (sin desmontarse) | PanelExpandible | un <details> o un acordeón propio — el indicador queda a la vista con el panel cerrado, que es lo que justifica plegarlo | | Los pasos de un recorrido (un formulario en etapas, un trámite), con el actual marcado | Stepper; la paleta se pisa con --kz-stepper-* (ver su docblock) | una barra de progreso propia — las líneas se llenan en orden y respeta prefers-reduced-motion | | Pestañas que cambian el contenido de una zona (subrayado o pastilla), con teclado y roles WAI-ARIA | Tabs; con indicador animado, AnimatedTabs de kaizen-lib/motion | una fila de botones propia — aria-controls, flechas, Home/End, roving tabindex y activación manual ya están | | Espaciado, radios, color, tipografía, sombra | kaizen-lib/tokens (space, radius, fg, accent, font, shadow, relief...) | un valor a mano — cada uno es una referencia var(--kz-*) | | Que algo entre y salga animado (un modal, un toast, un panel) | kaizen-lib/motion: AnimatedModal, o un preset (fade, scale, slideUp, slideSide) sobre m.div | importar motion directo o escribir duraciones a mano — los presets leen los tokens de entrada/salida | | Un aviso efímero (guardado, error con reintentar, deshacer) | ToastProvider en la raíz + useToast() (kaizen-lib/ui); animado, AnimatedToastProvider de kaizen-lib/motion | un toast o un contexto propio — las regiones aria-live, la pausa y el teclado ya están | | El lugar de un dato que todavía no llegó (renglones, card, avatar) | Skeleton (variant line/block/circle, lines) | un bloque gris propio — ya anuncia la carga (role="status", aria-busy) y respeta reduced motion | | Un número que cuenta hasta su valor (total, saldo, indicador) | kaizen-lib/motion: AnimatedNumber (formato de Intl.NumberFormat) | un contador con requestAnimationFrame — el lector lee sólo el valor final | | Que algo aparezca al entrar en pantalla, solo o escalonado | kaizen-lib/motion: Reveal (preset, escalonado) | un IntersectionObserver a mano | | Respuesta al apretar/pasar por encima de un botón o card | kaizen-lib/motion: Pressable (as, tap, hover) | un keyframe de scale propio | | Un efecto de landing (halo que sigue al mouse, card que se inclina, fondo de haces o de estrellas, título que aparece por palabras) | kaizen-lib/efectos | copiar efectos de Aceternity o el StarField de taboo/nsandev — los de acá se pausan fuera de pantalla, degradan en táctil y respetan reduced-motion | | Mostrar la ficha/disco de Haxball de un equipo (para elegir un equipo representativo, o para lucir el catálogo de camisetas) | DiscoHaxball + CAMISETAS_HAXBALL; el avatar (texto) se valida al guardarlo con recortarAvatarHaxball | una imagen propia — es una aproximación visual generada por SVG, no requiere ningún asset | | Un globo de ayuda al pasar el puntero o enfocar un control | Tooltip | un title o un tooltip a mano — no se reposiciona en los bordes ni abre con teclado | | Un panel flotante no modal anclado a un botón (filtros, detalle, formulario corto) | Popover | Modal (bloquea la página) | | Un menú de acciones (editar, duplicar, borrar) detrás de un botón, por defecto los tres puntos | Menu | un Popover con botones — no tendría flechas, typeahead ni roles de menú | | Elegir un valor de una lista corta | Select | un <select> estilado a mano | | Elegir un valor de una lista larga tipeando para filtrar | Combobox | un input + lista propios — el activedescendant y el teclado ya están resueltos |

Ningún componente trae íconos ni banderas: los recibe como children/icono, y el producto elige de dónde salen (react-icons, react-country-flag, SVG propio).

Instalación

npm install kaizen-lib

react y styled-components son peerDependencies y las pone el consumidor. Si alguna vez quedan como dependencies, el consumidor termina con dos instancias de styled-components y se rompen el theming y el SSR de un modo difícil de diagnosticar.

En next.config:

const nextConfig = { transpilePackages: ['kaizen-lib'] };

Movimiento (kaizen-lib/motion)

Subpath opcional sobre motion v12. motion es peerDependency opcional: sólo la instala quien importa kaizen-lib/motion; kaizen-lib/ui y kaizen-lib/tokens no la necesitan.

npm install motion

Una vez, cerca de la raíz (en Next, en un layout 'use client'):

import { MotionProvider } from 'kaizen-lib/motion';

<MotionProvider>{children}</MotionProvider>

Carga sólo domAnimation en modo strict (hay que usar m.*, no motion.*) y respeta prefers-reduced-motion: con la preferencia activa quedan los fades y se apagan los desplazamientos y escalas. Layout animation (layout, layoutId) necesita domMax: la pieza que la use la carga en diferido con su propio <LazyMotion features={() => import('motion/react').then((m) => m.domMax)}> (ver el docblock de MotionProvider).

Presets. fade(), scale(), slideUp(), slideSide('left' | 'right'). Cada uno devuelve { initial, animate, exit } para esparcir sobre un m.* dentro de AnimatePresence; ambos se reexportan desde el subpath:

import { AnimatePresence, m, slideSide } from 'kaizen-lib/motion';

<AnimatePresence>
  {visible ? <m.div key="aviso" {...slideSide('right')}>Guardado</m.div> : null}
</AnimatePresence>

Duraciones y curvas salen de --kz-duration-enter/-exit y --kz-ease-enter/-exit, leídas del CSS en ejecución: si el producto pisa el token, la animación lo sigue. Para un caso puntual, fade({ tiempos: { enter: 0.4 } }).

AnimatedModal. Las mismas props que Modal más entrada?: 'scale' | 'slideUp' (default scale). Fondo en fade, panel según entrada, y el desmontaje espera a que termine la salida. Foco, Escape, pila y disableClose son los de Modal. El Modal de kaizen-lib/ui sigue igual sin motion; la animación entra por su prop animacion (ModalAnimacion), que arma AnimatedModal.

AnimatedToastProvider. Las mismas props que ToastProvider (position, max, closeLabel). Cada toast entra y sale con slideSide('right') (o slideUp en bottom-center) y la pila se reacomoda con layout; domMax se carga en diferido sólo ahí. useToast() es el mismo hook. Sin motion, ToastProvider de kaizen-lib/ui hace lo mismo sin animar (adaptador ToastAnimacion, como ModalAnimacion). Tonos: info, success, warning (toast.warning('Tu sesión expiró'), región polite) y error (región assertive). Para no tapar barras fijas, offsetTop y offsetBottom aceptan cualquier largo CSS (offsetBottom="var(--bottom-nav-height)"); se suman al margen de la pila. Default: env(safe-area-inset-top|bottom, 0px); al pasarlos se reemplaza, así que si tu barra no incluye el safe-area, sumalo con calc. AnimatedSheet y AnimatedDrawer. Las props de Sheet/Drawer. El sheet entra desde abajo (desdeBorde('bottom')) y trae un tirador: arrastrarlo hacia abajo más de un cuarto del alto (tope 160px) o con un tirón rápido cierra (cierraPorArrastre). arrastrable={false} lo saca; con disableClose no cierra. El drawer entra por su costado (desdeBorde('left' | 'right')).

Efectos de landing (kaizen-lib/efectos)

Subpath propio para que su peso no caiga en ui ni en motion. No necesita motion ni MotionProvider: todo es CSS (transform, gradientes, SVG) y unas pocas líneas de JS que escriben variables CSS sin re-render.

import { Spotlight, Card3D, Beams, StarField, HeroText } from 'kaizen-lib/efectos';

<Spotlight style={{ borderRadius: 16 }}>…sección…</Spotlight>
<Card3D brillo max={6}>…card…</Card3D>
<section style={{ position: 'relative' }}><Beams cantidad={6} />…</section>
<StarField modo="fijo" color="#bb7fff" />
<HeroText texto={t('hero.title')} por="palabra" />
<HeroText texto={t('hero.title')} gradiente="linear-gradient(90deg, var(--a), var(--b))" />

HeroText con texto en gradiente: no pongas background-clip: text en el padre (las piezas inline-block animadas lo rompen); pasá gradiente. Cada pieza pinta su tramo del gradiente del título entero, medido sin re-render y re-medido al cambiar de tamaño, así corre continuo y no se repite por palabra.

| Efecto | Táctil (sin hover) | Reduced-motion | Fuera de pantalla / pestaña oculta | Peso (min / gzip) | |---|---|---|---|---| | Spotlight | halo quieto en reposo (o apagado con reposo={null}) | igual que táctil | sin trabajo: sólo reacciona a pointermove, una escritura por frame | 2.2 KB / 1.1 KB | | Card3D | plana, sin listeners | plana, sin brillo | sin trabajo | 2.7 KB / 1.2 KB | | Beams | igual | sólo las guías, sin pulso | animación CSS en pausa | 2.4 KB / 1.2 KB | | StarField | igual | estrellas quietas | animación CSS en pausa | 2.4 KB / 1.2 KB | | HeroText | igual | texto final, sin animar | anima una vez, al entrar en pantalla | 2.2 KB / 1.1 KB |

Pesos medidos con rolldown (minificado, react y styled-components externos); cada fila incluye el helper compartido. El subpath entero: 10.3 KB / 3.7 KB.

StarField reemplaza al componente copiado en taboo y nsandev: misma distribución gaussiana, pero en un solo SVG con tres grupos que titilan (no una animación por estrella) y posiciones por semilla, sin render vacío antes de montar.

Camisetas (kaizen-lib/camisetas)

Módulo puro, sin React: el catálogo CAMISETAS_HAXBALL (1 a 3 franjas, ángulo), distanciaCamisetas (el par de colores más parecido, portado de kaizen-bot), rivalQueContrasta(nombre, { semilla, excluir }) (determinista) y esCamiseta. Es el único subpath que además se publica compilado a CommonJS en dist/camisetas (npm run build, corre solo en prepublishOnly), para servidores Node que no transpilan el paquete. kaizen-lib/ui sigue reexportando el catálogo.

Tokens

El contrato es uno solo: los nombres. Los valores los define cada producto.

import styled from 'styled-components';
import { surface, fg, space, radius } from 'kaizen-lib/tokens';

const Card = styled.div`
  background: ${surface[2]};
  color: ${fg.DEFAULT};
  padding: ${space[4]};
  border-radius: ${radius.md};
`;
/* globals.css — opcional: valores por defecto, después los pisás con los tuyos */
@import 'kaizen-lib/tokens.css';

:root {
  --kz-accent: #e8318f; /* la marca del producto */
}

Adopción parcial. Si un producto redefine sólo algunos tokens, el resto cae al valor por defecto. Si no importa tokens.css y no define un token que un componente usa, el estilo simplemente no se aplica — no hay error. Por eso los tests exigen que todo token de la API esté definido en defaults.css.

Tema. Oscuro en :root; claro cuando <html> lleva la clase light. Es el mecanismo de tutipoker-school-front, el único consumidor con toggle real.

Elevación. Cuatro niveles de uso fijo: 0 página, 1 panel, 2 card, 3 overlay. sunken es el pozo (inputs, tracks) y va más oscuro que su contenedor.

Componentes

import { IconButton } from 'kaizen-lib/ui';

<IconButton label="Cambiar tema" size="lg" onClick={alternar}>
  <IconoLuna />
</IconButton>

<IconButton as={Link} href="/notificaciones" label="Notificaciones, 3 sin leer" badge={3}>
  <IconoCampana />
</IconButton>

IconButton es el botón redondo de sólo ícono de una topbar. label es obligatorio (es el nombre accesible). Con badge dibuja un contador, y ese número es decorativo: la cuenta va dentro del label, porque el idioma no es asunto de la librería. Con as toma otro elemento —un <Link> de Next— sin que la librería dependa de Next. size="lg" es el área táctil de 44px. Con motivo queda bloqueado pero enfocable, para poder mostrar por qué.

La librería no trae íconos: cada producto usa los suyos.

import { ReactionButton } from 'kaizen-lib/ui';

<ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
  <IconoCorazon />
</ReactionButton>

ReactionButton es un ícono que cambia de color al reaccionar (tone: neutral | accent | success | danger | warning | info), con resplandor opcional (glow, del mismo color vía currentColor) y una cuenta al lado (count; 0 se muestra, no es una ausencia). No es IconButton con más props: nunca tiñe el fondo. IconButton es "esto está seleccionado" (la página actual, un filtro aplicado) y por eso tiñe el círculo entero; ReactionButton es "reaccioné con esto" (un me gusta, un guardado), y ahí sólo el ícono cambia — un corazón marcado no necesita, además, un círculo rosa detrás.

Un color que los seis tonos no cubren —la marca de un producto, no una categoría semántica— se pisa con style, porque el color activo sale de la variable CSS --kz-icon-on y un estilo en línea le gana a la regla de la librería:

<ReactionButton
  active={guardado}
  style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
  label="Guardar"
>
  <IconoMarcador />
</ReactionButton>
import { Medidor } from 'kaizen-lib/ui';

<Medidor valor={8} maximo={10} sentido="menos-es-mejor" label="Anotados: 8 de 10" />

Medidor es un arco que dice cuánto de un total está ocupado, pintado por escalón. No es un control: es un role="img", así que si el dato tiene que llevar a algún lado lo envuelve un link o un botón del producto. label es obligatorio y va en palabras — el arco y el número del centro son decorativos, y el color nunca es el único portador del significado.

Los cuatro escalones salen de la proporción valor / maximo, no del valor (8 no significa lo mismo sobre 10 que sobre 22). Sobre una escala de 0 a 10: 10 tope, 8 a 9,99 alto, 4 a 7,99 medio, menos de 4 bajo. Cada corte es inclusivo abajo.

sentido es obligatorio y decide qué extremo es el bueno, porque eso lo define lo que el número significa en el producto y no el componente:

| Escalón | menos-es-mejor | mas-es-mejor | |---|---|---| | bajo (< 40%) | verde | rojo | | medio (40–79%) | azul | naranja | | alto (80–99%) | naranja | azul | | tope (100%) | rojo | verde |

menos-es-mejor es "cuánto lugar queda": vacío es una oportunidad, lleno una puerta cerrada. mas-es-mejor es un progreso o una meta. Usa los tonos success, info, warning y danger, así que un producto que ya los definió no declara nada nuevo. El centro muestra valor/maximo y se reemplaza con children cuando el formato es del producto.

import { IconButton, Relieve } from 'kaizen-lib/ui';
import Link from 'next/link';

<Relieve>
  <IconButton label="Notificaciones, 3 sin leer" badge={3}><IconoCampana /></IconButton>
</Relieve>

<Relieve $prendido={enPerfil}>
  <IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil"><IconoPerfil /></IconButton>
</Relieve>

Relieve envuelve a IconButton con el relieve neumórfico: un círculo sin relleno ni borde, dibujado por un par de sombras opuestas (--kz-relief-out) que se invierten al presionar (--kz-relief-in) o con $prendido (el equivalente sostenido de "esto está activo" o "vos estás acá"). Es un <span> alrededor del control y no un styled(IconButton): IconButton es polimórfico (as), y styled-components intercepta el as de cualquier componente que envuelve, así que envolverlo así lo haría saltear toda su lógica. El relieve es neutro por defecto (negro/blanco) — un producto con marca lo tiñe redefiniendo esos tres tokens, igual que con cualquier otro.

import { LangSelector } from 'kaizen-lib/ui';

<LangSelector
  actual={i18n.language}
  onChange={(codigo) => i18n.changeLanguage(codigo)}
  etiqueta={(siguiente) => `Cambiar idioma a ${siguiente.nombre}`}
  opciones={[
    { codigo: 'es', nombre: 'Español', icono: <ReactCountryFlag countryCode="AR" /> },
    { codigo: 'en', nombre: 'English', icono: <ReactCountryFlag countryCode="US" /> },
  ]}
/>

LangSelector es el botón de idioma de una topbar: cicla a la siguiente opción en cada tap y muestra el ícono del idioma actual, con el relieve de Relieve ya incluido (a diferencia de IconButton, acá no hace falta envolverlo aparte — todo consumidor lo quería). No trae banderas — mismo criterio que IconButton no trae íconos —, así que icono es cosa del producto; react-country-flag (ya usado en tuxon-front) es una opción probada. etiqueta arma el nombre accesible a partir de la opción siguiente y la actual — es obligatoria y sin default: la frase y el idioma son del producto (dos ya difieren: uno anuncia el destino, "Cambiar idioma a English"; otro el estado, "Idioma: English. Cambiar idioma"). onChange recibe el código de la opción siguiente; qué hacer con eso (i18n.changeLanguage, next-intl, lo que sea) es del producto — la librería no sabe qué mecanismo de traducción hay atrás.

import { Modal } from 'kaizen-lib/ui';

<Modal
  open={abierto}
  onClose={cerrar}
  title="Eliminar cepa"
  description="Esta acción no se puede deshacer."
  footer={<Acciones><Boton onClick={cerrar}>Cancelar</Boton><Boton onClick={eliminar}>Eliminar</Boton></Acciones>}
>
  <p>Se van a borrar también sus lotes asociados.</p>
</Modal>

Modal es el shell único de un panel flotante: portalea a document.body, atrapa el foco y lo devuelve a quien lo abrió al cerrar, cierra con Escape/click afuera/la X, y bloquea el scroll del fondo. Lleva una pila interna para el anidamiento real (un modal de confirmación abierto encima de un formulario): sólo el de más arriba atiende Escape, y el scroll se libera recién cuando se cierra el último.

Sin title no hay nombre accesible propio — pasá ariaLabel. size (sm|md|lg|xl) fija el ancho máximo del panel; maxWidth lo pisa con un valor explícito. disableClose bloquea la X, el overlay y Escape a la vez — para una operación en curso, no para "no quiero que cierre nunca". dismissOnOverlay={false} sólo desactiva el click afuera, para un formulario a medio llenar que sigue queriendo cerrar con la X o Escape. closeLabel (default 'Cerrar') es el nombre accesible de la X — mismo criterio que etiqueta de LangSelector: un consumidor con más de un idioma lo pisa, el resto no lo toca.

Lo que no trae: un placement lateral (drawer) y un padding compacto de cuerpo son casos de un solo consumidor cada uno (components/ui/Dialog de valle-verde) — se resuelven ahí con un wrapper local hasta que un segundo consumidor los pida acá.

import { Badge } from 'kaizen-lib/ui';

<Badge tone="success">Activo</Badge>
<Badge tone="danger" bordered>Vencido</Badge>

Badge es una píldora de estado: fondo lavado + texto del mismo tono (neutral | accent | success | danger | warning | info, default neutral), mayúsculas apretadas. Elicitado de tres implementaciones ya duplicadas (taboo-next, valle-verde, kaizen-next) — el look es el de valle-verde. bordered agrega un borde de 1px del color pleno del tono, para cuando el fondo lavado solo no alcanza de contraste contra la superficie de atrás; no es una segunda estética, es el mismo tono con más peso.

import Link from 'next/link';
import { BottomNav, bottomNavOffset } from 'kaizen-lib/ui';

<BottomNav
  items={[{ id: 'inicio', label: 'Inicio', icon: <IoHomeOutline />, href: '/' }, ...]}
  activeId={activo}
  maxWidth={640}
  ariaLabel="Secciones"
  linkAs={Link}
/>

const Contenido = styled.main`padding-bottom: ${bottomNavOffset};`;

BottomNav es la barra de navegación fija abajo del teléfono: ícono + rótulo por destino, el activo con color de acento y una franja arriba (no sólo color). Elicitada de cuatro barras ya duplicadas (mixbol, valle-verde, tuxon, platenzen). Existe hasta maxWidth px y arriba se apaga por CSS, sin desmontarse, para no parpadear al hidratar.

Qué decide el producto: activeId (si un destino matchea exacto o por prefijo es regla de sus rutas), linkAs (su router; sin él, <a>), y ariaLabel (su idioma, sin default). onSelect(item, event) se llama antes de navegar; con event.preventDefault() el destino hace otra cosa sin dejar de ser un link.

Mientras se ve, publica --kz-bottom-nav-offset (su alto más el inset inferior del dispositivo). La barra es fija, así que el contenido lleva bottomNavOffset de padding inferior y lo que flota (FAB, avisos) lo suma a su bottom; donde la barra no está, vale 0px.

import { PanelExpandible } from 'kaizen-lib/ui';

<PanelExpandible titulo="Comprometido" indicador="30 g" leyenda="faltan entregar">
  <Tabla />
</PanelExpandible>

PanelExpandible es un panel de pantalla: cabecera con el título y un indicador (indicador + leyenda, o varios con indicadores), y un cuerpo que se pliega. Plegado no desmonta: el contenido conserva filtros y selección. El control accesible es el chevron (aria-expanded); toda la cabecera responde al click. Controlado (abierto + onCambiar) o no (abiertoInicial). Con plegable={false} conserva el formato sin chevron, para una pantalla de un solo panel. duracionMs (default 300) fija la animación y el momento en que el cuerpo se oculta. etiquetaPlegar/etiquetaDesplegar traducen el nombre del chevron. La columna del plegable es minmax(0, 1fr): una tabla ancha adentro se compacta o scrollea, no empuja el panel fuera de la pantalla.

Flotantes: Tooltip, Popover, Menu, Select, Combobox

Construidos sobre Base UI (@base-ui/react, dependencia de la lib: el consumidor no la instala). Base UI pone el comportamiento —posición con volteo, portal, foco, teclado, roles ARIA— y la lib sólo el estilo con tokens --kz-*. La entrada y salida son una transición CSS de opacidad y escala, apagada con prefers-reduced-motion: reduce. Los cinco quedan por encima de Modal, así que se pueden abrir dentro de uno.

import { Combobox, Menu, Popover, Select, Tooltip } from 'kaizen-lib/ui';

<Tooltip content="Copiar enlace"><IconButton aria-label="Copiar enlace">…</IconButton></Tooltip>

<Popover trigger={<button>Filtros</button>} title="Filtrar">
  {({ close }) => <Filtros onAplicar={close} />}
</Popover>

<Menu label="Acciones" items={[{ label: 'Editar', onSelect: editar }, { label: 'Borrar', onSelect: borrar, danger: true }]} />

<Select label="Orden" value={orden} onChange={setOrden} options={[{ value: 'nombre', label: 'Nombre' }]} />

<Combobox label="Paciente" placeholder="Por asignar" value={id} onChange={setId}
  options={pacientes.map((p) => ({ value: p.id, label: p.nombre, searchText: p.email }))} />
  • Todo disparador (children de Tooltip, trigger de Popover y Menu) es un elemento que acepte ref y props. El tooltip es visual: el disparador lleva su propio nombre accesible.
  • Select y Combobox comparten SelectOption (value, label, disabled; searchText sólo lo usa Combobox). label es el nombre accesible. Combobox filtra sin acentos ni mayúsculas sobre label + searchText, resalta la primera coincidencia y se limpia con su botón (onChange(null)).
  • Peso: importar uno solo suma entre ~35 kB (Tooltip) y ~63 kB (Menu) gzip al bundle del cliente; los cinco juntos, ~97 kB, porque comparten el núcleo de Base UI. Quien no los importa no paga nada: kaizen-lib/ui no tiene efectos secundarios y el bundler los descarta.

Desarrollo

npm run check   # typecheck + tests

Los tests verifican dos cosas:

  • Paridad (tokens.test.ts): todo token de la API está definido en el CSS y todo token del CSS está en la API. El tema claro no inventa tokens ni deja colores sin redefinir.
  • Contraste (defaults.test.ts): los pares texto/fondo de la paleta por defecto cumplen WCAG 2.1 (4.5:1 texto, 3:1 no textual), en los dos temas.

Si cambiás un color, el número lo da la suite, no el ojo.

Licencia

MIT