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

@kitoep/admin-kit

v0.13.0

Published

Kit de design system para paneles administrativos en Vue 3 — tokens, tema claro/oscuro, componentes, charts y tablas ya resueltos.

Downloads

225

Readme

Admin Kit

npm license

Design system para paneles administrativos en Vue 3 + TypeScript, ya resuelto: tokens, tema claro/oscuro, componentes tematizados, charts, tablas y feedback. Se instala como una librería normal (npm install @kitoep/admin-kit) y este repo trae, además, una app demo completa que muestra todo en vivo.


Qué trae

El lenguaje visual: lienzo gris con cards flotantes de radio 16, sidebar con item activo en pill de marca y chip de icono, controles en cápsula de 10px, pills de estado con puntito, charts con gradiente de marca y tooltip oscuro. Claro y oscuro con el mismo sistema de tokens. Todo en español.

Y es white-label de verdad: color, tipografía, forma y densidad salen de UN juego de semillas que pasa por makeTheme(). Cambiar de identidad es escribir ~15 valores, no buscar hex por el repo — hay una prueba que falla si aparece un color literal fuera del archivo de semillas, y otra que audita el contraste WCAG de todos los pares texto/fondo del sistema. Ver §Crea tu propio tema.

Y trae doctrina, no solo piezas: una jerarquía de acciones escrita y aplicable en una línea (ACCION_PRIMARIA / ACCION_SECUNDARIA / ACCION_DE_FILA) y una escala de superficie que impide que el hover de una fila se coma los controles que viven en ella. Las dos con pruebas que fallan si se rompen.

La app demo (4 pantallas):

| Pantalla | Qué demuestra | |---|---| | Dashboard | 4 KPIs con sparkline full-bleed, chart de barras conmutable (simple / apilado), tabla de últimos pedidos con sort y filtro en popover | | Usuarios | Lista completa: búsqueda, chip de filtro por estado, sort, paginación, modal de edición con validación y toast al guardar | | Configuración | Formulario largo: inputs, selects, switches, date/time picker, slider, y barra de acciones con estado "sin guardar" | | Login | Fuera del shell, con proveedor de auth stub ("Entrar (demo)") |

