/** * Grafito — tema para activeUIX / Eidos (paleta derivada de Untitled UI, variada). * * Reproduce el lenguaje visual de https://www.untitledui.com con el * sistema de theming canónico de Eidos: NO inventa variants ni roles * nuevos (eso es canon fijo del framework, ver `eidos/THEMING.md §19`), * solo **retinta** los 9 roles canónicos y ajusta primitivas estables * (tipografía, radio, sombra) que un theme/app puede mover. * * Anclas del design system de Untitled UI: * - Tipografía : Inter (display + texto), mono para código. * - Brand 600 : #7F56D9 → role `primary` (escala `brand`). * - Gray 900 : #101828 → role `neutral`/`secondary` (escala `gray`). * - Error 600 : #D92D20 → role `threat` (escala `red`). * - Warning 600 : #DC6803 → role `risk` (escala `amber`). * - Success 600 : #079455 → role `fulfill` (escala `green`). * - Teal 600 : #0E9384 → role `affirm` (escala `teal`). * - Plum 900 : #7A3AAD → role `loss` (escala `plum`, violeta-grave). * - Radio : 8px en controles (`md`), 12px paneles. * - Sombras : sistema en capas de Untitled UI (xs … 2xl). * * Las escalas físicas se mapean del ramp 25→900 de Untitled UI al modelo * funcional de 12 pasos de Eidos (Radix-style): paso 1-2 fondos, 3-5 * fondos de componente, 6-8 bordes, 9 sólido (= el `-600` de Untitled UI), * 10 sólido-hover, 11 texto, 12 texto de alto contraste. * * Se construye como **config standalone** (no como patch sobre el theme * base): conserva las primitivas y recipes del base pero deja únicamente * los themes `untitled-ui-{light,dark}` y los 9 roles re-mapeados a las * escalas de Untitled UI. (Un patch dejaría los themes base, cuyo set de * escalas no coincide con los roles, y el validador de Eidos lo * rechazaría.) */ import { createThemeBaseEidosConfig, THEME_BASE_LIGHT_COLOR_SCALES, THEME_BASE_DARK_COLOR_SCALES } from '$uix/eidos/lib/themes/base' import type { ColorScale, ColorRoleMap, EidosConfig } from '$uix/eidos/lib/config-types' const s = ( c1: string, c2: string, c3: string, c4: string, c5: string, c6: string, c7: string, c8: string, c9: string, c10: string, c11: string, c12: string ): ColorScale => ({ '1': c1, '2': c2, '3': c3, '4': c4, '5': c5, '6': c6, '7': c7, '8': c8, '9': c9, '10': c10, '11': c11, '12': c12 }) // ── Escalas — modo claro ────────────────────────────────────────────── // prettier-ignore const LIGHT = { // Hereda la paleta base del framework (31 escalas Radix). Grafito sobreescribe // abajo las escalas de marca (carbon, gray, violet, blue + intents UUI-tuned) y // hereda las demás → ~32 escalas en la paleta del tema. ...THEME_BASE_LIGHT_COLOR_SCALES, // Carbon — grafito casi negro. Primario monocromo (Vercel / Linear style). // 9 = sólido oscuro del botón; con slots.contrast='1' el texto (step 1) // queda casi blanco. En dark la escala se invierte y todo encaja solo. carbon: s('#fafafa','#f5f5f6','#ececee','#e3e3e6','#d8d9dd','#c7c8cd','#a1a2a9','#717179','#1f2024','#101114','#34353b','#08090b'), // Untitled UI Gray (neutral frío) — 900 = #101828 gray: s('#fcfcfd','#f9fafb','#f2f4f7','#eaecf0','#e4e7ec','#d0d5dd','#c3cad3','#98a2b3','#667085','#475467','#344054','#101828'), // Untitled UI Brand (violeta #7F56D9) — el primario REAL de Untitled UI, // aquí como secundario (jerarquía de apoyo). violet: s('#fcfaff','#f9f5ff','#f4ebff','#e9d7fe','#d6bbfb','#c3a5f7','#b692f6','#9e77ed','#7f56d9','#6941c6','#5b34b5','#42307d'), // Untitled UI Blue — 600 = #1570EF (terciario). blue: s('#f5faff','#eff8ff','#d1e9ff','#b2ddff','#84caff','#6cbbfd','#53b1fd','#2e90fa','#1570ef','#175cd3','#1849a9','#194185'), // Untitled UI Teal — 600 = #0E9384 teal: s('#f6fefc','#f0fdf9','#ccfbef','#99f6e0','#5fe9d0','#47d9c0','#2ed3b7','#15b79e','#0e9384','#0c7e72','#107569','#134e48'), // Untitled UI Success (verde) — 600 = #079455 green: s('#f6fef9','#ecfdf3','#dcfae6','#abefc6','#75e0a7','#5bd295','#47cd89','#17b26a','#079455','#067647','#085d3a','#074d31'), // Untitled UI Warning (ámbar) — 600 = #DC6803 amber: s('#fffcf5','#fffaeb','#fef0c7','#fedf89','#fec84b','#fdb937','#fdb022','#f79009','#dc6803','#b54708','#93370d','#7a2e0e'), // Untitled UI Error (rojo) — 600 = #D92D20 red: s('#fffbfa','#fef3f2','#fee4e2','#fecdca','#fda29b','#fb897f','#f97066','#f04438','#d92d20','#b42318','#912018','#7a271a'), // Loss — violeta-grave (plum). Sólido más oscuro y magenta que el violeta // de marca (secondary), para que la pérdida se lea sombría y posterior. plum: s('#fdfbfe','#faf5fd','#f3e9fb','#eaddf7','#dfccf1','#d0b4e8','#bd99dc','#a577cd','#7a3aad','#6f349f','#652f90','#321a4a') } satisfies Record // ── Escalas — modo oscuro ───────────────────────────────────────────── // Ramps oscuros perceptualmente coherentes con el mismo hue: 1 = fondo // más oscuro, 12 = texto más claro, 9 = sólido (= `-600`, igual que claro), // 10 = `-500` (más claro para hover sobre oscuro). // prettier-ignore const DARK = { ...THEME_BASE_DARK_COLOR_SCALES, // ── Escalas de marca Grafito (dark) ── // Carbon invertido: 1 = casi negro, 9 = botón claro, contrast(1) = oscuro. carbon: s('#0b0c0e','#131418','#1b1c20','#232428','#2c2d32','#3a3b41','#565760','#7c7d86','#f4f4f6','#ffffff','#cdced4','#fbfbfc'), gray: s('#0c111d','#101828','#1d2939','#243144','#2c3a4f','#344054','#475467','#5d6b7e','#667085','#98a2b3','#ced5de','#f5f6f8'), violet: s('#1a132b','#20183a','#2e1f55','#382569','#432d7d','#503690','#5e40a8','#6e4bc4','#7f56d9','#9e77ed','#c9b5f8','#ece3ff'), blue: s('#0d1a2e','#11213a','#0f2f5e','#0b3b7a','#0e4894','#1559b0','#1f6fd6','#2a86ec','#1570ef','#2e90fa','#84caff','#d1e9ff'), teal: s('#0b1b19','#0f2422','#103832','#0e463e','#11554b','#166458','#1a7568','#178573','#0e9384','#15b79e','#5fe9d0','#c9f7ec'), green: s('#0c1a12','#0f2417','#0f3a23','#0e4a2c','#155b36','#1d6c41','#218358','#06934f','#079455','#17b26a','#75e0a7','#cdf5dd'), amber: s('#1c1408','#241b09','#3a2a08','#4a3607','#5c4408','#70530c','#8a6410','#b07908','#dc6803','#f79009','#fec84b','#fee9c0'), red: s('#1f1314','#281618','#41181c','#551a1e','#6b1f22','#842529','#a12d2e','#c13438','#d92d20','#f04438','#fda29b','#fee0de'), plum: s('#160f1f','#1d142b','#2a1a40','#341e50','#3e255f','#4a2f72','#5a3a8a','#6d47a6','#8348c0','#9560d0','#c2a8e6','#ece1fb') } satisfies Record // ── Mapeo role → escala (los 9 roles canónicos de Eidos) ────────────── const ROLES: ColorRoleMap = { // Solo la JERARQUÍA se mapea explícitamente (decisión de marca). Carbon // monocromo: `slots.contrast: '1'` hace que el texto sobre el sólido sea el // step 1 — casi blanco en claro, casi negro en oscuro — invirtiéndose con el // modo (el step 9 también se invierte). primary: { scale: 'carbon', slots: { contrast: '1' } }, secondary: 'violet', tertiary: 'blue' // Los 6 intents (neutral + affirm/fulfill/risk/threat/loss) NO se cablean: // auto-derivan de la paleta por la convención del libro // (gray/teal/green/amber/red/plum — todas presentes arriba), identidad = step 9. // Un tema puede overridear un intent mapeándolo explícitamente. } // ── Superficies / contenido / bordes / focus ────────────────────────── const LIGHT_SEMANTICS = { surface: { default: '#ffffff', raised: '#ffffff', overlay: 'var(--primitive-neutral-2)', muted: 'var(--primitive-neutral-3)', backdrop: 'rgb(16 24 40 / 0.45)' }, content: { primary: 'var(--primitive-neutral-12)', secondary: 'var(--primitive-neutral-11)', muted: 'var(--primitive-neutral-10)', disabled: 'var(--primitive-neutral-8)', onSolid: '#ffffff' }, border: { subtle: 'var(--primitive-neutral-4)', default: 'var(--primitive-neutral-6)', strong: 'var(--primitive-neutral-8)' }, focus: { ring: 'color-mix(in srgb, var(--primitive-secondary-9) 24%, transparent)', ringError: 'color-mix(in srgb, var(--primitive-threat-9) 22%, transparent)' } } as const const DARK_SEMANTICS = { surface: { default: 'var(--primitive-neutral-1)', raised: 'var(--primitive-neutral-3)', overlay: 'var(--primitive-neutral-4)', muted: 'var(--primitive-neutral-2)', backdrop: 'rgb(0 0 0 / 0.6)' }, content: { primary: 'var(--primitive-neutral-12)', secondary: 'var(--primitive-neutral-11)', muted: 'var(--primitive-neutral-10)', disabled: 'var(--primitive-neutral-8)', onSolid: '#ffffff' }, border: { subtle: 'var(--primitive-neutral-4)', default: 'var(--primitive-neutral-6)', strong: 'var(--primitive-neutral-8)' }, focus: { ring: 'color-mix(in srgb, var(--primitive-secondary-9) 40%, transparent)', ringError: 'color-mix(in srgb, var(--primitive-threat-9) 40%, transparent)' } } as const // ── Sombras de Untitled UI (xs → 2xl) ───────────────────────────────── const LIGHT_SHADOW = { none: 'none', subtle: 'var(--shadow-1)', raised: 'var(--shadow-3)', overlay: 'var(--shadow-5)', '1': '0 1px 2px 0 rgb(16 24 40 / 0.05)', '2': '0 1px 3px 0 rgb(16 24 40 / 0.1), 0 1px 2px -1px rgb(16 24 40 / 0.1)', '3': '0 4px 8px -2px rgb(16 24 40 / 0.1), 0 2px 4px -2px rgb(16 24 40 / 0.06)', '4': '0 12px 16px -4px rgb(16 24 40 / 0.08), 0 4px 6px -2px rgb(16 24 40 / 0.03)', '5': '0 20px 24px -4px rgb(16 24 40 / 0.08), 0 8px 8px -4px rgb(16 24 40 / 0.03)', '6': '0 24px 48px -12px rgb(16 24 40 / 0.18)' } as const const DARK_SHADOW = { none: 'none', subtle: 'var(--shadow-1)', raised: 'var(--shadow-3)', overlay: 'var(--shadow-5)', '1': '0 1px 2px 0 rgb(0 0 0 / 0.3)', '2': '0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.4)', '3': '0 4px 8px -2px rgb(0 0 0 / 0.45), 0 2px 4px -2px rgb(0 0 0 / 0.3)', '4': '0 12px 16px -4px rgb(0 0 0 / 0.5), 0 4px 6px -2px rgb(0 0 0 / 0.3)', '5': '0 20px 24px -4px rgb(0 0 0 / 0.55), 0 8px 8px -4px rgb(0 0 0 / 0.3)', '6': '0 24px 48px -12px rgb(0 0 0 / 0.65)' } as const const INTER_FALLBACKS = [ '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif' ] // Base con primitivas + recipes del sistema, con las primitivas estables // que el tema Untitled UI ajusta (tipografía Inter, radio 8px, focus ring). const base = createThemeBaseEidosConfig({ primitives: { // Untitled UI = Inter en todo (display + texto), mono para código. typography: { families: { primary: { family: 'Inter', fallbacks: INTER_FALLBACKS, source: 'remote' }, secondary: { family: 'Inter', fallbacks: INTER_FALLBACKS, source: 'remote' }, display: { family: 'Inter', fallbacks: INTER_FALLBACKS, source: 'remote' }, mono: { family: 'Roboto Mono', fallbacks: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'], source: 'remote' } }, // Tracking ceñido en titulares grandes — firma de Untitled UI. styles: { hero: { letterSpacing: '-0.02em' }, h1: { letterSpacing: '-0.02em' }, h2: { letterSpacing: '-0.02em' }, h3: { letterSpacing: '-0.01em' } } }, // Radio: controles a 8px (md), paneles a 12px (lg). radius: { none: '0px', sm: '6px', md: '8px', lg: '12px', xl: '16px', xxl: '20px', full: '9999px' }, // Halo de focus suave (Untitled UI usa un anillo amplio, sin hueco). focusRing: { offset: '1px', width: '3px' } } }) /** * Config completo del tema Grafito. Se pasa a * `ActiveEidos.create({ config: grafitoConfig, theme: 'grafito' })`. */ export const grafitoConfig: EidosConfig = { primitives: base.primitives, recipes: base.recipes, semantics: { color: { roles: ROLES } }, themes: { 'grafito-light': { color: { scales: LIGHT, ...LIGHT_SEMANTICS }, shadow: LIGHT_SHADOW }, 'grafito-dark': { color: { scales: DARK, ...DARK_SEMANTICS }, shadow: DARK_SHADOW } } } // ── Metadatos para la página de documentación del theme ─────────────── export interface ThemeRoleInfo { readonly role: string readonly scale: string readonly label: string readonly note: string } /** Roles de JERARQUÍA — alias explícito a una escala de la paleta (marca). Los * intents NO van aquí: auto-derivan de la paleta por convención (ver THEME_INTENTS). */ export const THEME_HIERARCHY: readonly ThemeRoleInfo[] = [ { role: 'primary', scale: 'carbon', label: 'Primary', note: 'Carbón · grafito #1F2024' }, { role: 'secondary', scale: 'violet', label: 'Secondary', note: 'Violeta de marca #7F56D9' }, { role: 'tertiary', scale: 'blue', label: 'Tertiary', note: 'Azul acento #1570EF' } ] /** * La paleta COMPLETA del tema (~32 escalas), derivada de las claves de `LIGHT` * para no desincronizarse. Etiquetada por NOMBRE DE COLOR — nunca por intent * (los intents son una capa semántica encima; ver THEME_INTENTS). */ export const THEME_SCALES: readonly { name: string; label: string }[] = Object.keys(LIGHT) .sort() .map((name) => ({ name, label: name })) /** * Los 6 intents y la escala de la paleta de la que **auto-derivan** (convención * del libro). El tema NO los cablea — el engine los rellena por nombre, * identidad = step 9. Esto son solo metadatos para documentar el mapeo en el demo. */ export const THEME_INTENTS: readonly { role: string; label: string; scale: string; note: string }[] = [ { role: 'neutral', label: 'Neutral', scale: 'gray', note: 'Sin valencia · gris de trabajo' }, { role: 'affirm', label: 'Affirm', scale: 'teal', note: 'Positivo ligero · teal' }, { role: 'fulfill', label: 'Fulfill', scale: 'green', note: 'Éxito / completado · verde' }, { role: 'risk', label: 'Risk', scale: 'amber', note: 'Aviso · ámbar' }, { role: 'threat', label: 'Threat', scale: 'red', note: 'Error / peligro · rojo' }, { role: 'loss', label: 'Loss', scale: 'plum', note: 'Pérdida grave · violeta sombrío' } ] export const COLOR_STEPS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] as const