@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
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:5173El 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-kitSin .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 deIntlque usaformat.ts(formatDate,formatMoney,formatWhen, …) — cualquier BCP-47 funciona,Intlno tiene un set curado — y, por PREFIJO de idioma, el objeto de locale de Naive UI (esAR/enUS/frFR/deDE/ptBRcurados; cualquier otro cae aenUScon un aviso en consola, o pásalo tú connaiveLocaleOverride).currency(default"MXN") es el default deformatMoney()— una vista lo sigue pudiendo pisar (formatMoney(valor, "EUR")).messagessobreescribe cualquier texto propio del kit (DataTablevacío,FourStates, el shell deAppShell,CodeBlock,ThemeToggle, el mapa coroplético, el copy relativo deformatWhen). Ellocaletambié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ú, verKitMessagesen 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/whenreciben la fecha CRUDA: unDateya parseado (nuncaInvalid 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 elDatecrudo resuelves ambos en un solo lugar.- Merge parcial, igual que
messages: si solo registrasdateTime,dateywhense quedan siendo los del kit ("12 jul 2026"/ relativo <24h). - No cambia el default.
formatDate/formatDateTimedel 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-inLos 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 aparteimport {
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 --tagsEl 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 iconoquaternaryde 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#FFBC00da 1.7:1. El kit lo calcula si no lo declaras.<rol>.textvs<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#hexo unrgb()en cualquier archivo desrc/que no sea de semillas. Excepción única y explícita: una anotación// theme-exempt: <razón>. Verifica además que todovar(--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 enkit/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 sintitle/aria-label. - Charts, contra invisibilidad (
kind: "chart"encontraste.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 updateNO 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 tenerassertThemeOk(misSemillas)cableado a un test de la suite, como arriba: ahí elnpm updateque 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.