El escaparate del design system (/componentes/*), diez páginas donde cada variante vive en su card con el componente vivo, controles que lo mutan en el momento (disabled, loading, tamaño, densidad) y un toggle "Ver código" con el snippet de uso:

botones · formularios · tablas · charts · feedback · navegación · datos · datos avanzados · colores · iconos · tipografía

El configurador de tema vive aparte, en /tema (item de primer nivel del menú, no una familia de componentes): presets de white-label, todas las semillas editables en secciones colapsables y, en un panel pegado, la vista previa en vivo junto a la auditoría de contraste del tema activo — la misma función que corre en las pruebas.

En el menú, esas páginas cuelgan de Componentes; las pantallas de muestra (dashboard, usuarios, configuración) y las plantillas de página cuelgan de Ejemplos.

Plantillas listas para copiar (/paginas/*): perfil de usuario con tabs, bitácora de actividad agrupada por día, tabla de precios de 3 planes, y una página vacía que ES el esqueleto correcto de una vista nueva (con su snippet).

Fuera del shell: registro, recuperar contraseña, 404, 500 y mantenimiento.


La app demo

Para ver el kit en vivo, clona el repo y corre la demo incluida:

git clone https://github.com/kitoep/admin-kit.git
cd admin-kit
npm install
npm run dev        # http://localhost:5173

El login de la demo es un stub local: el botón "Entrar (demo)" no valida nada. (Para conectar tu propio proveedor de auth en un fork del repo, ver AGENTS.md.)


Usarlo en tu proyecto

src/kit/ se publica como paquete público @kitoep/admin-kit en npmjs.com, licencia MIT. src/app/ (esta demo) NUNCA se publica — es solo el entorno donde el kit se desarrolla y se ve vivo.

Instalar

npm install @kitoep/admin-kit

Sin .npmrc, sin tokens, sin registro aparte — es un paquete público normal de npmjs.com.

Los peers reales del kit (vue, vue-router, naive-ui, @visactor/vchart, @hugeicons/vue, @hugeicons/core-free-icons) se instalan aparte si tu proyecto no los tiene ya — npm te avisa cuáles faltan. pinia y @fontsource/* no son peers: la app demo los usa para su propio store de sesión y para autohospedar las fuentes, pero el kit no depende de ninguno de los dos en tiempo de ejecución (ver AGENTS.md §Frontera publicable). Si quieres las mismas tipografías que la demo (Plus Jakarta Sans / Lexend), instala @fontsource/plus-jakarta-sans y @fontsource/lexend tú mismo e impórtalas antes que el resto de tu CSS — los tokens del kit solo referencian el nombre de familia, no cargan el archivo de fuente.

Setup mínimo

// main.ts
import { createApp } from "vue"
import naive from "naive-ui" // o registro selectivo de componentes
import { makeTheme, esmeraldaSeeds, applyThemeCss, createAdminKit } from "@kitoep/admin-kit"
import "@kitoep/admin-kit/styles.css" // tokens + microinteracciones — import explícito, no es automático
import App from "./App.vue"

createAdminKit() // locale/moneda/textos — opcional, default español/MXN. Ver §i18n
applyThemeCss(makeTheme(esmeraldaSeeds)) // o tus propias semillas — ver §Crea tu propio tema

createApp(App).use(naive).mount("#app")

Desde ahí, cualquier componente (KitCard, DataTable, KitChart, KpiTile, StatusTag, …) y builder de chart (buildBarSpec, buildLineSpec, …) se importa igual que arriba, del barril @kitoep/admin-kit.

i18n — createAdminKit({ locale, currency, messages, formatters })

El problema que resuelve (v0.10.0): hasta la v0.9.0 el kit hablaba español/es-MX en tres sitios distintos, sin relación entre ellos — formatDate/formatMoney con un const LOCALE = "es-MX" fijo, el locale de Naive UI (paginación, placeholders, meses del DatePicker…) a cargo de cada consumidor en su propio NConfigProvider, y el copy propio del kit ("Sin filas para este filtro.", "Copiar", "Salir", …) escrito a mano en cada componente. Un consumidor que copiara el NConfigProvider de este README sin querer se quedaba con paginación en INGLÉS (default de Naive) mezclada con el emptyMessage del kit en ESPAÑOL, en la misma tabla.

createAdminKit() es la entrada única. Se llama UNA vez, antes de montar (igual que applyThemeCss):

import { createAdminKit } from "@kitoep/admin-kit"

createAdminKit({
  locale: "en-US", // Intl (fechas, números, moneda) + locale de Naive, por idioma
  currency: "USD", // default de formatMoney() cuando una vista no pasa uno explícito
  messages: {
    // sobreescribe SOLO lo que necesites — merge profundo con el paquete del idioma
    dataTable: { empty: "Nothing here yet." },
  },
})
  • locale (default "es-MX") resuelve DOS cosas a la vez: los formatos de Intl que usa format.ts (formatDate, formatMoney, formatWhen, …) — cualquier BCP-47 funciona, Intl no tiene un set curado — y, por PREFIJO de idioma, el objeto de locale de Naive UI (esAR/enUS/frFR/deDE/ptBR curados; cualquier otro cae a enUS con un aviso en consola, o pásalo tú con naiveLocaleOverride).
  • currency (default "MXN") es el default de formatMoney() — una vista lo sigue pudiendo pisar (formatMoney(valor, "EUR")).
  • messages sobreescribe cualquier texto propio del kit (DataTable vacío, FourStates, el shell de AppShell, CodeBlock, ThemeToggle, el mapa coroplético, el copy relativo de formatWhen). El locale también elige el PAQUETE base de estos textos (español/inglés curados hoy — MESSAGE_PACKS; otro idioma hereda el paquete en español salvo que lo sobreescribas tú, ver KitMessages en los tipos). No hace falta declarar el árbol completo: solo la rama que cambias.

Sin llamarla, nada cambia — el default es exactamente lo que veía cualquier consumidor antes de v0.10.0 (español, MXN, los mismos textos). Retro-compatible, MINOR.

Lo que NO cubre (a propósito, ver docs/i18n/messages.ts del código fuente): los nombres de pares/reglas de theme/audit.ts — son mensajes para quien arma un tema, nunca los ve el usuario final — y src/app/ de este repo (la demo), que se queda en español a propósito: es el consumidor de muestra, no el paquete.

formatters — fecha propia, con su zona horaria (v0.12.0)

El problema que resuelve: locale mueve el PATRÓN de formatDate ("12 jul 2026" → "Jul 12, 2026"), pero no toca la ZONA horaria — Intl con cualquier locale sigue formateando en la zona del NAVEGADOR de quien mira la pantalla. Un consumidor que guarda todo en UTC y necesita presentar siempre en, por ejemplo, America/Monterrey (para que la misma fila de un reporte no se lea distinto según desde dónde la abra cada quien) no lo resuelve con locale: necesita Intl.DateTimeFormat con timeZone explícito, o su propia librería de fechas.

import { createAdminKit } from "@kitoep/admin-kit"

// Ejemplo: un backend que guarda todo en UTC y siempre presenta en
// America/Monterrey, con ISO propio en vez del "12 jul 2026" del kit.
const MONTERREY = "America/Monterrey"

createAdminKit({
  locale: "es-MX",
  formatters: {
    date: (value) =>
      new Intl.DateTimeFormat("es-MX", { timeZone: MONTERREY, dateStyle: "short" }).format(value),
    dateTime: (value) =>
      new Intl.DateTimeFormat("es-MX", {
        timeZone: MONTERREY,
        dateStyle: "short",
        timeStyle: "medium",
      }).format(value),
    // `when` reemplaza formatWhen COMPLETO — recibe el mismo segundo
    // argumento (`now`) que ya acepta la función del kit.
    when: (value, now) => miTextoRelativoConZona(value, now, MONTERREY),
  },
})
  • date/dateTime/when reciben la fecha CRUDA: un Date ya parseado (nunca Invalid Date — eso lo sigue resolviendo el kit con el guion de siempre, antes de llamar a tu función), pero nunca ya formateada ni ya convertida de zona. Es la decisión de diseño que importa: si el kit te diera un string o una fecha ya movida a una zona, perderías la mitad del problema (patrón O zona, nunca los dos). Con el Date crudo resuelves ambos en un solo lugar.
  • Merge parcial, igual que messages: si solo registras dateTime, date y when se quedan siendo los del kit ("12 jul 2026" / relativo <24h).
  • No cambia el default. formatDate/formatDateTime del kit siguen siendo exactamente "12 jul 2026" / "12 jul 2026, 14:32" para quien no registra nada — es una costura, no un rediseño del formato.

AppShell — logo propio (#logo / #logo-collapsed)

El área de marca del sidebar (arriba a la izquierda) acepta tu logo por slot en vez del wordmark de muestra ("admin kit") que trae el kit por default:

<AppShell :nav-groups="navGroups" :user="user" @logout="onLogout">
  <!-- Expandido: cabe un <img>, un SVG inline o texto propio -->
  <template #logo>
    <img src="/mi-logo.svg" alt="Mi Marca" style="height: 100%" />
  </template>

  <!-- Opcional. Riel contraído (64px, solo el ícono). Si no lo das, el riel
       reusa `#logo` acotado a 28×28 — no hace falta duplicar el <img> si tu
       logo ya se ve bien recortado cuadrado. -->
  <template #logo-collapsed>
    <img src="/mi-icono.svg" alt="Mi Marca" style="height: 100%" />
  </template>
</AppShell>

Contrato del área: el shell decide el alto disponible (28px) y la alineación (fila, centrado en riel); el theming del contenedor — fondo, padding, borde — sigue siendo del shell, no del slot. Lo que entra por el slot se acomoda dentro con object-fit: contain sin romper el layout del sidebar. Sin ningún slot, cae al wordmark default. Ejemplo en vivo con "Ver código" en la galería (/componentes/navegacion de la app demo).

Barras: gradiente y énfasis (gradient / emphasizeMax)

buildBarSpec, buildStackedBarSpec y buildHorizontalBarSpec (ranking) aceptan dos perillas:

| Opción | Qué hace | Default | |---|---|---| | gradient | Relleno en degradado (vertical en buildBarSpec/apiladas, horizontal en el ranking) del color de la serie, en vez de tono sólido | true en buildBarSpec (el lenguaje visual de siempre); false en apiladas y ranking (tampoco rompe nada — antes ni existía la opción) | | emphasizeMax | La barra (o, en apiladas, la COLUMNA completa) con el valor más alto sale en un tono más intenso | true en buildBarSpec (alias del histórico highlightMax, que sigue funcionando); false en apiladas y ranking |

buildBarSpec(salesByDay, colors, { gradient: true, emphasizeMax: true }) // default de buildBarSpec
buildStackedBarSpec(labels, series, { gradient: true, emphasizeMax: true }) // opt-in
buildHorizontalBarSpec(topSellers, colors.brand, { gradient: true, emphasizeMax: true }) // opt-in

Los defaults son los de siempre en cada builder — ningún consumidor existente ve cambiar su chart al actualizar. Ejemplos con switches on/off para los tres builders en /componentes/charts de la app demo.

Mapas (@kitoep/admin-kit/maps) — sub-export opcional

El chart coroplético (regiones coloreadas por valor) vive en un entry separado del paquete, no en el barril principal — si nunca haces import ... from "@kitoep/admin-kit/maps", el registro del chart de mapa de VChart y los GeoJSON de abajo pesan cero en tu bundle.

npm install @kitoep/admin-kit   # ya incluye el sub-export, no es un paquete aparte
import {
  ChoroplethMap,
  MEXICO_STATES_MAP,
  MEXICO_SALES_MOCK,
  registerMexicoStatesMap,
} from "@kitoep/admin-kit/maps"

// Una vez, antes de montar el primer mapa (p. ej. en main.ts):
registerMexicoStatesMap()
<ChoroplethMap
  :data="ventasPorEstado"
  :map="MEXICO_STATES_MAP"
  series-name="Ventas (MDP)"
/>

ChoroplethMap es el componente "batteries-included" (chart + leyenda de rango). buildChoroplethSpec es el mismo builder puro por si prefieres tu propio KitChart + layout — ambos documentados y con "Ver código" en la galería (/componentes/mapas de la app demo).

Geografías incluidas — GeoJSON derivado de Natural Earth (dominio público, sin restricciones de uso) vía la conversión martynafford/natural-earth-geojson (licencia CC0), simplificado con mapshaper para bajar de peso:

| Geografía | Key de registro | Peso (sin gzip) | |---|---|---| | México por estados (32 entidades) | MEXICO_STATES_MAP | ~70 KB | | Mundo por países (177 países) | WORLD_COUNTRIES_MAP | ~60 KB |

Geografía propia — registerMapData(name, geoJson) registra cualquier FeatureCollection (una propiedad de nombre por región) bajo el name que le des; es la misma función que usan por dentro las dos de arriba:

import { registerMapData, buildChoroplethSpec } from "@kitoep/admin-kit/maps"

registerMapData("mis-sucursales", miGeoJson)
buildChoroplethSpec(datos, { map: "mis-sucursales", ramp: colors.ramp })

No tienes que preocuparte por el sentido de giro de los anillos: registerMapData normaliza el GeoJSON (rewindGeoJson, también exportado) al que espera el motor de proyección de VChart — exterior horario, huecos antihorarios, o sea el CONTRARIO del RFC 7946 que emiten mapshaper, ogr2ogr, PostGIS y compañía. Sin eso d3-geo lee cada polígono como "toda la esfera menos esta forma" y el mapa sale como un rectángulo sólido de un solo color, sin un solo error en consola.

Publicar una versión

Solo para quien mantiene este repo (el dueño):

npm version patch   # o minor/major — actualiza package.json y crea el tag
git push && git push --tags

El push del tag v* dispara .github/workflows/release.yml: corre las pruebas, npm run build:lib y publica a npmjs.com vía Trusted Publishing (OIDC) — el workflow se autentica con un token de corta duración que GitHub Actions emite y npmjs.com verifica contra el Trusted Publisher configurado para @kitoep/admin-kit (org kitoep, repo admin-kit, workflow release.yml). Ya no hay NPM_TOKEN ni ningún secreto de npm en el repo: nada que rotar ni que filtrar. Ver el run en la pestaña Actions del repo.


Stack

| Pieza | Qué es y por qué | |---|---| | Vue 3 + Vite + TS | SFC con <script setup>, tipado estricto (noUnusedLocals, erasableSyntaxOnly) | | naive-ui (MIT) | Set completo de componentes tematizables. Dropdown, date picker, modal, tabla con sort/filtro/paginación no se reimplementan a mano | | @visactor/vchart | La única librería de charts. Registro tree-shaken: bar/line/area/pie/radar/gauge/funnel/scatter/heatmap y solo los componentes que se usan | | @hugeicons/vue + core-free-icons | El único set de iconos. Cero lucide, cero emojis, cero SVG a mano | | vue-router + pinia | Rutas con guard de sesión default-closed; store de sesión | | @fontsource Plus Jakarta Sans + Lexend | Títulos y cifras / cuerpo y tablas | | vitest + @vue/test-utils + jsdom | Pruebas de builders puros, componentes, guard y frontera |

Cero dependencias de UI extra: no hay resaltador de sintaxis, no hay librería de iconos secundaria, no hay segunda librería de tablas ni de charts.


Crea tu propio tema

El kit es white-label: toda decisión de color, tipografía, forma y densidad vive en un ThemeSeeds y la fábrica deriva el resto. No hay un segundo lugar que actualizar.

El camino corto: /tema. Esa página es un configurador en vivo — color pickers para las semillas, selects de tipografía, sliders de forma y densidad, "Restaurar" para volver al default del kit, y una auditoría de contraste que se recalcula mientras editas. Cuando te guste, "Copiar semillas" te da el objeto TypeScript listo para pegar. El camino largo (escribirlo a mano) es esto:

// src/kit/theme/seeds/acme.ts
import type { ThemeSeeds } from "../types"

export const acmeSeeds: ThemeSeeds = {
  name: "Acme",

  brand: {
    base:       { light: "#4F46E5", dark: "#818CF8" }, // identidad, tal cual
    hover:      { light: "#4338CA", dark: "#A5B4FC" },
    pressed:    { light: "#3730A3", dark: "#7C83F5" },
    soft:       { light: "#EEF0FE", dark: "#1E1B4B" }, // fondo tenue (pill activo)
    ink:        { light: "#4338CA", dark: "#A5B4FC" }, // la marca como TEXTO chico
    foreground: { light: "#FFFFFF", dark: "#14161F" }, // el texto SOBRE la marca
  },

  // Cada rol tiene DOS usos que no se pisan:
  //   `text` + `soft`     → TRAZO: texto, icono, punto, borde, serie de dato.
  //                         Se elige por CONTRASTE (≥4.5:1), y en modo claro eso
  //                         obliga a bajarlo: el ámbar legible ES un café.
  //   `solid` + `onSolid` → RELLENO: botón `type="warning"`, y con él el botón
  //                         de confirmación de `dialog.warning()` y el positivo
  //                         del popconfirm. Aquí manda la identidad del estado.
  // `solid` es opcional (se deriva del tono de texto, aproximando) y `onSolid`
  // también (se calcula por luminancia). Declara `solid`: la derivación acierta
  // la familia, no TU ámbar.
  success: { text: { light: "#007C40", dark: "#2BD584" }, soft: { light: "#E6F9F0", dark: "#0B2C1D" }, solid: { light: "#00BF63", dark: "#00BF63" } },
  danger:  { text: { light: "#B42318", dark: "#F87171" }, soft: { light: "#FDF0F1", dark: "#2B1416" }, solid: { light: "#E5484D", dark: "#E5484D" } },
  warning: { text: { light: "#7A5A00", dark: "#FFBC00" }, soft: { light: "#FFF6DE", dark: "#271F0A" }, solid: { light: "#FFBC00", dark: "#FFBC00" } },
  info:    { text: { light: "#00688A", dark: "#00C3FF" }, soft: { light: "#E9F7FC", dark: "#092028" }, solid: { light: "#00C3FF", dark: "#00C3FF" } },

  surfaces: {
    canvas:     { light: "#F5F6FA", dark: "#0B0D14" }, // el lienzo
    surface:    { light: "#FFFFFF", dark: "#14161F" }, // la card
    well:       { light: "#F0F2F8", dark: "#1C1F2B" }, // el hueco recesivo (código, zebra)
    line:       { light: "#E4E7F0", dark: "#232735" }, // divisor
    lineStrong: { light: "#CDD3E2", dark: "#333A4D" }, // borde de control
    // OPCIONALES — la escala de presencia (ver §La escala de presencia):
    control:    { light: "#E7EAF3", dark: "#333A43" }, // relleno de control (icono de fila, tag)
    // hover:   se DERIVA atenuado de `control` si no lo declaras
  },

  ink: {
    primary:   { light: "#14161F", dark: "#F1F3F8" },
    secondary: { light: "#565E75", dark: "#A7AEC1" },
    tertiary:  { light: "#676E80", dark: "#9098AB" }, // labels de eje, micro-labels
  },

  typography: {
    display: 'system-ui, -apple-system, "Segoe UI", sans-serif',
    body: 'system-ui, -apple-system, "Segoe UI", sans-serif',
    mono: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
  },

  // OPCIONALES — si no se declaran, la fábrica los deriva de lo de arriba:
  // focus (default: brand.ink)  ·  overlay (burbuja de tooltip)
  // elevation (sombras)         ·  radii (forma)        ·  controls (densidad)
  // chart (gradiente, rampa de un hue y paleta categórica)
  radii: { control: "6px", controlSmall: "5px", chip: "6px", popover: "8px", card: "10px", pill: "999px" },
  controls: { height: "34px", heightSmall: "28px", filterHeight: "32px", pillHeight: "20px", tagHeight: "24px" },
}

Y aplicarlo:

// src/main.ts
import { makeTheme } from "@kit/theme/factory"
import { setActiveTheme } from "@kit/theme/apply"
import { acmeSeeds } from "@kit/theme/seeds/acme"

setActiveTheme(makeTheme(acmeSeeds))

Eso es todo. Con esa sola llamada cambian las CSS variables (inyectadas en el <head>), los GlobalThemeOverrides de los ~75 componentes de Naive que el kit mapea, y los colores de los charts. Se puede hacer también en vivo: es exactamente lo que hace el switcher de /tema.

La escala de presencia (surfaces.control / surfaces.hover)

Los tintes neutros que van ENCIMA de una superficie forman una escala, y el orden no es negociable:

hover de superficie   <   relleno de control   <   borde de control
 (--surface-hover)         (--control-fill)          (--line-2)
  • surfaces.control → --control-fill: el fondo en reposo de un control chico que vive sobre una superficie — el botón de icono quaternary de una fila, un tag, un chip, el track del segmentado. Sube este si quieres que tus controles secundarios tengan presencia.
  • surfaces.hover → --surface-hover: el hover de una fila de tabla, un item de menú, una opción de lista. Es el tinte más DÉBIL de la escala.
  • surfaces.well → --well: el hueco recesivo (bloque de código, zebra de tabla, pie de card, zona de arrastre).

Las dos primeras son opcionales y compatibles hacia atrás:

| Declaras | Resultado | |---|---| | nada | control y hover valen well — render idéntico al de v0.4.0 | | solo control | hover se deriva atenuado: mix(surface, control, 0.45) | | las dos | manda lo que escribiste, y la auditoría verifica el orden |

Por qué existe. Hasta la v0.4.0 el hover de fila y el relleno de control eran el mismo token. Subir well para darles presencia a los botones secundarios en modo oscuro volvía el hover del DataTable idéntico al relleno de los botones de icono que viven en la fila: al pasar el puntero, los controles se disolvían. Con la escala separada eso no puede pasar, y auditSurfaceScale falla el test si alguien invierte el orden.

La pareja de usos de un rol semántico (text / solid)

Un rol de estado hace dos trabajos con requisitos opuestos, y por eso son dos tonos:

| | warning.text → --warn | warning.solid → --warn-solid | |---|---|---| | Qué es | TRAZO: tinta sobre una superficie | RELLENO: un área con contenido encima | | Dónde | título e icono de alerta, cápsula, punto de estado, borde, feedback de campo, serie de chart | botón type="warning", y con él el botón de confirmación de dialog.warning() y el positivo de NPopconfirm; barra de progreso; indicador de paso | | Cómo se elige | por CONTRASTE: ≥4.5:1 sobre su fondo tenue y sobre las tres superficies | por IDENTIDAD: el ámbar de tu marca de estado, el mismo en los dos modos | | Esmeralda | #7A5A00 en claro (sí, un café: es el ámbar que se puede leer) | #FFBC00 en los dos modos | | Se audita | contra los fondos donde vive | su etiqueta --on-warn encima, ≥4.5:1 |

Las dos perillas nuevas son opcionales y compatibles hacia atrás:

| Declaras | Resultado | |---|---| | nada (semilla de v0.6.0, con ink) | ink se lee como text — el trazo no cambia ni un hex. solid se deriva subiendo la luminosidad HSL del tono de texto y onSolid se calcula por luminancia | | solid | manda el tuyo; onSolid se sigue calculando | | solid + onSolid | manda todo lo que escribiste |

Por qué existe. Naive tiene UN color por rol y lo usa para las dos cosas: common.warningColor pinta el borde de un input en advertencia (donde tiene que ser oscuro para leerse) y el fondo de <NButton type="warning">. En modo claro eso da botones mostaza — y como dialog.warning() y el popconfirm son ese mismo botón por dentro, el bug aparecía en las tres. Es el tercer caso del mismo patrón en este kit (después de --well y de ACCION_SECUNDARIA): un token con dos semánticas no se afina, se parte.

Jerarquía de acciones

Cinco reglas, y vienen en el paquete como constantes para que escribas la intención y no la receta:

import {
  ACCION_PRIMARIA,
  ACCION_SECUNDARIA,
  ACCION_SECUNDARIA_DESTACADA,
  ACCION_DE_FILA,
  ACCION_DESTRUCTIVA,
} from "@kitoep/admin-kit"

| # | Regla | Cómo se escribe | |---|---|---| | 1 | Un solo primario sólido de marca por pantalla — la acción por la que la vista existe | v-bind="ACCION_PRIMARIA" | | 2 | Los secundarios son NEUTRO VIVO — relleno, borde, texto a tinta completa. El caballo de batalla: puede haber varios sin saturar | v-bind="ACCION_SECUNDARIA" | | 3 | La destacada es la EXCEPCIÓN: tonal con tinte de marca, máximo UNA por contexto/card | v-bind="ACCION_SECUNDARIA_DESTACADA" | | 4 | quaternary es la jerarquía más baja, para botones de SOLO ICONO — círculo en una fila de tabla, rectángulo en una barra de herramientas. Siempre con title (o un <NTooltip>) + aria-label | v-bind="ACCION_DE_FILA" · v-bind="ACCION_DE_BARRA" | | 5 | El gris NO es la señal de deshabilitado — el neutro vivo también tiene relleno gris. Lo apagado se reconoce por TRES señales a la vez: relleno más tenue, SIN borde, texto lavado | disabled |

Lo destructivo va aparte de la escala (ACCION_DESTRUCTIVA) y solo cuando de verdad borra.

Por qué se redefinió el secundario (v0.6.0). Hasta la v0.5.0 ACCION_SECUNDARIA era el tonal de marca. El primer consumidor real lo rechazó dos veces: en una pantalla densa, si TODOS los secundarios llevan tinte de marca, saturan y el primario deja de leerse como el único importante — la regla del primario ≤10% aplica a botones igual que a cualquier otro elemento visual. La v0.6.0 mueve el secundario a NEUTRO VIVO (sin límite de cuántos) y el tonal de marca sobrevive como ACCION_SECUNDARIA_DESTACADA, ahora explícitamente excepcional. Quien quería el look anterior en un botón puntual usa la destacada.

Cómo se distingue el neutro vivo de disabled. Con TRES señales a la vez, nunca una sola: relleno más tenue (--control-fill-disabled, un token propio — nunca el mismo --control-fill del activo), SIN borde, y texto lavado (ink.tertiary, no la tinta completa del activo). Naive apaga el div que dibuja el borde para todo secondary/tertiary/quaternary, así que el borde del neutro vivo lo pone el kit en styles/interactions.css con CSS liso — no hay var de tema que lo alcance.

Los TRES anti-patrones, porque los tres se ven razonables en una captura aislada: el default transparente como secundario (sin relleno no tiene peso y sobre el lienzo desaparece), el tonal gris de Naive (tertiary, sin constante en el kit — el mismo relleno que disabled, a propósito: si se usa, se ve mal por diseño) y todo tonal de marca (si cada secundario destaca, ninguno se distingue y el primario deja de destacar).

Naive no entrega el tonal de marca listo —calcula el relleno con alfa y pone colorPrimary de TEXTO, ~1.9:1— así que el kit lo corrige en su CSS con --brand-soft de relleno y --brand-deep de texto, el par que la auditoría ya exige a 4.5:1. La galería /componentes/botones enseña la doctrina completa con una pantalla aplicada, una pantalla DENSA (tres cards de precios, 2-3 acciones cada una) y los tres anti-patrones tachados.

Lo que el kit sugiere por ti

kit/theme/builder.ts — las mismas funciones que usa el configurador, puras y testeadas:

| Función | Qué decide, y con qué criterio | |---|---| | suggestForeground(color) | el texto sobre un color sólido: compara un casi-negro y un casi-blanco teñidos con el propio color y gana el de más contraste | | suggestInk(color, fondos) | el color como TEXTO: lo profundiza (o aclara) hasta pasar 4.5:1 contra todos los fondos donde se usa, y se detiene ahí | | suggestSoft(color, superficie) | el fondo tenue del rol | | suggestSolid(texto, superficie) | el relleno sólido de un rol desde su tono de texto: sube la luminosidad HSL conservando hue y saturación. Aproximación declarada — acierta la familia, no tu ámbar | | toEditableSeeds(seeds) | semillas con todo lo derivable ya resuelto, listas para un formulario | | suggestBrandStates(color) | hover y pressed: se ALEJAN del texto que llevan encima — la única regla que no puede romper el contraste del botón | | suggestBrandSeed(base, superficies) | la familia de marca completa desde un color | | radiiFromCard(px) / controlsFromHeight(px) | la escala de forma / densidad desde un número | | seedsToSource(seeds) | el objeto TypeScript pegable |

Hay una prueba que arma temas con siete marcas distintas usando solo sugerencias y verifica que los tres pasen la auditoría completa.

Las perillas que casi nadie modela

  • brand.foreground — el texto sobre la marca sólida. Naive (y casi todo el mundo) asume blanco; sobre un verde o un naranja claro eso da 2.4:1 y falla hasta el umbral de gráficos. Si tu marca es clara, esto es un casi-negro.
  • <rol>.onSolid — lo mismo para cada estado. Blanco sobre #FFBC00 da 1.7:1. El kit lo calcula si no lo declaras.
  • <rol>.text vs <rol>.solid — dos tonos por rol, no uno, porque el mismo valor no puede ser texto legible y relleno vibrante. El rojo "bonito" de las paletas (#E5484D) da 3.5:1 como texto sobre su fondo tenue —por eso el trazo del kit es #B42318, 5.9:1— y es exactamente el que quieres de relleno.

El blindaje

npm run test -- --run
  • Cero hardcode (src/__tests__/tema-sin-hardcode.spec.ts): falla si aparece un #hex o un rgb() en cualquier archivo de src/ que no sea de semillas. Excepción única y explícita: una anotación // theme-exempt: <razón>. Verifica además que todo var(--x) usado exista de verdad.
  • Contraste (src/__tests__/contraste.spec.ts): recorre los pares texto/fondo del sistema, con nombre y ubicación, contra todos los temas (Esmeralda y los de muestra) en los dos modos. Umbrales de WCAG 2.1: 4.5:1 para texto, 3:1 para lo que es información no textual. Si falla, se corrige la semilla — no el umbral. Los pares que se miden pero no se exigen están enumerados con su justificación escrita en kit/theme/audit.ts.
  • Escala de presencia (src/__tests__/superficies.spec.ts): verifica que el hover de superficie nunca supere al relleno de control ni este al borde, en los dos modos y en todos los temas, y que un tema viejo (sin las semillas nuevas) renderice exactamente igual que antes.
  • Jerarquía de acciones (src/__tests__/jerarquia-acciones.spec.ts): las constantes, la separación entre el tonal de marca y el gris de deshabilitado, y que ningún icono de fila se quede sin title/aria-label.
  • Charts, contra invisibilidad (kind: "chart" en contraste.spec.ts): cada serie categórica tiene que tener presencia MÍNIMA contra el lienzo del chart (1.6:1 — no es legibilidad de texto, es "que se vea que ahí hay algo"). Nace de un caso real: una marca pastel dejaba las barras invisibles con todo lo demás en verde.

Los charts no necesitan nada extra: leen los tokens vivos del DOM (readSeriesColors()) y se re-instancian con themeRevision.

La página /componentes/colores muestra la paleta viva del DOM, y /tema el switcher, las semillas y la auditoría del tema activo.

Corre el blindaje contra TUS semillas

Todo lo de arriba también se exporta, para que un consumidor lo corra en su propio repo, contra sus propias semillas, en su propio CI — no solo dentro de este kit:

// src/theme/theme.spec.ts, en TU proyecto
import { assertThemeOk } from "@kitoep/admin-kit"
import { misSemillas } from "./seeds"

it("mi tema pasa la auditoría del kit", () => {
  expect(() => assertThemeOk(misSemillas)).not.toThrow()
})

assertThemeOk(seeds) corre contraste + escala de presencia (zebra vs. hover, tinta deshabilitada vs. placeholder, el velo del modal, la categórica de charts, …) + la regla de marca gráfica (abajo) en los dos modos, y lanza con cada hallazgo si algo falla. auditThemeOk(seeds) es la versión que no lanza — devuelve { ok, contrast, scale, graphicPaint, hueDrift } para pintar en una UI propia (es lo que pinta /tema). Y makeTheme(seeds) ya valida la entrada solo: un hex inválido o un campo ausente lanzan nombrando la ruta exacta, no un TypeError de tres niveles de profundidad.

Si declaras tus propias semillas, npm update NO te trae los colores nuevos. Una versión del kit puede subir el piso de un par de la auditoría Y subir el valor de la semilla que lo cumple. Al actualizar heredas lo primero —la regla nueva viaja en el código— pero no lo segundo: tus semillas son tuyas y el kit no las toca. El resultado es un tema que empieza a incumplir una regla que antes no existía, con los mismos colores de siempre.

Pasó literal en la v0.12.1, con surfaces.lineStrong. La forma de enterarse es tener assertThemeOk(misSemillas) cableado a un test de la suite, como arriba: ahí el npm update que rompe algo se ve en el CI de esa misma tarde. Si la auditoría solo se corre a mano, o solo se mira en /tema, la actualización te deja con la auditoría nueva y los colores viejos en silencio — el kit no tiene forma de avisarte desde afuera. Cada entrada del CHANGELOG que mueve una semilla lo dice en su sección "Notas para quien actualiza", con los valores concretos.

Una marca gráfica nunca lleva la tinta de su rol (v0.11.0). No es un chequeo de contraste: es de QUÉ TOKEN va en qué PAPEL. Un ícono o un borde son un ÁREA con forma propia, no texto — pintarlos con la tinta de un rol (oscurecida para pasar 4.5:1 como texto) es el defecto que dejaba el ícono de NPopconfirm, el ícono+borde de NAlert/NDialog café en vez de ámbar. La regla inspecciona el override REAL que produce buildNaiveOverrides (el mismo objeto que recibe NConfigProvider) contra el valor esperado (el tono SÓLIDO del rol): si alguien vuelve a cablear iconColorWarning: p.warn, truena sin que nadie tenga que acordarse de un test por componente.

Aviso cuando la tinta de un rol se sale de su hue (v0.11.0, diagnóstico). El caso del amarillo: al oscurecer el ámbar para que pase 4.5:1 como texto, el resultado se lee "café" — un nombre de color DISTINTO —, mientras que el verde/rojo/azul oscurecidos siguen leyéndose verde/rojo/azul. Se mide comparando el matiz HSL de text contra solid por distancia circular; nunca bloquea (auditThemeOk(...).hueDrift siempre viene completo, no solo los fallos) — es la señal de qué rol necesita el tratamiento de arriba en cualquier superficie donde se use como color, no solo ícono/borde.


Licencia

MIT — ver LICENSE.


Este README es la página del paquete en npmjs.com. Si vas a contribuir al repo o a la app demo, el detalle de convenciones y arquitectura interna vive en AGENTS.md y en docs/design-system.md.