diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f95259..e64d852 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,9 +155,16 @@ jobs:
-

NEUBAT: tu Arch, tu ISO, tu red

-

Configura desde el navegador una instalación desatendida.

- Configurar instalación
+
+
+

NEUBAT

+

Propuesta

NEUBAT define en el navegador una instalación desatendida de Arch Linux.

+

Características

Perfil JSON, btrfs y dos entradas de arranque.

+

Funcionalidades

Asistente, ISO y arranque por URL.

+

Roadmap

El instalador iPXE real sigue necesitando el portal.

+

Cierre

Configurar instalación
+
+
`; const dom = new JSDOM(html, { runScripts: 'dangerously', pretendToBeVisual: true }); diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9e0ae60..4a1c68b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,13 +64,14 @@ ansible/ first-boot (ConditionFirstBoot) | Perfil JSON | `configs/*.json` | PR + docs/guides/packages.md | | Token de instalación | hex 32 chars | `configPathFor()` — no relajar | | Firma de config | `NEUBAT_HMAC_SECRET` | Entorno portal + live (nunca en git) | +| Tokens DTCG OKLCH | `portal/frontend/tokens/` v1.0 | `tokens/CONTRACT.md`; `make smoke` comprueba contraste | ## 5. Calidad - Jest + Supertest sobre el portal; Vitest + oxlint en el frontend - `bash -n` + shellcheck + bats en `scripts/` - Ansible syntax-check / ansible-lint -- CI: `quality` → `test` → `build` (SPA) → `smoke` (`/api/health` + `POST /api/install`) +- CI: `quality` → `test` → `build` (SPA) → `smoke` (`/api/health` + `POST /api/install` + contraste DTCG) - QEMU/NVMe (`make test-vm`) y build de ISO: **opt-in**, no required ## 6. Decisiones de diseño (vivas) diff --git a/Makefile b/Makefile index 2e84c27..69e74fe 100644 --- a/Makefile +++ b/Makefile @@ -31,8 +31,10 @@ lint: test: cd portal && npm test -# Fachada canónica: health + POST /api/install (falla si el portal no responde) +# Fachada canónica: contraste de tokens + health + POST /api/install smoke: + @node portal/frontend/scripts/build-tokens.mjs --check + @node portal/frontend/scripts/check-contrast.mjs @cd portal && \ PORT=3100 node server.js >/tmp/neubat-smoke.log 2>&1 & pid=$$!; \ ok=0; \ diff --git a/docs/guides/desarrollo.md b/docs/guides/desarrollo.md index 3da359c..6056a4e 100644 --- a/docs/guides/desarrollo.md +++ b/docs/guides/desarrollo.md @@ -35,7 +35,7 @@ docker compose up -d | -------- | -------- | | `make lint` | shellcheck de `scripts/*.sh` + oxlint del frontend si hay `node_modules` | | `make test` | Jest del portal | -| `make smoke` | `/api/health` + `POST /api/install` contra un portal temporal | +| `make smoke` | contraste DTCG (4.5:1 / 3:1) + `/api/health` + `POST /api/install` | | `make validate` | `bash -n`, `node --check` y JSON de `configs/` | Complementarios (no required de CI): `make test-frontend`, `make test-bash`, `make test-ansible`, `make test-vm`, `make build-iso`. diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index c81f16d..3e87d3b 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -13,7 +13,7 @@ | `quality` | `make validate` + `make lint` + Ansible syntax/lint + oxlint frontend | Estática | | `test` | `make test` + frontend Vitest + `make test-bash` | Unidad / integración rápida | | `build` | `make build-frontend` | Artefacto desplegable (SPA Vite) | -| `smoke` | `make smoke` + axe sintético del landing | Health + `POST /api/install` + a11y mínima | +| `smoke` | `make smoke` + axe sintético del landing | Contraste de tokens + health + `POST /api/install` + a11y mínima | ## Fallos diff --git a/portal/frontend/index.html b/portal/frontend/index.html index d8882ce..2e2cfae 100644 --- a/portal/frontend/index.html +++ b/portal/frontend/index.html @@ -4,6 +4,18 @@ NEUBAT — Instalación desatendida de Arch Linux +
diff --git a/portal/frontend/package.json b/portal/frontend/package.json index 726f8bc..953eed6 100644 --- a/portal/frontend/package.json +++ b/portal/frontend/package.json @@ -5,6 +5,11 @@ "type": "module", "scripts": { "dev": "vite", + "predev": "node scripts/build-tokens.mjs", + "prebuild": "node scripts/build-tokens.mjs", + "prepreview": "node scripts/build-tokens.mjs", + "tokens:build": "node scripts/build-tokens.mjs", + "tokens:check": "node scripts/check-contrast.mjs", "build": "tsc -b && vite build", "lint": "oxlint", "test": "vitest run", diff --git a/portal/frontend/scripts/build-tokens.mjs b/portal/frontend/scripts/build-tokens.mjs new file mode 100644 index 0000000..ab18b8e --- /dev/null +++ b/portal/frontend/scripts/build-tokens.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Genera CSS variables desde tokens DTCG OKLCH. Sin dependencias. +// node scripts/build-tokens.mjs +// node scripts/build-tokens.mjs --check +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { fallbackCss, loadIndex, resolveModes, varName } from "./token-lib.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TOKENS_DIR = join(root, "tokens"); +const OUT_CSS = join(TOKENS_DIR, "generated", "variables.css"); +const OUT_HEX = join(TOKENS_DIR, "generated", "fallback-hex.json"); + +const REQUIRED = { + "primitive.color.tokens.json": ["color"], + "primitive.dimension.tokens.json": ["space", "radius", "shadow", "motion", "z", "breakpoint"], + "semantic.color.tokens.json": ["bg", "text", "border", "action", "feedback"], + "semantic.typography.tokens.json": ["typography"], + "component.alias.tokens.json": ["button", "card", "input"], +}; + +const COLOR_GROUPS = ["brand", "neutral", "success", "warning", "danger", "focus"]; + +function assertShape(docs) { + const byFile = new Map(docs.map((d) => [d.file, d.json])); + for (const [file, groups] of Object.entries(REQUIRED)) { + const json = byFile.get(file); + if (!json) throw new Error(`Falta ${file}`); + for (const group of groups) { + if (!json[group] || typeof json[group] !== "object") { + throw new Error(`${file} sin grupo ${group}`); + } + } + } + const color = byFile.get("primitive.color.tokens.json").color; + for (const group of COLOR_GROUPS) { + if (!color[group]) throw new Error(`primitive.color sin ${group}`); + } + const semantic = byFile.get("semantic.color.tokens.json"); + for (const group of ["bg", "text", "border", "action", "feedback"]) { + const leaves = JSON.stringify(semantic[group]); + if (!leaves.includes('"light"') || !leaves.includes('"dark"')) { + throw new Error(`semantic.color ${group} sin modos light|dark`); + } + } +} + +function render(light, dark) { + const names = [...light.keys()].sort(); + const lightDecls = []; + const darkDecls = []; + const fallbackLight = []; + const fallbackDark = []; + const hexLight = {}; + const hexDark = {}; + for (const key of names) { + const cssName = varName(key.split(".")); + lightDecls.push(` ${cssName}: ${light.get(key)};`); + darkDecls.push(` ${cssName}: ${dark.get(key)};`); + const fbLight = fallbackCss(light.get(key)); + const fbDark = fallbackCss(dark.get(key)); + if (fbLight) { + hexLight[cssName] = fbLight.match(/#[0-9a-f]{6,8}/i)?.[0] ?? fbLight; + fallbackLight.push(` ${cssName}: ${fbLight};`); + } + if (fbDark) { + hexDark[cssName] = fbDark.match(/#[0-9a-f]{6,8}/i)?.[0] ?? fbDark; + fallbackDark.push(` ${cssName}: ${fbDark};`); + } + } + const css = `/* Generado por scripts/build-tokens.mjs. No editar a mano. Contrato DTCG OKLCH 1.0. */ +:root { + color-scheme: light; +${lightDecls.join("\n")} +} + +.dark { + color-scheme: dark; +${darkDecls.join("\n")} +} + +@supports not (color: oklch(0% 0 0)) { + :root { +${fallbackLight.join("\n")} + } + + .dark { +${fallbackDark.join("\n")} + } +} + +@media (prefers-reduced-motion: reduce) { + :root { + --nb-motion-duration-fast: 0.01ms; + --nb-motion-duration-base: 0.01ms; + --nb-motion-duration-slow: 0.01ms; + } +} +`; + const hexOut = `${JSON.stringify({ light: hexLight, dark: hexDark }, null, 2)}\n`; + return { css, hexOut, count: names.length }; +} + +const { index, docs } = loadIndex(TOKENS_DIR); +assertShape(docs); +const { light, dark } = resolveModes(index); +const { css, hexOut, count } = render(light, dark); + +if (process.argv.includes("--check")) { + let currentCss = ""; + let currentHex = ""; + try { + currentCss = readFileSync(OUT_CSS, "utf-8"); + currentHex = readFileSync(OUT_HEX, "utf-8"); + } catch { + console.error("tokens: faltan ficheros generados. Ejecuta node portal/frontend/scripts/build-tokens.mjs"); + process.exit(1); + } + if (currentCss !== css || currentHex !== hexOut) { + console.error("tokens: variables.css desactualizado. Ejecuta node portal/frontend/scripts/build-tokens.mjs"); + process.exit(1); + } + console.log(`tokens: ${count} variables al día`); + process.exit(0); +} + +mkdirSync(dirname(OUT_CSS), { recursive: true }); +writeFileSync(OUT_CSS, css); +writeFileSync(OUT_HEX, hexOut); +console.log(`tokens: ${count} variables → tokens/generated/variables.css`); diff --git a/portal/frontend/scripts/check-contrast.mjs b/portal/frontend/scripts/check-contrast.mjs new file mode 100644 index 0000000..982c9b0 --- /dev/null +++ b/portal/frontend/scripts/check-contrast.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// Gate WCAG 2.2 AA: texto/fondo ≥ 4.5:1, UI/bordes ≥ 3:1, claro y oscuro. +// Lee los tokens DTCG (no el CSS) y convierte OKLCH → sRGB solo para el ratio. +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { contrastRatio, hexForColor, loadIndex, resolveModes } from "./token-lib.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const { index } = loadIndex(join(root, "tokens")); +const { light, dark } = resolveModes(index); + +const PAIRS = [ + { name: "text/base sobre bg/base", fg: "text.base", bg: "bg.base", min: 4.5 }, + { name: "text/muted sobre bg/base", fg: "text.muted", bg: "bg.base", min: 4.5 }, + { name: "text/link sobre bg/base", fg: "text.link", bg: "bg.base", min: 4.5 }, + { name: "text/base sobre bg/surface", fg: "text.base", bg: "bg.surface", min: 4.5 }, + { name: "text/muted sobre bg/surface", fg: "text.muted", bg: "bg.surface", min: 4.5 }, + { name: "text/base sobre bg/muted", fg: "text.base", bg: "bg.muted", min: 4.5 }, + { name: "text/muted sobre bg/muted", fg: "text.muted", bg: "bg.muted", min: 4.5 }, + { name: "text/base sobre card/bg", fg: "text.base", bg: "card.bg", min: 4.5 }, + { name: "text/muted sobre card/bg", fg: "text.muted", bg: "card.bg", min: 4.5 }, + { name: "text/base sobre input/bg", fg: "input.text", bg: "input.bg", min: 4.5 }, + { name: "action/on-primary sobre primary-bg", fg: "action.on-primary", bg: "action.primary-bg", min: 4.5 }, + { name: "action/on-primary sobre primary-bg-hover", fg: "action.on-primary", bg: "action.primary-bg-hover", min: 4.5 }, + { name: "button/on-primary sobre button/primary-bg", fg: "button.on-primary", bg: "button.primary-bg", min: 4.5 }, + { name: "action/on-secondary sobre secondary-bg", fg: "action.on-secondary", bg: "action.secondary-bg", min: 4.5 }, + { name: "feedback/success-text sobre success-bg", fg: "feedback.success-text", bg: "feedback.success-bg", min: 4.5 }, + { name: "feedback/warning-text sobre warning-bg", fg: "feedback.warning-text", bg: "feedback.warning-bg", min: 4.5 }, + { name: "feedback/danger-text sobre danger-bg", fg: "feedback.danger-text", bg: "feedback.danger-bg", min: 4.5 }, + { name: "border/base sobre bg/base (UI)", fg: "border.base", bg: "bg.base", min: 3 }, + { name: "border/strong sobre bg/base (UI)", fg: "border.strong", bg: "bg.base", min: 3 }, + { name: "border/base sobre bg/surface (UI)", fg: "border.base", bg: "bg.surface", min: 3 }, + { name: "card/border sobre card/bg (UI)", fg: "card.border", bg: "card.bg", min: 3 }, + { name: "input/border sobre input/bg (UI)", fg: "input.border", bg: "input.bg", min: 3 }, + { name: "focus-ring sobre bg/base (UI)", fg: "action.focus-ring", bg: "bg.base", min: 3 }, + { name: "input/border-focus sobre input/bg (UI)", fg: "input.border-focus", bg: "input.bg", min: 3 }, +]; + +const modes = { light, dark }; +let failed = false; +for (const mode of ["light", "dark"]) { + console.log(`== modo ${mode} ==`); + for (const pair of PAIRS) { + const fgRaw = modes[mode].get(pair.fg); + const bgRaw = modes[mode].get(pair.bg); + if (!fgRaw || !bgRaw) { + console.error(`FAIL ${pair.name}: token ausente`); + failed = true; + continue; + } + const fg = hexForColor(fgRaw); + const bg = hexForColor(bgRaw); + if (!fg || !bg) { + console.error(`FAIL ${pair.name}: sin color resoluble (${fgRaw} / ${bgRaw})`); + failed = true; + continue; + } + const ratio = contrastRatio(fg, bg); + const ok = ratio >= pair.min; + if (!ok) failed = true; + console.log( + `${ok ? "OK " : "FAIL"} ${pair.name}: ${ratio.toFixed(2)}:1 (min ${pair.min}) [${fg} sobre ${bg}]` + ); + } +} + +if (failed) { + console.error("Contraste bajo umbral. Revisa tokens/semantic.color.tokens.json"); + process.exit(1); +} +console.log("Contraste WCAG AA verificado (claro y oscuro)"); diff --git a/portal/frontend/scripts/token-lib.mjs b/portal/frontend/scripts/token-lib.mjs new file mode 100644 index 0000000..43aa2c5 --- /dev/null +++ b/portal/frontend/scripts/token-lib.mjs @@ -0,0 +1,179 @@ +// Resolución DTCG sin dependencias. Lo usan el generador CSS y el gate de contraste. +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export function parseOklch(input) { + const m = String(input) + .trim() + .match(/^oklch\(\s*([0-9.]+%?)\s+(-?[0-9.]+)\s+(-?[0-9.]+)(?:\s*\/\s*([0-9.]+%?))?\s*\)$/i); + if (!m) throw new Error(`OKLCH invalido: ${input}`); + const num = (v, name) => { + const n = v.endsWith("%") ? parseFloat(v) / 100 : parseFloat(v); + if (!Number.isFinite(n)) throw new Error(`OKLCH invalido (${name}): ${input}`); + return n; + }; + const l = num(m[1], "L"); + const c = parseFloat(m[2]); + const h = parseFloat(m[3]); + const a = m[4] === undefined ? 1 : num(m[4], "alpha"); + if (!(l >= 0 && l <= 1)) throw new Error(`OKLCH fuera de rango (L): ${input}`); + if (!(c >= 0 && c <= 0.5)) throw new Error(`OKLCH fuera de rango (C): ${input}`); + if (!(h >= 0 && h <= 360)) throw new Error(`OKLCH fuera de rango (H): ${input}`); + if (!(a >= 0 && a <= 1)) throw new Error(`OKLCH fuera de rango (alpha): ${input}`); + return { l, c, h, a }; +} + +function oklchToRgb({ l, c, h }) { + const hr = (h * Math.PI) / 180; + const a = c * Math.cos(hr); + const b = c * Math.sin(hr); + const l_ = l + 0.3963377774 * a + 0.2158037573 * b; + const m_ = l - 0.1055613458 * a - 0.0638541728 * b; + const s_ = l - 0.0894841775 * a - 1.291485548 * b; + const l3 = l_ ** 3; + const m3 = m_ ** 3; + const s3 = s_ ** 3; + return { + r: 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3, + g: -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3, + b: -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3, + }; +} + +function linearToSrgb(channel) { + const v = Math.min(1, Math.max(0, channel)); + return v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055; +} + +export function oklchToHex(input) { + const { r, g, b } = oklchToRgb(parseOklch(input)); + const hx = (channel) => + Math.round(linearToSrgb(channel) * 255) + .toString(16) + .padStart(2, "0"); + return `#${hx(r)}${hx(g)}${hx(b)}`.toLowerCase(); +} + +function hexWithAlpha(hex, alpha) { + if (alpha >= 1) return hex; + const a = Math.round(alpha * 255) + .toString(16) + .padStart(2, "0"); + return `${hex}${a}`; +} + +export function fallbackCss(value) { + if (!/oklch\(/i.test(value)) return null; + return String(value).replace(/oklch\([^)]*\)/gi, (inner) => { + const parsed = parseOklch(inner); + return hexWithAlpha(oklchToHex(inner), parsed.a); + }); +} + +function isLeaf(node) { + return !!node && typeof node === "object" && !Array.isArray(node) && typeof node.$value === "string"; +} + +function collectLeaves(node, path, out) { + for (const key of Object.keys(node).sort()) { + if (key.startsWith("$")) continue; + const child = node[key]; + if (isLeaf(child)) out.push({ path: [...path, key], node: child }); + else if (child && typeof child === "object") collectLeaves(child, [...path, key], out); + } +} + +export function varName(path) { + return `--nb-${path.join("-")}`; +} + +export function loadIndex(tokensDir) { + const files = readdirSync(tokensDir) + .filter((f) => f.endsWith(".tokens.json")) + .sort(); + if (files.length === 0) throw new Error(`Sin ficheros ${tokensDir}/*.tokens.json`); + const index = new Map(); + const docs = []; + for (const file of files) { + const json = JSON.parse(readFileSync(join(tokensDir, file), "utf-8")); + if (json.$meta?.version !== "1.0") { + throw new Error(`${file}: $meta.version debe ser "1.0"`); + } + docs.push({ file, json }); + const leaves = []; + collectLeaves(json, [], leaves); + for (const leaf of leaves) { + const key = leaf.path.join("."); + if (index.has(key)) throw new Error(`Token duplicado: ${key} (en ${file})`); + index.set(key, { ...leaf, file }); + } + } + return { files, index, docs }; +} + +function resolveForMode(raw, mode, index, stack) { + return String(raw).replace(/\{([a-z0-9._-]+)\}/gi, (_m, ref) => { + if (stack.includes(ref)) throw new Error(`Referencia circular: ${[...stack, ref].join(" -> ")}`); + const target = index.get(ref); + if (!target) throw new Error(`Referencia sin resolver: {${ref}}`); + const targetRaw = target.node.$extensions?.mode?.[mode] ?? target.node.$value; + return resolveForMode(targetRaw, mode, index, [...stack, ref]); + }); +} + +export function resolveModes(index) { + const light = new Map(); + const dark = new Map(); + const types = new Map(); + for (const [key, leaf] of [...index.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) { + const modes = leaf.node.$extensions?.mode; + const lightRaw = modes?.light ?? leaf.node.$value; + const darkRaw = modes?.dark ?? lightRaw; + const lightValue = resolveForMode(lightRaw, "light", index, [key]); + const darkValue = resolveForMode(darkRaw, "dark", index, [key]); + if (leaf.node.$type === "color" && !/^oklch\(/i.test(lightValue)) { + throw new Error(`Color sin OKLCH resuelto (${key} light): ${lightValue}`); + } + if (leaf.node.$type === "color" && !/^oklch\(/i.test(darkValue)) { + throw new Error(`Color sin OKLCH resuelto (${key} dark): ${darkValue}`); + } + for (const value of [lightValue, darkValue]) { + const found = value.match(/oklch\([^)]*\)/gi) || []; + for (const inner of found) parseOklch(inner); + } + light.set(key, lightValue); + dark.set(key, darkValue); + types.set(key, leaf.node.$type || ""); + } + return { light, dark, types }; +} + +function hexToRgb(hex) { + const raw = hex.replace("#", "").slice(0, 6); + return { + r: parseInt(raw.slice(0, 2), 16) / 255, + g: parseInt(raw.slice(2, 4), 16) / 255, + b: parseInt(raw.slice(4, 6), 16) / 255, + }; +} + +function lin(c) { + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +export function contrastRatio(fgHex, bgHex) { + const lum = (hex) => { + const { r, g, b } = hexToRgb(hex); + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + }; + const a = lum(fgHex); + const b = lum(bgHex); + const [hi, lo] = a >= b ? [a, b] : [b, a]; + return (hi + 0.05) / (lo + 0.05); +} + +export function hexForColor(value) { + const m = String(value).match(/oklch\([^)]*\)/i); + if (!m) return null; + return oklchToHex(m[0]); +} diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx index ba2c5af..29fad15 100644 --- a/portal/frontend/src/App.tsx +++ b/portal/frontend/src/App.tsx @@ -10,8 +10,8 @@ import { HomePage } from '@/pages/HomePage'; function App() { return ( - - + + } /> @@ -23,8 +23,8 @@ function App() { } /> - - + + ); } diff --git a/portal/frontend/src/components/Brand.tsx b/portal/frontend/src/components/Brand.tsx index 05be7d0..7152e66 100644 --- a/portal/frontend/src/components/Brand.tsx +++ b/portal/frontend/src/components/Brand.tsx @@ -3,13 +3,13 @@ import { Link } from 'react-router-dom'; export function Brand({ admin = false }: { admin?: boolean }) { return ( -
+ N -
-

- NEUBAT + + + NEUBAT {admin && · Admin} -

+ ); } diff --git a/portal/frontend/src/components/Layout.tsx b/portal/frontend/src/components/Layout.tsx index 45db6bf..0b9a78f 100644 --- a/portal/frontend/src/components/Layout.tsx +++ b/portal/frontend/src/components/Layout.tsx @@ -1,9 +1,38 @@ import { Brand } from './Brand'; import { Button } from '@/components/ui/button'; import { Link, useLocation } from 'react-router-dom'; -import { BookOpen, Download, Shield, UserRound, Wand2 } from 'lucide-react'; +import { BookOpen, Download, Moon, Shield, Sun, UserRound, Wand2 } from 'lucide-react'; import { useAuth } from '@/lib/auth'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; + +function ThemeToggle() { + const [theme, setTheme] = useState<'light' | 'dark'>(() => { + const stored = localStorage.getItem('neubat-theme'); + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }); + + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + document.documentElement.style.colorScheme = theme; + localStorage.setItem('neubat-theme', theme); + }, [theme]); + + const next = theme === 'dark' ? 'light' : 'dark'; + return ( + + ); +} export function Layout({ children }: { children: React.ReactNode }) { const location = useLocation(); @@ -22,7 +51,7 @@ export function Layout({ children }: { children: React.ReactNode }) { }, [location.pathname]); return ( -
+
Saltar al contenido @@ -63,6 +92,7 @@ export function Layout({ children }: { children: React.ReactNode }) { Wiki +
diff --git a/portal/frontend/src/index.css b/portal/frontend/src/index.css index 92ca97a..0600c8a 100644 --- a/portal/frontend/src/index.css +++ b/portal/frontend/src/index.css @@ -1,12 +1,13 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import "../tokens/generated/variables.css"; @custom-variant dark (&:is(.dark *)); @theme inline { --font-heading: var(--font-sans); - --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-mono: ui-monospace, "Fira Code", Consolas, monospace; + --font-sans: var(--nb-typography-family-sans); + --font-mono: var(--nb-typography-family-mono); --color-background: var(--background); --color-foreground: var(--foreground); @@ -27,57 +28,57 @@ --color-input: var(--input); --color-ring: var(--ring); - --radius-sm: 0.375rem; - --radius-md: 0.5rem; - --radius-lg: 0.625rem; - --radius-xl: 0.875rem; + --radius-sm: var(--nb-radius-sm); + --radius-md: var(--nb-radius-md); + --radius-lg: var(--nb-radius-lg); + --radius-xl: var(--nb-radius-xl); } -/* Tema claro: contraste AA/AAA sobre fondo claro */ +/* Alias de la SPA. Los oklch viven en tokens/generated/variables.css. */ :root { color-scheme: light; - --background: oklch(0.985 0.01 250); - --foreground: oklch(0.22 0.03 255); - --card: oklch(1 0 0); - --card-foreground: oklch(0.22 0.03 255); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.22 0.03 255); - --primary: oklch(0.45 0.14 240); - --primary-foreground: oklch(0.99 0.01 250); - --secondary: oklch(0.94 0.02 250); - --secondary-foreground: oklch(0.28 0.04 255); - --muted: oklch(0.94 0.015 250); - --muted-foreground: oklch(0.42 0.03 255); - --accent: oklch(0.55 0.16 285); - --accent-foreground: oklch(0.99 0.01 285); - --destructive: oklch(0.5 0.2 25); - --border: oklch(0.88 0.02 250); - --input: oklch(0.88 0.02 250); - --ring: oklch(0.45 0.14 240); - --radius: 0.625rem; + --background: var(--nb-bg-base); + --foreground: var(--nb-text-base); + --card: var(--nb-card-bg); + --card-foreground: var(--nb-text-base); + --popover: var(--nb-bg-surface); + --popover-foreground: var(--nb-text-base); + --primary: var(--nb-action-primary-bg); + --primary-foreground: var(--nb-action-on-primary); + --secondary: var(--nb-action-secondary-bg); + --secondary-foreground: var(--nb-action-on-secondary); + --muted: var(--nb-bg-muted); + --muted-foreground: var(--nb-text-muted); + --accent: var(--nb-bg-muted); + --accent-foreground: var(--nb-text-base); + --destructive: var(--nb-feedback-danger-bg); + --border: var(--nb-border-base); + --input: var(--nb-input-border); + --ring: var(--nb-action-focus-ring); + --radius: var(--nb-radius-lg); } -/* Tema oscuro por defecto en la app */ .dark { color-scheme: dark; - --background: oklch(0.16 0.025 255); - --foreground: oklch(0.94 0.015 250); - --card: oklch(0.2 0.03 255); - --card-foreground: oklch(0.94 0.015 250); - --popover: oklch(0.2 0.03 255); - --popover-foreground: oklch(0.94 0.015 250); - --primary: oklch(0.78 0.12 220); - --primary-foreground: oklch(0.18 0.03 255); - --secondary: oklch(0.26 0.035 255); - --secondary-foreground: oklch(0.94 0.015 250); - --muted: oklch(0.26 0.035 255); - --muted-foreground: oklch(0.72 0.03 250); - --accent: oklch(0.7 0.14 285); - --accent-foreground: oklch(0.99 0.01 285); - --destructive: oklch(0.65 0.2 25); - --border: oklch(0.32 0.04 255); - --input: oklch(0.32 0.04 255); - --ring: oklch(0.78 0.12 220); + --background: var(--nb-bg-base); + --foreground: var(--nb-text-base); + --card: var(--nb-card-bg); + --card-foreground: var(--nb-text-base); + --popover: var(--nb-bg-surface); + --popover-foreground: var(--nb-text-base); + --primary: var(--nb-action-primary-bg); + --primary-foreground: var(--nb-action-on-primary); + --secondary: var(--nb-action-secondary-bg); + --secondary-foreground: var(--nb-action-on-secondary); + --muted: var(--nb-bg-muted); + --muted-foreground: var(--nb-text-muted); + --accent: var(--nb-bg-muted); + --accent-foreground: var(--nb-text-base); + --destructive: var(--nb-feedback-danger-bg); + --border: var(--nb-border-base); + --input: var(--nb-input-border); + --ring: var(--nb-action-focus-ring); + --radius: var(--nb-radius-lg); } @layer base { diff --git a/portal/frontend/src/lib/auth.tsx b/portal/frontend/src/lib/auth.tsx index 325945d..c6c3028 100644 --- a/portal/frontend/src/lib/auth.tsx +++ b/portal/frontend/src/lib/auth.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { api } from '@/lib/api'; import type { User } from '@/types'; @@ -12,8 +13,10 @@ interface AuthContextValue { const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { + const location = useLocation(); const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); + const [settledPath, setSettledPath] = useState(null); + const loading = location.pathname !== '/' && settledPath !== location.pathname; const refresh = useCallback(async () => { try { @@ -22,20 +25,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch { setUser(null); } finally { - setLoading(false); + setSettledPath(window.location.pathname); } }, []); useEffect(() => { - refresh(); - }, [refresh]); + if (location.pathname === '/') return; + let cancelled = false; + const path = location.pathname; + api + .me() + .then((data) => { + if (!cancelled) setUser(data.user); + }) + .catch(() => { + if (!cancelled) setUser(null); + }) + .finally(() => { + if (!cancelled) setSettledPath(path); + }); + return () => { + cancelled = true; + }; + }, [location.pathname]); const logout = useCallback(async () => { - await api.logout(); + try { + await api.logout(); + } catch { + /* Sin portal la cookie no se puede revocar; la UI deja de mostrar sesión. */ + } setUser(null); }, []); - const value = useMemo(() => ({ user, loading, refresh, logout }), [user, loading, refresh, logout]); + const value = useMemo( + () => ({ user, loading, refresh, logout }), + [user, loading, refresh, logout] + ); return {children}; } diff --git a/portal/frontend/src/lib/install-config.test.ts b/portal/frontend/src/lib/install-config.test.ts new file mode 100644 index 0000000..5cad3e3 --- /dev/null +++ b/portal/frontend/src/lib/install-config.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { installJsonFilename, installJsonText } from './install-config'; + +describe('install-config', () => { + it('serializa el cuerpo de instalación sin campos de portal', () => { + const text = installJsonText({ + profile: 'production', + hostname: 'equipo-1', + desktop: 'kde', + packages: ['git'], + encryption: { enabled: true, method: 'keyfile' }, + snapshots: { enabled: true }, + }); + const body = JSON.parse(text); + expect(body.hostname).toBe('equipo-1'); + expect(body.token).toBeUndefined(); + expect(body.boot_url).toBeUndefined(); + expect(installJsonFilename(body)).toBe('neubat-equipo-1.json'); + }); +}); diff --git a/portal/frontend/src/lib/install-config.ts b/portal/frontend/src/lib/install-config.ts new file mode 100644 index 0000000..a7f59fa --- /dev/null +++ b/portal/frontend/src/lib/install-config.ts @@ -0,0 +1,25 @@ +import type { InstallRequest } from '@/types'; + +export function installJsonFilename(body: InstallRequest) { + const raw = (body.hostname || 'config').toLowerCase(); + const safe = raw.replace(/[^a-z0-9-]+/g, '').replace(/^-+|-+$/g, '') || 'config'; + return `neubat-${safe}.json`; +} + +export function installJsonText(body: InstallRequest) { + return `${JSON.stringify(body, null, 2)}\n`; +} + +/** Descarga el cuerpo de instalación en el navegador, sin POST ni Express. */ +export function downloadInstallJson(body: InstallRequest, doc: Document = document) { + const blob = new Blob([installJsonText(body)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = doc.createElement('a'); + a.href = url; + a.download = installJsonFilename(body); + a.rel = 'noopener'; + doc.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/portal/frontend/src/pages/ConfigurePage.test.tsx b/portal/frontend/src/pages/ConfigurePage.test.tsx new file mode 100644 index 0000000..e747c69 --- /dev/null +++ b/portal/frontend/src/pages/ConfigurePage.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { ConfigurePage } from './ConfigurePage'; +import { AuthProvider } from '@/lib/auth'; + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +describe('ConfigurePage', () => { + beforeEach(() => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:neubat'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('descarga el JSON sin POST /api/install', async () => { + render(, { wrapper: Wrapper }); + await userEvent.click(screen.getByRole('button', { name: 'Descargar JSON' })); + + const posts = (globalThis.fetch as ReturnType).mock.calls.filter((call) => { + const init = call[1] as RequestInit | undefined; + return init?.method === 'POST' && String(call[0]).includes('/api/install'); + }); + expect(posts).toHaveLength(0); + expect(URL.createObjectURL).toHaveBeenCalled(); + expect(screen.getAllByText(/Instalar por iPXE sigue exigiendo el portal/i).length).toBeGreaterThan(0); + }); + + it('si el portal no responde, pasa a modo local', async () => { + render(, { wrapper: Wrapper }); + await userEvent.click(screen.getByRole('button', { name: /Generar instalación/i })); + + await waitFor(() => { + expect(screen.getByText(/Modo local/i)).toBeInTheDocument(); + }); + expect(URL.createObjectURL).toHaveBeenCalled(); + }); +}); diff --git a/portal/frontend/src/pages/ConfigurePage.tsx b/portal/frontend/src/pages/ConfigurePage.tsx index 4d3b383..7b2de99 100644 --- a/portal/frontend/src/pages/ConfigurePage.tsx +++ b/portal/frontend/src/pages/ConfigurePage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '@/lib/api'; +import { downloadInstallJson } from '@/lib/install-config'; import { useAuth } from '@/lib/auth'; import type { InstallRequest, InstallResponse, Recommendation } from '@/types'; import { Button } from '@/components/ui/button'; @@ -43,13 +44,25 @@ export function ConfigurePage() { const [encryptionMethod, setEncryptionMethod] = useState<'keyfile' | 'prompt'>('keyfile'); const [enableSnapshots, setEnableSnapshots] = useState(true); const [desktop, setDesktop] = useState('kde'); + const [profile, setProfile] = useState('production'); const [selectedPackages, setSelectedPackages] = useState(['git', 'htop']); const [packageQuery, setPackageQuery] = useState(''); const [recommendations, setRecommendations] = useState([]); + const [recsOffline, setRecsOffline] = useState(false); const [saveName, setSaveName] = useState(''); + const [localBody, setLocalBody] = useState(null); useEffect(() => { - api.recommendations().then((r) => setRecommendations(r.recommendations)).catch(() => {}); + api + .recommendations() + .then((r) => { + setRecommendations(r.recommendations); + setRecsOffline(false); + }) + .catch(() => { + setRecommendations([]); + setRecsOffline(true); + }); }, []); const catalog = useMemo(() => Object.values(PACKAGE_GROUPS).flat(), []); @@ -66,25 +79,18 @@ export function ConfigurePage() { if (rec.packages?.length) setSelectedPackages(rec.packages); } - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setSubmitting(true); - setError(null); - setResult(null); - - const form = new FormData(e.currentTarget); - const extra = ((form.get('packages_extra') as string) || '') - .split(/\s+/) - .filter(Boolean); + function buildBody(form: HTMLFormElement): InstallRequest { + const data = new FormData(form); + const extra = ((data.get('packages_extra') as string) || '').split(/\s+/).filter(Boolean); const body: InstallRequest = { - profile: (form.get('profile') as string) || 'base', - hostname: (form.get('hostname') as string) || undefined, - username: (form.get('username') as string) || undefined, + profile: profile || 'base', + hostname: (data.get('hostname') as string) || undefined, + username: (data.get('username') as string) || undefined, desktop, packages: [...new Set([...selectedPackages, ...extra])], - locale: (form.get('locale') as string) || 'es_ES.UTF-8', - keyboard: (form.get('keyboard') as string) || 'es', - timezone: (form.get('timezone') as string) || 'Europe/Madrid', + locale: (data.get('locale') as string) || 'es_ES.UTF-8', + keyboard: (data.get('keyboard') as string) || 'es', + timezone: (data.get('timezone') as string) || 'Europe/Madrid', }; if (enableEncryption) { body.encryption = { @@ -93,15 +99,39 @@ export function ConfigurePage() { }; } if (enableSnapshots) body.snapshots = { enabled: true }; + return body; + } + + function handleDownload(form: HTMLFormElement) { + const body = buildBody(form); + setLocalBody(body); + setError(null); + downloadInstallJson(body); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + setResult(null); + + const body = buildBody(e.currentTarget); try { const data = await api.install(body); setResult(data); + setLocalBody(null); if (user && saveName.trim()) { - await api.saveConfig({ name: saveName.trim(), ...body }); + try { + await api.saveConfig({ name: saveName.trim(), ...body }); + } catch { + setError('La instalación quedó en el portal, pero no se pudo guardar en la cuenta.'); + } } - } catch (err) { - setError(err instanceof Error ? err.message : 'Error desconocido'); + } catch { + setResult(null); + setLocalBody(body); + downloadInstallJson(body); } finally { setSubmitting(false); } @@ -121,7 +151,11 @@ export function ConfigurePage() { Configurar instalación

- Elige escritorio, paquetes y opciones. Genera una URL iPXE o guarda el perfil en tu cuenta. + Elige escritorio, paquetes y opciones. Si el portal responde, registra la instalación. Si no, + descarga el JSON y úsalo cuando el portal esté en marcha. +

+

+ Instalar por iPXE sigue exigiendo el portal. Este asistente no arranca la máquina por sí solo.

{!user && (

@@ -147,7 +181,7 @@ export function ConfigurePage() {

- @@ -294,9 +328,22 @@ export function ConfigurePage() {
)} - +
+ + +
{error && ( @@ -305,6 +352,18 @@ export function ConfigurePage() {
)} + {localBody && ( +
+

Modo local: el portal no ha registrado esta instalación.

+

+ El JSON ya se puede descargar. Instalar por iPXE sigue exigiendo el portal: sin{' '} + boot_url la máquina no arranca por red. +

+
+                  {JSON.stringify(localBody, null, 2)}
+                
+
+ )} {result && (
@@ -334,6 +393,11 @@ export function ConfigurePage() { Perfiles curados como punto de partida. + {recsOffline && ( +

+ Sin portal no hay recomendaciones. El formulario y la descarga del JSON siguen disponibles. +

+ )} {recommendations.map((rec) => (

{rec.title}

diff --git a/portal/frontend/src/pages/HomePage.test.tsx b/portal/frontend/src/pages/HomePage.test.tsx index 27bdea3..128e5d8 100644 --- a/portal/frontend/src/pages/HomePage.test.tsx +++ b/portal/frontend/src/pages/HomePage.test.tsx @@ -7,19 +7,35 @@ import { AuthProvider } from '@/lib/auth'; function Wrapper({ children }: { children: React.ReactNode }) { return ( - - {children} - + + {children} + ); } describe('HomePage', () => { beforeEach(() => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 401, - json: async () => ({ error: 'Sin sesión' }), - }); + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/install') && init?.method === 'POST') { + return { + ok: true, + json: async () => ({ + success: true, + token: 'tokentest', + machine_id: 'machine1', + config_url: '/api/config/tokentest', + boot_url: '/boot/tokentest', + message: 'Creada', + }), + }; + } + return { + ok: false, + status: 401, + json: async () => ({ error: 'Sin sesión' }), + }; + }) as unknown as typeof fetch; }); afterEach(() => { @@ -35,24 +51,6 @@ describe('HomePage', () => { }); it('crea una instalación y muestra resultados', async () => { - (globalThis.fetch as ReturnType) - .mockResolvedValueOnce({ - ok: false, - status: 401, - json: async () => ({ error: 'Sin sesión' }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - success: true, - token: 'tokentest', - machine_id: 'machine1', - config_url: '/api/config/tokentest', - boot_url: '/boot/tokentest', - message: 'Creada', - }), - }); - render(, { wrapper: Wrapper }); const button = screen.getByRole('button', { name: /Generar instalación/i }); diff --git a/portal/frontend/src/pages/HomePage.tsx b/portal/frontend/src/pages/HomePage.tsx index 0ecaaa9..2c9657d 100644 --- a/portal/frontend/src/pages/HomePage.tsx +++ b/portal/frontend/src/pages/HomePage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { api } from '@/lib/api'; +import { downloadInstallJson } from '@/lib/install-config'; import type { InstallRequest, InstallResponse } from '@/types'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -21,35 +22,41 @@ export function HomePage() { const [enableEncryption, setEnableEncryption] = useState(true); const [encryptionMethod, setEncryptionMethod] = useState<'keyfile' | 'passphrase'>('keyfile'); const [enableSnapshots, setEnableSnapshots] = useState(true); + const [localBody, setLocalBody] = useState(null); - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setSubmitting(true); - setError(null); - setResult(null); - - const form = new FormData(e.currentTarget); + function buildBody(form: HTMLFormElement): InstallRequest { + const data = new FormData(form); const body: InstallRequest = { - profile: form.get('profile') as string, - hostname: (form.get('hostname') as string) || undefined, - username: (form.get('username') as string) || undefined, - packages: (form.get('packages') as string) - .split(/\s+/) - .filter(Boolean), + profile: (data.get('profile') as string) || 'production', + hostname: (data.get('hostname') as string) || undefined, + username: (data.get('username') as string) || undefined, + packages: ((data.get('packages') as string) || '').split(/\s+/).filter(Boolean), }; - if (enableEncryption) { body.encryption = { enabled: true, method: encryptionMethod }; } if (enableSnapshots) { body.snapshots = { enabled: true }; } + return body; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + setResult(null); + + const body = buildBody(e.currentTarget); try { const data = await api.install(body); setResult(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Error desconocido'); + setLocalBody(null); + } catch { + setResult(null); + setLocalBody(body); + downloadInstallJson(body); } finally { setSubmitting(false); } @@ -69,7 +76,8 @@ export function HomePage() { Nueva instalación

- Configura el sistema, obtén tu URL única y arranca por iPXE. Sin USB, sin intervención. + Configura el sistema y, si el portal responde, obtén la URL de iPXE. Instalar por iPXE sigue + exigiendo el portal.

@@ -180,11 +188,37 @@ export function HomePage() {
- +
+ + +
+ {localBody && ( +
+

Modo local: JSON descargado sin registrar en el portal.

+

+ Instalar por iPXE sigue exigiendo el portal. +

+
+ )} + {error && (
- {children} - + + {children} + ); } describe('LandingPage', () => { beforeEach(() => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - json: async () => ({}), - }); + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); }); afterEach(() => { vi.restoreAllMocks(); }); - it('presenta el producto y enlaces principales', () => { + it('presenta las secciones en orden y no llama a la API', () => { render(, { wrapper: Wrapper }); - expect(screen.getByRole('heading', { name: /NEUBAT: tu Arch/i })).toBeInTheDocument(); + + const headings = screen.getAllByRole('heading').map((node) => node.textContent); + expect(headings).toEqual([ + 'NEUBAT', + 'Propuesta', + 'Características', + 'Funcionalidades', + 'Roadmap', + 'Cierre', + ]); expect(screen.getByRole('link', { name: /Configurar instalación/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Descargar ISO/i })).toBeInTheDocument(); + expect(screen.getByText(/sigue necesitando el portal/i)).toBeInTheDocument(); + expect(globalThis.fetch).not.toHaveBeenCalled(); }); }); diff --git a/portal/frontend/src/pages/LandingPage.tsx b/portal/frontend/src/pages/LandingPage.tsx index 30a841c..7e8b45c 100644 --- a/portal/frontend/src/pages/LandingPage.tsx +++ b/portal/frontend/src/pages/LandingPage.tsx @@ -1,76 +1,121 @@ +import type { ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Cable, Download, HardDrive, ShieldCheck, Sparkles } from 'lucide-react'; +import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Cable, Download, FileJson, HardDrive, ShieldCheck } from 'lucide-react'; export function LandingPage() { return ( -
-
-

Arch Linux personalizado

-

- NEUBAT: tu Arch, tu ISO, tu red +
+
+

+ Instalación desatendida de Arch Linux +

+

+ NEUBAT

-

- Configura desde el navegador una instalación desatendida de Arch Linux, descarga la ISO con - verificación automática del hash o arranca solo con un cable Ethernet. +

+ +
+

+ Propuesta +

+

+ NEUBAT define en el navegador una instalación desatendida de Arch Linux y la entrega como JSON + local, ISO verificada o URL de arranque.

-
- - - -
-
-

+
+

Características

- } - title="Configurador web" - text="Paquetes, escritorio o compositor (KDE, GNOME, Hyprland, Sway…), locale y cifrado desde el navegador." - /> - } - title="Netinstall por URL" - text="Copia el enlace iPXE, conecta Ethernet y la máquina arranca e instala sin USB." - /> - } - title="Absorbe tu sistema" - text="Un agente local inventaría paquetes y dotfiles allowlist y los guarda en tu perfil NEUBAT." - /> - } - title="ISO con hash automático" - text="Descarga desde el repositorio oficial; el navegador comprueba SHA-256 antes de guardar el fichero." - /> - } - title="Cuenta y recomendaciones" - text="Guarda configuraciones, confirma copias del sistema y aplica perfiles curados por el equipo." - /> +
    + + + +
+
+ +
+

+ Funcionalidades +

+
    + } + title="Asistente en el navegador" + text="Escritorio, paquetes, teclado y cifrado. El JSON se arma aquí y se puede descargar sin Express." + /> + } + title="ISO con hash" + text="La descarga de la ISO comprueba SHA-256 antes de guardar el fichero, si el portal publica el release." + /> + } + title="Arranque por URL" + text="Con el portal en marcha, iPXE encadena boot_url y la máquina instala sin USB." + /> + } + title="Absorber un Arch ya instalado" + text="Un agente local inventaría paquetes y dotfiles de una lista cerrada y los deja en el perfil." + /> + } + title="Cuenta opcional" + text="Registrar, guardar configuraciones y ver recomendaciones exige el portal. Armar el JSON, no." + /> +
-
-

- Cómo funciona +
+

+ Roadmap

-
    -
  1. Crea una cuenta y elige un perfil recomendado o absorbe tu Arch actual.
  2. -
  3. Personaliza paquetes y escritorio en el configurador.
  4. -
  5. Arranca por iPXE con la URL generada, o descarga la ISO verificada.
  6. -
  7. archinstall instalará la base; los scripts NEUBAT completan LUKS, snapper y el portal local.
  8. -
+

+ Hecho y en el repositorio: portal, GUI, ISO híbrida, LUKS2, snapper, firma HMAC y el agente de + absorción. +

+

+ El instalador iPXE real sigue necesitando el portal. El JSON descargado no arranca una máquina: iPXE + pide boot_url para bajar kernel, initrd y la configuración. +

+
    +
  • Validar en VM el conjunto LUKS + snapper + HMAC, no solo cada pieza por separado.
  • +
  • Rotar el keyfile de /boot tras el primer arranque.
  • +
  • Servir un mirror iPXE propio; hoy el arranque depende del portal y del mirror de Arch.
  • +
+
+ +
+

+ Cierre +

+

+ Esta página y el asistente se abren con el servidor de Vite aunque Express no esté. Registrar la + instalación en el portal es opcional. Instalar por iPXE sigue exigiendo el portal. +

+
+ + +
-

+ ); } @@ -79,20 +124,21 @@ function Feature({ title, text, }: { - icon: React.ReactNode; + icon?: ReactNode; title: string; text: string; }) { return ( - - - - {icon} - {title} - - {text} - - - +
  • + + + + {icon} + {title} + + {text} + + +
  • ); } diff --git a/portal/frontend/tokens/CONTRACT.md b/portal/frontend/tokens/CONTRACT.md new file mode 100644 index 0000000..45720f5 --- /dev/null +++ b/portal/frontend/tokens/CONTRACT.md @@ -0,0 +1,37 @@ +# Contrato de tokens NEUBAT + +Versión: **1.0** + +Formato: W3C DTCG. Color en OKLCH. Paleta propia de NEUBAT (azules de la SPA), no la de otro producto. + +## Hojas + +| Fichero | Contenido | +| --- | --- | +| `primitive.color.tokens.json` | `brand`, `neutral`, `success`, `warning`, `danger`, `focus` | +| `semantic.color.tokens.json` | `bg`, `text`, `border`, `action`, `feedback` con modos `light` y `dark` | +| `primitive.dimension.tokens.json` | `space`, `radius`, `shadow`, `motion`, `z`, `breakpoint` | +| `semantic.typography.tokens.json` | familia, peso, tamaño, interlineado, tracking | +| `component.alias.tokens.json` | `button`, `card`, `input` → semánticos | + +Cada hoja lleva `$meta.version` `"1.0"`. Las referencias usan `{grupo.token}`. + +## Build + +```bash +node portal/frontend/scripts/build-tokens.mjs +node portal/frontend/scripts/build-tokens.mjs --check +node portal/frontend/scripts/check-contrast.mjs +``` + +El generador es Node puro (sin dependencias). Escribe `tokens/generated/variables.css` y `tokens/generated/fallback-hex.json`. + +`src/index.css` mapea las variables de la SPA (`--background`, `--primary`, radios, fuentes) a `--nb-*`. Los componentes no repiten `oklch()` literales. El hex solo aparece como fallback de `@supports not (color: oklch(0% 0 0))` y como entrada del gate de contraste. + +## Contraste + +Texto sobre fondo ≥ 4.5:1. Bordes, anillo de foco y demás UI ≥ 3:1. Se evalúa en claro y en oscuro. `make smoke` ejecuta el check y falla si el CSS generado no está al día. + +## Tema + +`html.dark` activa el modo oscuro. Sin preferencia guardada se sigue `prefers-color-scheme`. `prefers-reduced-motion: reduce` acorta las duraciones de `motion` a `0.01ms` y anula animaciones en `index.css`. diff --git a/portal/frontend/tokens/component.alias.tokens.json b/portal/frontend/tokens/component.alias.tokens.json new file mode 100644 index 0000000..caae956 --- /dev/null +++ b/portal/frontend/tokens/component.alias.tokens.json @@ -0,0 +1,35 @@ +{ + "$meta": { + "ds": "neubat", + "tier": "component", + "version": "1.0", + "$description": "Alias de componente. Apuntan a tokens semánticos, no a primitivos de color." + }, + "button": { + "primary-bg": { "$value": "{action.primary-bg}", "$type": "color" }, + "primary-bg-hover": { "$value": "{action.primary-bg-hover}", "$type": "color" }, + "on-primary": { "$value": "{action.on-primary}", "$type": "color" }, + "secondary-bg": { "$value": "{action.secondary-bg}", "$type": "color" }, + "on-secondary": { "$value": "{action.on-secondary}", "$type": "color" }, + "radius": { "$value": "{radius.md}", "$type": "borderRadius" }, + "padding-x": { "$value": "{space.4}", "$type": "dimension" }, + "padding-y": { "$value": "{space.2}", "$type": "dimension" }, + "focus-ring": { "$value": "{action.focus-ring}", "$type": "color" } + }, + "card": { + "bg": { "$value": "{bg.surface}", "$type": "color" }, + "border": { "$value": "{border.strong}", "$type": "color" }, + "radius": { "$value": "{radius.lg}", "$type": "borderRadius" }, + "padding": { "$value": "{space.6}", "$type": "dimension" }, + "shadow": { "$value": "{shadow.md}", "$type": "shadow" } + }, + "input": { + "bg": { "$value": "{bg.base}", "$type": "color" }, + "border": { "$value": "{border.base}", "$type": "color" }, + "border-focus": { "$value": "{action.focus-ring}", "$type": "color" }, + "text": { "$value": "{text.base}", "$type": "color" }, + "radius": { "$value": "{radius.md}", "$type": "borderRadius" }, + "padding-x": { "$value": "{space.3}", "$type": "dimension" }, + "padding-y": { "$value": "{space.2}", "$type": "dimension" } + } +} diff --git a/portal/frontend/tokens/generated/fallback-hex.json b/portal/frontend/tokens/generated/fallback-hex.json new file mode 100644 index 0000000..10186e0 --- /dev/null +++ b/portal/frontend/tokens/generated/fallback-hex.json @@ -0,0 +1,148 @@ +{ + "light": { + "--nb-action-focus-ring": "#005b9a", + "--nb-action-on-primary": "#ffffff", + "--nb-action-on-secondary": "#1b2a3c", + "--nb-action-primary-bg": "#005b9a", + "--nb-action-primary-bg-hover": "#00477f", + "--nb-action-secondary-bg": "#e4ecf5", + "--nb-bg-base": "#f5fbff", + "--nb-bg-muted": "#e4ecf5", + "--nb-bg-surface": "#ffffff", + "--nb-border-base": "#5f6a77", + "--nb-border-strong": "#424e5e", + "--nb-button-focus-ring": "#005b9a", + "--nb-button-on-primary": "#ffffff", + "--nb-button-on-secondary": "#1b2a3c", + "--nb-button-primary-bg": "#005b9a", + "--nb-button-primary-bg-hover": "#00477f", + "--nb-button-secondary-bg": "#e4ecf5", + "--nb-card-bg": "#ffffff", + "--nb-card-border": "#424e5e", + "--nb-card-shadow": "#060e1838", + "--nb-color-brand-100": "#d7ebfa", + "--nb-color-brand-200": "#93d5f5", + "--nb-color-brand-300": "#4ac9ec", + "--nb-color-brand-400": "#25afd2", + "--nb-color-brand-500": "#0079ba", + "--nb-color-brand-600": "#005b9a", + "--nb-color-brand-700": "#004d8a", + "--nb-color-brand-800": "#00477f", + "--nb-color-brand-900": "#002c4c", + "--nb-color-danger-100": "#ffdedb", + "--nb-color-danger-600": "#a20519", + "--nb-color-danger-700": "#83000d", + "--nb-color-focus-ring": "#005b9a", + "--nb-color-focus-ring-strong": "#4ac9ec", + "--nb-color-neutral-0": "#ffffff", + "--nb-color-neutral-100": "#e4ecf5", + "--nb-color-neutral-200": "#ced9e5", + "--nb-color-neutral-300": "#aeb9c4", + "--nb-color-neutral-400": "#7a8798", + "--nb-color-neutral-50": "#f5fbff", + "--nb-color-neutral-500": "#5f6a77", + "--nb-color-neutral-600": "#424e5e", + "--nb-color-neutral-700": "#384352", + "--nb-color-neutral-800": "#1b2a3c", + "--nb-color-neutral-850": "#182535", + "--nb-color-neutral-900": "#111b28", + "--nb-color-neutral-920": "#0c1723", + "--nb-color-neutral-940": "#08121f", + "--nb-color-neutral-950": "#060e18", + "--nb-color-success-100": "#d4f0dc", + "--nb-color-success-600": "#006933", + "--nb-color-success-700": "#005423", + "--nb-color-warning-100": "#fae9ce", + "--nb-color-warning-600": "#774b00", + "--nb-color-warning-700": "#603900", + "--nb-feedback-danger-bg": "#a20519", + "--nb-feedback-danger-text": "#ffffff", + "--nb-feedback-success-bg": "#006933", + "--nb-feedback-success-text": "#ffffff", + "--nb-feedback-warning-bg": "#774b00", + "--nb-feedback-warning-text": "#ffffff", + "--nb-input-bg": "#f5fbff", + "--nb-input-border": "#5f6a77", + "--nb-input-border-focus": "#005b9a", + "--nb-input-text": "#111b28", + "--nb-shadow-lg": "#060e1847", + "--nb-shadow-md": "#060e1838", + "--nb-shadow-sm": "#060e182e", + "--nb-text-base": "#111b28", + "--nb-text-link": "#004d8a", + "--nb-text-muted": "#384352" + }, + "dark": { + "--nb-action-focus-ring": "#4ac9ec", + "--nb-action-on-primary": "#08121f", + "--nb-action-on-secondary": "#e4ecf5", + "--nb-action-primary-bg": "#4ac9ec", + "--nb-action-primary-bg-hover": "#25afd2", + "--nb-action-secondary-bg": "#182535", + "--nb-bg-base": "#060e18", + "--nb-bg-muted": "#182535", + "--nb-bg-surface": "#0c1723", + "--nb-border-base": "#7a8798", + "--nb-border-strong": "#aeb9c4", + "--nb-button-focus-ring": "#4ac9ec", + "--nb-button-on-primary": "#08121f", + "--nb-button-on-secondary": "#e4ecf5", + "--nb-button-primary-bg": "#4ac9ec", + "--nb-button-primary-bg-hover": "#25afd2", + "--nb-button-secondary-bg": "#182535", + "--nb-card-bg": "#0c1723", + "--nb-card-border": "#aeb9c4", + "--nb-card-shadow": "#060e1838", + "--nb-color-brand-100": "#d7ebfa", + "--nb-color-brand-200": "#93d5f5", + "--nb-color-brand-300": "#4ac9ec", + "--nb-color-brand-400": "#25afd2", + "--nb-color-brand-500": "#0079ba", + "--nb-color-brand-600": "#005b9a", + "--nb-color-brand-700": "#004d8a", + "--nb-color-brand-800": "#00477f", + "--nb-color-brand-900": "#002c4c", + "--nb-color-danger-100": "#ffdedb", + "--nb-color-danger-600": "#a20519", + "--nb-color-danger-700": "#83000d", + "--nb-color-focus-ring": "#005b9a", + "--nb-color-focus-ring-strong": "#4ac9ec", + "--nb-color-neutral-0": "#ffffff", + "--nb-color-neutral-100": "#e4ecf5", + "--nb-color-neutral-200": "#ced9e5", + "--nb-color-neutral-300": "#aeb9c4", + "--nb-color-neutral-400": "#7a8798", + "--nb-color-neutral-50": "#f5fbff", + "--nb-color-neutral-500": "#5f6a77", + "--nb-color-neutral-600": "#424e5e", + "--nb-color-neutral-700": "#384352", + "--nb-color-neutral-800": "#1b2a3c", + "--nb-color-neutral-850": "#182535", + "--nb-color-neutral-900": "#111b28", + "--nb-color-neutral-920": "#0c1723", + "--nb-color-neutral-940": "#08121f", + "--nb-color-neutral-950": "#060e18", + "--nb-color-success-100": "#d4f0dc", + "--nb-color-success-600": "#006933", + "--nb-color-success-700": "#005423", + "--nb-color-warning-100": "#fae9ce", + "--nb-color-warning-600": "#774b00", + "--nb-color-warning-700": "#603900", + "--nb-feedback-danger-bg": "#a20519", + "--nb-feedback-danger-text": "#ffffff", + "--nb-feedback-success-bg": "#006933", + "--nb-feedback-success-text": "#ffffff", + "--nb-feedback-warning-bg": "#774b00", + "--nb-feedback-warning-text": "#ffffff", + "--nb-input-bg": "#060e18", + "--nb-input-border": "#7a8798", + "--nb-input-border-focus": "#4ac9ec", + "--nb-input-text": "#e4ecf5", + "--nb-shadow-lg": "#060e1847", + "--nb-shadow-md": "#060e1838", + "--nb-shadow-sm": "#060e182e", + "--nb-text-base": "#e4ecf5", + "--nb-text-link": "#4ac9ec", + "--nb-text-muted": "#aeb9c4" + } +} diff --git a/portal/frontend/tokens/generated/variables.css b/portal/frontend/tokens/generated/variables.css new file mode 100644 index 0000000..15ddcf2 --- /dev/null +++ b/portal/frontend/tokens/generated/variables.css @@ -0,0 +1,424 @@ +/* Generado por scripts/build-tokens.mjs. No editar a mano. Contrato DTCG OKLCH 1.0. */ +:root { + color-scheme: light; + --nb-action-focus-ring: oklch(0.45 0.14 240); + --nb-action-on-primary: oklch(1 0 0); + --nb-action-on-secondary: oklch(0.28 0.04 255); + --nb-action-primary-bg: oklch(0.45 0.14 240); + --nb-action-primary-bg-hover: oklch(0.38 0.13 240); + --nb-action-secondary-bg: oklch(0.94 0.015 250); + --nb-bg-base: oklch(0.985 0.01 250); + --nb-bg-muted: oklch(0.94 0.015 250); + --nb-bg-surface: oklch(1 0 0); + --nb-border-base: oklch(0.52 0.025 252); + --nb-border-strong: oklch(0.42 0.03 255); + --nb-breakpoint-lg: 64rem; + --nb-breakpoint-md: 48rem; + --nb-breakpoint-sm: 40rem; + --nb-breakpoint-xl: 80rem; + --nb-button-focus-ring: oklch(0.45 0.14 240); + --nb-button-on-primary: oklch(1 0 0); + --nb-button-on-secondary: oklch(0.28 0.04 255); + --nb-button-padding-x: 1rem; + --nb-button-padding-y: 0.5rem; + --nb-button-primary-bg: oklch(0.45 0.14 240); + --nb-button-primary-bg-hover: oklch(0.38 0.13 240); + --nb-button-radius: 0.5rem; + --nb-button-secondary-bg: oklch(0.94 0.015 250); + --nb-card-bg: oklch(1 0 0); + --nb-card-border: oklch(0.42 0.03 255); + --nb-card-padding: 1.5rem; + --nb-card-radius: 0.625rem; + --nb-card-shadow: 0 4px 12px oklch(0.16 0.025 255 / 0.22); + --nb-color-brand-100: oklch(0.93 0.03 240); + --nb-color-brand-200: oklch(0.84 0.08 230); + --nb-color-brand-300: oklch(0.78 0.12 220); + --nb-color-brand-400: oklch(0.70 0.12 220); + --nb-color-brand-500: oklch(0.55 0.14 240); + --nb-color-brand-600: oklch(0.45 0.14 240); + --nb-color-brand-700: oklch(0.40 0.14 240); + --nb-color-brand-800: oklch(0.38 0.13 240); + --nb-color-brand-900: oklch(0.28 0.08 240); + --nb-color-danger-100: oklch(0.93 0.04 25); + --nb-color-danger-600: oklch(0.45 0.18 25); + --nb-color-danger-700: oklch(0.38 0.16 25); + --nb-color-focus-ring: oklch(0.45 0.14 240); + --nb-color-focus-ring-strong: oklch(0.78 0.12 220); + --nb-color-neutral-0: oklch(1 0 0); + --nb-color-neutral-100: oklch(0.94 0.015 250); + --nb-color-neutral-200: oklch(0.88 0.02 250); + --nb-color-neutral-300: oklch(0.78 0.02 250); + --nb-color-neutral-400: oklch(0.62 0.03 255); + --nb-color-neutral-50: oklch(0.985 0.01 250); + --nb-color-neutral-500: oklch(0.52 0.025 252); + --nb-color-neutral-600: oklch(0.42 0.03 255); + --nb-color-neutral-700: oklch(0.38 0.03 255); + --nb-color-neutral-800: oklch(0.28 0.04 255); + --nb-color-neutral-850: oklch(0.26 0.035 255); + --nb-color-neutral-900: oklch(0.22 0.03 255); + --nb-color-neutral-920: oklch(0.20 0.03 255); + --nb-color-neutral-940: oklch(0.18 0.03 255); + --nb-color-neutral-950: oklch(0.16 0.025 255); + --nb-color-success-100: oklch(0.93 0.04 155); + --nb-color-success-600: oklch(0.45 0.13 155); + --nb-color-success-700: oklch(0.38 0.12 155); + --nb-color-warning-100: oklch(0.94 0.04 80); + --nb-color-warning-600: oklch(0.45 0.12 80); + --nb-color-warning-700: oklch(0.38 0.11 80); + --nb-feedback-danger-bg: oklch(0.45 0.18 25); + --nb-feedback-danger-text: oklch(1 0 0); + --nb-feedback-success-bg: oklch(0.45 0.13 155); + --nb-feedback-success-text: oklch(1 0 0); + --nb-feedback-warning-bg: oklch(0.45 0.12 80); + --nb-feedback-warning-text: oklch(1 0 0); + --nb-input-bg: oklch(0.985 0.01 250); + --nb-input-border: oklch(0.52 0.025 252); + --nb-input-border-focus: oklch(0.45 0.14 240); + --nb-input-padding-x: 0.75rem; + --nb-input-padding-y: 0.5rem; + --nb-input-radius: 0.5rem; + --nb-input-text: oklch(0.22 0.03 255); + --nb-motion-duration-base: 200ms; + --nb-motion-duration-fast: 120ms; + --nb-motion-duration-slow: 320ms; + --nb-motion-easing-out: cubic-bezier(0.16, 1, 0.3, 1); + --nb-radius-full: 62.5rem; + --nb-radius-lg: 0.625rem; + --nb-radius-md: 0.5rem; + --nb-radius-none: 0; + --nb-radius-sm: 0.375rem; + --nb-radius-xl: 0.875rem; + --nb-shadow-lg: 0 12px 32px oklch(0.16 0.025 255 / 0.28); + --nb-shadow-md: 0 4px 12px oklch(0.16 0.025 255 / 0.22); + --nb-shadow-none: none; + --nb-shadow-sm: 0 1px 2px oklch(0.16 0.025 255 / 0.18); + --nb-space-0: 0; + --nb-space-1: 0.25rem; + --nb-space-10: 2.5rem; + --nb-space-12: 3rem; + --nb-space-16: 4rem; + --nb-space-2: 0.5rem; + --nb-space-3: 0.75rem; + --nb-space-4: 1rem; + --nb-space-5: 1.25rem; + --nb-space-6: 1.5rem; + --nb-space-8: 2rem; + --nb-text-base: oklch(0.22 0.03 255); + --nb-text-link: oklch(0.40 0.14 240); + --nb-text-muted: oklch(0.38 0.03 255); + --nb-typography-family-mono: ui-monospace, "Fira Code", Consolas, monospace; + --nb-typography-family-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --nb-typography-leading-base: 1.5; + --nb-typography-leading-relaxed: 1.65; + --nb-typography-leading-tight: 1.2; + --nb-typography-size-body: 1rem; + --nb-typography-size-caption: 0.875rem; + --nb-typography-size-h1: 2.25rem; + --nb-typography-size-h2: 1.5rem; + --nb-typography-size-h3: 1.25rem; + --nb-typography-size-lead: 1.125rem; + --nb-typography-tracking-base: 0; + --nb-typography-tracking-tight: -0.02em; + --nb-typography-tracking-wide: 0.08em; + --nb-typography-weight-bold: 700; + --nb-typography-weight-medium: 500; + --nb-typography-weight-regular: 400; + --nb-typography-weight-semibold: 600; + --nb-z-0: 0; + --nb-z-10: 10; + --nb-z-20: 20; + --nb-z-30: 30; + --nb-z-40: 40; + --nb-z-50: 50; +} + +.dark { + color-scheme: dark; + --nb-action-focus-ring: oklch(0.78 0.12 220); + --nb-action-on-primary: oklch(0.18 0.03 255); + --nb-action-on-secondary: oklch(0.94 0.015 250); + --nb-action-primary-bg: oklch(0.78 0.12 220); + --nb-action-primary-bg-hover: oklch(0.70 0.12 220); + --nb-action-secondary-bg: oklch(0.26 0.035 255); + --nb-bg-base: oklch(0.16 0.025 255); + --nb-bg-muted: oklch(0.26 0.035 255); + --nb-bg-surface: oklch(0.20 0.03 255); + --nb-border-base: oklch(0.62 0.03 255); + --nb-border-strong: oklch(0.78 0.02 250); + --nb-breakpoint-lg: 64rem; + --nb-breakpoint-md: 48rem; + --nb-breakpoint-sm: 40rem; + --nb-breakpoint-xl: 80rem; + --nb-button-focus-ring: oklch(0.78 0.12 220); + --nb-button-on-primary: oklch(0.18 0.03 255); + --nb-button-on-secondary: oklch(0.94 0.015 250); + --nb-button-padding-x: 1rem; + --nb-button-padding-y: 0.5rem; + --nb-button-primary-bg: oklch(0.78 0.12 220); + --nb-button-primary-bg-hover: oklch(0.70 0.12 220); + --nb-button-radius: 0.5rem; + --nb-button-secondary-bg: oklch(0.26 0.035 255); + --nb-card-bg: oklch(0.20 0.03 255); + --nb-card-border: oklch(0.78 0.02 250); + --nb-card-padding: 1.5rem; + --nb-card-radius: 0.625rem; + --nb-card-shadow: 0 4px 12px oklch(0.16 0.025 255 / 0.22); + --nb-color-brand-100: oklch(0.93 0.03 240); + --nb-color-brand-200: oklch(0.84 0.08 230); + --nb-color-brand-300: oklch(0.78 0.12 220); + --nb-color-brand-400: oklch(0.70 0.12 220); + --nb-color-brand-500: oklch(0.55 0.14 240); + --nb-color-brand-600: oklch(0.45 0.14 240); + --nb-color-brand-700: oklch(0.40 0.14 240); + --nb-color-brand-800: oklch(0.38 0.13 240); + --nb-color-brand-900: oklch(0.28 0.08 240); + --nb-color-danger-100: oklch(0.93 0.04 25); + --nb-color-danger-600: oklch(0.45 0.18 25); + --nb-color-danger-700: oklch(0.38 0.16 25); + --nb-color-focus-ring: oklch(0.45 0.14 240); + --nb-color-focus-ring-strong: oklch(0.78 0.12 220); + --nb-color-neutral-0: oklch(1 0 0); + --nb-color-neutral-100: oklch(0.94 0.015 250); + --nb-color-neutral-200: oklch(0.88 0.02 250); + --nb-color-neutral-300: oklch(0.78 0.02 250); + --nb-color-neutral-400: oklch(0.62 0.03 255); + --nb-color-neutral-50: oklch(0.985 0.01 250); + --nb-color-neutral-500: oklch(0.52 0.025 252); + --nb-color-neutral-600: oklch(0.42 0.03 255); + --nb-color-neutral-700: oklch(0.38 0.03 255); + --nb-color-neutral-800: oklch(0.28 0.04 255); + --nb-color-neutral-850: oklch(0.26 0.035 255); + --nb-color-neutral-900: oklch(0.22 0.03 255); + --nb-color-neutral-920: oklch(0.20 0.03 255); + --nb-color-neutral-940: oklch(0.18 0.03 255); + --nb-color-neutral-950: oklch(0.16 0.025 255); + --nb-color-success-100: oklch(0.93 0.04 155); + --nb-color-success-600: oklch(0.45 0.13 155); + --nb-color-success-700: oklch(0.38 0.12 155); + --nb-color-warning-100: oklch(0.94 0.04 80); + --nb-color-warning-600: oklch(0.45 0.12 80); + --nb-color-warning-700: oklch(0.38 0.11 80); + --nb-feedback-danger-bg: oklch(0.45 0.18 25); + --nb-feedback-danger-text: oklch(1 0 0); + --nb-feedback-success-bg: oklch(0.45 0.13 155); + --nb-feedback-success-text: oklch(1 0 0); + --nb-feedback-warning-bg: oklch(0.45 0.12 80); + --nb-feedback-warning-text: oklch(1 0 0); + --nb-input-bg: oklch(0.16 0.025 255); + --nb-input-border: oklch(0.62 0.03 255); + --nb-input-border-focus: oklch(0.78 0.12 220); + --nb-input-padding-x: 0.75rem; + --nb-input-padding-y: 0.5rem; + --nb-input-radius: 0.5rem; + --nb-input-text: oklch(0.94 0.015 250); + --nb-motion-duration-base: 200ms; + --nb-motion-duration-fast: 120ms; + --nb-motion-duration-slow: 320ms; + --nb-motion-easing-out: cubic-bezier(0.16, 1, 0.3, 1); + --nb-radius-full: 62.5rem; + --nb-radius-lg: 0.625rem; + --nb-radius-md: 0.5rem; + --nb-radius-none: 0; + --nb-radius-sm: 0.375rem; + --nb-radius-xl: 0.875rem; + --nb-shadow-lg: 0 12px 32px oklch(0.16 0.025 255 / 0.28); + --nb-shadow-md: 0 4px 12px oklch(0.16 0.025 255 / 0.22); + --nb-shadow-none: none; + --nb-shadow-sm: 0 1px 2px oklch(0.16 0.025 255 / 0.18); + --nb-space-0: 0; + --nb-space-1: 0.25rem; + --nb-space-10: 2.5rem; + --nb-space-12: 3rem; + --nb-space-16: 4rem; + --nb-space-2: 0.5rem; + --nb-space-3: 0.75rem; + --nb-space-4: 1rem; + --nb-space-5: 1.25rem; + --nb-space-6: 1.5rem; + --nb-space-8: 2rem; + --nb-text-base: oklch(0.94 0.015 250); + --nb-text-link: oklch(0.78 0.12 220); + --nb-text-muted: oklch(0.78 0.02 250); + --nb-typography-family-mono: ui-monospace, "Fira Code", Consolas, monospace; + --nb-typography-family-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --nb-typography-leading-base: 1.5; + --nb-typography-leading-relaxed: 1.65; + --nb-typography-leading-tight: 1.2; + --nb-typography-size-body: 1rem; + --nb-typography-size-caption: 0.875rem; + --nb-typography-size-h1: 2.25rem; + --nb-typography-size-h2: 1.5rem; + --nb-typography-size-h3: 1.25rem; + --nb-typography-size-lead: 1.125rem; + --nb-typography-tracking-base: 0; + --nb-typography-tracking-tight: -0.02em; + --nb-typography-tracking-wide: 0.08em; + --nb-typography-weight-bold: 700; + --nb-typography-weight-medium: 500; + --nb-typography-weight-regular: 400; + --nb-typography-weight-semibold: 600; + --nb-z-0: 0; + --nb-z-10: 10; + --nb-z-20: 20; + --nb-z-30: 30; + --nb-z-40: 40; + --nb-z-50: 50; +} + +@supports not (color: oklch(0% 0 0)) { + :root { + --nb-action-focus-ring: #005b9a; + --nb-action-on-primary: #ffffff; + --nb-action-on-secondary: #1b2a3c; + --nb-action-primary-bg: #005b9a; + --nb-action-primary-bg-hover: #00477f; + --nb-action-secondary-bg: #e4ecf5; + --nb-bg-base: #f5fbff; + --nb-bg-muted: #e4ecf5; + --nb-bg-surface: #ffffff; + --nb-border-base: #5f6a77; + --nb-border-strong: #424e5e; + --nb-button-focus-ring: #005b9a; + --nb-button-on-primary: #ffffff; + --nb-button-on-secondary: #1b2a3c; + --nb-button-primary-bg: #005b9a; + --nb-button-primary-bg-hover: #00477f; + --nb-button-secondary-bg: #e4ecf5; + --nb-card-bg: #ffffff; + --nb-card-border: #424e5e; + --nb-card-shadow: 0 4px 12px #060e1838; + --nb-color-brand-100: #d7ebfa; + --nb-color-brand-200: #93d5f5; + --nb-color-brand-300: #4ac9ec; + --nb-color-brand-400: #25afd2; + --nb-color-brand-500: #0079ba; + --nb-color-brand-600: #005b9a; + --nb-color-brand-700: #004d8a; + --nb-color-brand-800: #00477f; + --nb-color-brand-900: #002c4c; + --nb-color-danger-100: #ffdedb; + --nb-color-danger-600: #a20519; + --nb-color-danger-700: #83000d; + --nb-color-focus-ring: #005b9a; + --nb-color-focus-ring-strong: #4ac9ec; + --nb-color-neutral-0: #ffffff; + --nb-color-neutral-100: #e4ecf5; + --nb-color-neutral-200: #ced9e5; + --nb-color-neutral-300: #aeb9c4; + --nb-color-neutral-400: #7a8798; + --nb-color-neutral-50: #f5fbff; + --nb-color-neutral-500: #5f6a77; + --nb-color-neutral-600: #424e5e; + --nb-color-neutral-700: #384352; + --nb-color-neutral-800: #1b2a3c; + --nb-color-neutral-850: #182535; + --nb-color-neutral-900: #111b28; + --nb-color-neutral-920: #0c1723; + --nb-color-neutral-940: #08121f; + --nb-color-neutral-950: #060e18; + --nb-color-success-100: #d4f0dc; + --nb-color-success-600: #006933; + --nb-color-success-700: #005423; + --nb-color-warning-100: #fae9ce; + --nb-color-warning-600: #774b00; + --nb-color-warning-700: #603900; + --nb-feedback-danger-bg: #a20519; + --nb-feedback-danger-text: #ffffff; + --nb-feedback-success-bg: #006933; + --nb-feedback-success-text: #ffffff; + --nb-feedback-warning-bg: #774b00; + --nb-feedback-warning-text: #ffffff; + --nb-input-bg: #f5fbff; + --nb-input-border: #5f6a77; + --nb-input-border-focus: #005b9a; + --nb-input-text: #111b28; + --nb-shadow-lg: 0 12px 32px #060e1847; + --nb-shadow-md: 0 4px 12px #060e1838; + --nb-shadow-sm: 0 1px 2px #060e182e; + --nb-text-base: #111b28; + --nb-text-link: #004d8a; + --nb-text-muted: #384352; + } + + .dark { + --nb-action-focus-ring: #4ac9ec; + --nb-action-on-primary: #08121f; + --nb-action-on-secondary: #e4ecf5; + --nb-action-primary-bg: #4ac9ec; + --nb-action-primary-bg-hover: #25afd2; + --nb-action-secondary-bg: #182535; + --nb-bg-base: #060e18; + --nb-bg-muted: #182535; + --nb-bg-surface: #0c1723; + --nb-border-base: #7a8798; + --nb-border-strong: #aeb9c4; + --nb-button-focus-ring: #4ac9ec; + --nb-button-on-primary: #08121f; + --nb-button-on-secondary: #e4ecf5; + --nb-button-primary-bg: #4ac9ec; + --nb-button-primary-bg-hover: #25afd2; + --nb-button-secondary-bg: #182535; + --nb-card-bg: #0c1723; + --nb-card-border: #aeb9c4; + --nb-card-shadow: 0 4px 12px #060e1838; + --nb-color-brand-100: #d7ebfa; + --nb-color-brand-200: #93d5f5; + --nb-color-brand-300: #4ac9ec; + --nb-color-brand-400: #25afd2; + --nb-color-brand-500: #0079ba; + --nb-color-brand-600: #005b9a; + --nb-color-brand-700: #004d8a; + --nb-color-brand-800: #00477f; + --nb-color-brand-900: #002c4c; + --nb-color-danger-100: #ffdedb; + --nb-color-danger-600: #a20519; + --nb-color-danger-700: #83000d; + --nb-color-focus-ring: #005b9a; + --nb-color-focus-ring-strong: #4ac9ec; + --nb-color-neutral-0: #ffffff; + --nb-color-neutral-100: #e4ecf5; + --nb-color-neutral-200: #ced9e5; + --nb-color-neutral-300: #aeb9c4; + --nb-color-neutral-400: #7a8798; + --nb-color-neutral-50: #f5fbff; + --nb-color-neutral-500: #5f6a77; + --nb-color-neutral-600: #424e5e; + --nb-color-neutral-700: #384352; + --nb-color-neutral-800: #1b2a3c; + --nb-color-neutral-850: #182535; + --nb-color-neutral-900: #111b28; + --nb-color-neutral-920: #0c1723; + --nb-color-neutral-940: #08121f; + --nb-color-neutral-950: #060e18; + --nb-color-success-100: #d4f0dc; + --nb-color-success-600: #006933; + --nb-color-success-700: #005423; + --nb-color-warning-100: #fae9ce; + --nb-color-warning-600: #774b00; + --nb-color-warning-700: #603900; + --nb-feedback-danger-bg: #a20519; + --nb-feedback-danger-text: #ffffff; + --nb-feedback-success-bg: #006933; + --nb-feedback-success-text: #ffffff; + --nb-feedback-warning-bg: #774b00; + --nb-feedback-warning-text: #ffffff; + --nb-input-bg: #060e18; + --nb-input-border: #7a8798; + --nb-input-border-focus: #4ac9ec; + --nb-input-text: #e4ecf5; + --nb-shadow-lg: 0 12px 32px #060e1847; + --nb-shadow-md: 0 4px 12px #060e1838; + --nb-shadow-sm: 0 1px 2px #060e182e; + --nb-text-base: #e4ecf5; + --nb-text-link: #4ac9ec; + --nb-text-muted: #aeb9c4; + } +} + +@media (prefers-reduced-motion: reduce) { + :root { + --nb-motion-duration-fast: 0.01ms; + --nb-motion-duration-base: 0.01ms; + --nb-motion-duration-slow: 0.01ms; + } +} diff --git a/portal/frontend/tokens/primitive.color.tokens.json b/portal/frontend/tokens/primitive.color.tokens.json new file mode 100644 index 0000000..ac9436c --- /dev/null +++ b/portal/frontend/tokens/primitive.color.tokens.json @@ -0,0 +1,58 @@ +{ + "$meta": { + "ds": "neubat", + "tier": "primitive", + "colorSpace": "oklch", + "spec": "W3C DTCG Format Module", + "version": "1.0" + }, + "color": { + "brand": { + "100": { "$value": "oklch(0.93 0.03 240)", "$type": "color", "$description": "Tinte de marca" }, + "200": { "$value": "oklch(0.84 0.08 230)", "$type": "color" }, + "300": { "$value": "oklch(0.78 0.12 220)", "$type": "color", "$description": "Acción sobre fondo oscuro (primary histórico)" }, + "400": { "$value": "oklch(0.70 0.12 220)", "$type": "color", "$description": "Hover de acción en oscuro" }, + "500": { "$value": "oklch(0.55 0.14 240)", "$type": "color" }, + "600": { "$value": "oklch(0.45 0.14 240)", "$type": "color", "$description": "Acción sobre fondo claro (primary histórico)" }, + "700": { "$value": "oklch(0.40 0.14 240)", "$type": "color", "$description": "Enlace sobre claro" }, + "800": { "$value": "oklch(0.38 0.13 240)", "$type": "color", "$description": "Hover de acción en claro" }, + "900": { "$value": "oklch(0.28 0.08 240)", "$type": "color" } + }, + "neutral": { + "0": { "$value": "oklch(1 0 0)", "$type": "color", "$description": "Superficie blanca" }, + "50": { "$value": "oklch(0.985 0.01 250)", "$type": "color", "$description": "Fondo de página en claro" }, + "100": { "$value": "oklch(0.94 0.015 250)", "$type": "color", "$description": "Texto sobre oscuro y fondo atenuado en claro" }, + "200": { "$value": "oklch(0.88 0.02 250)", "$type": "color" }, + "300": { "$value": "oklch(0.78 0.02 250)", "$type": "color", "$description": "Texto secundario en oscuro" }, + "400": { "$value": "oklch(0.62 0.03 255)", "$type": "color", "$description": "Borde UI en oscuro (≥3:1)" }, + "500": { "$value": "oklch(0.52 0.025 252)", "$type": "color", "$description": "Borde UI en claro (≥3:1)" }, + "600": { "$value": "oklch(0.42 0.03 255)", "$type": "color" }, + "700": { "$value": "oklch(0.38 0.03 255)", "$type": "color", "$description": "Texto secundario en claro" }, + "800": { "$value": "oklch(0.28 0.04 255)", "$type": "color" }, + "850": { "$value": "oklch(0.26 0.035 255)", "$type": "color", "$description": "Superficie atenuada en oscuro" }, + "900": { "$value": "oklch(0.22 0.03 255)", "$type": "color", "$description": "Texto sobre claro" }, + "920": { "$value": "oklch(0.20 0.03 255)", "$type": "color", "$description": "Tarjeta en oscuro" }, + "940": { "$value": "oklch(0.18 0.03 255)", "$type": "color", "$description": "Tinta sobre acción clara en oscuro" }, + "950": { "$value": "oklch(0.16 0.025 255)", "$type": "color", "$description": "Fondo de página en oscuro" } + }, + "success": { + "100": { "$value": "oklch(0.93 0.04 155)", "$type": "color" }, + "600": { "$value": "oklch(0.45 0.13 155)", "$type": "color" }, + "700": { "$value": "oklch(0.38 0.12 155)", "$type": "color" } + }, + "warning": { + "100": { "$value": "oklch(0.94 0.04 80)", "$type": "color" }, + "600": { "$value": "oklch(0.45 0.12 80)", "$type": "color" }, + "700": { "$value": "oklch(0.38 0.11 80)", "$type": "color" } + }, + "danger": { + "100": { "$value": "oklch(0.93 0.04 25)", "$type": "color" }, + "600": { "$value": "oklch(0.45 0.18 25)", "$type": "color" }, + "700": { "$value": "oklch(0.38 0.16 25)", "$type": "color" } + }, + "focus": { + "ring": { "$value": "oklch(0.45 0.14 240)", "$type": "color", "$description": "Anillo de foco en claro" }, + "ring-strong": { "$value": "oklch(0.78 0.12 220)", "$type": "color", "$description": "Anillo de foco en oscuro" } + } + } +} diff --git a/portal/frontend/tokens/primitive.dimension.tokens.json b/portal/frontend/tokens/primitive.dimension.tokens.json new file mode 100644 index 0000000..6ba2ee5 --- /dev/null +++ b/portal/frontend/tokens/primitive.dimension.tokens.json @@ -0,0 +1,67 @@ +{ + "$meta": { + "ds": "neubat", + "tier": "primitive", + "version": "1.0" + }, + "space": { + "0": { "$value": "0", "$type": "dimension" }, + "1": { "$value": "0.25rem", "$type": "dimension" }, + "2": { "$value": "0.5rem", "$type": "dimension" }, + "3": { "$value": "0.75rem", "$type": "dimension" }, + "4": { "$value": "1rem", "$type": "dimension" }, + "5": { "$value": "1.25rem", "$type": "dimension" }, + "6": { "$value": "1.5rem", "$type": "dimension" }, + "8": { "$value": "2rem", "$type": "dimension" }, + "10": { "$value": "2.5rem", "$type": "dimension" }, + "12": { "$value": "3rem", "$type": "dimension" }, + "16": { "$value": "4rem", "$type": "dimension" } + }, + "radius": { + "none": { "$value": "0", "$type": "borderRadius" }, + "sm": { "$value": "0.375rem", "$type": "borderRadius" }, + "md": { "$value": "0.5rem", "$type": "borderRadius" }, + "lg": { "$value": "0.625rem", "$type": "borderRadius", "$description": "Radio por defecto de la SPA" }, + "xl": { "$value": "0.875rem", "$type": "borderRadius" }, + "full": { "$value": "62.5rem", "$type": "borderRadius" } + }, + "shadow": { + "none": { "$value": "none", "$type": "shadow" }, + "sm": { + "$value": "0 1px 2px oklch(0.16 0.025 255 / 0.18)", + "$type": "shadow" + }, + "md": { + "$value": "0 4px 12px oklch(0.16 0.025 255 / 0.22)", + "$type": "shadow" + }, + "lg": { + "$value": "0 12px 32px oklch(0.16 0.025 255 / 0.28)", + "$type": "shadow" + } + }, + "motion": { + "duration": { + "fast": { "$value": "120ms", "$type": "duration" }, + "base": { "$value": "200ms", "$type": "duration" }, + "slow": { "$value": "320ms", "$type": "duration" } + }, + "easing": { + "out": { "$value": "cubic-bezier(0.16, 1, 0.3, 1)", "$type": "cubicBezier" } + } + }, + "z": { + "0": { "$value": "0", "$type": "number" }, + "10": { "$value": "10", "$type": "number" }, + "20": { "$value": "20", "$type": "number" }, + "30": { "$value": "30", "$type": "number" }, + "40": { "$value": "40", "$type": "number" }, + "50": { "$value": "50", "$type": "number" } + }, + "breakpoint": { + "sm": { "$value": "40rem", "$type": "dimension" }, + "md": { "$value": "48rem", "$type": "dimension" }, + "lg": { "$value": "64rem", "$type": "dimension" }, + "xl": { "$value": "80rem", "$type": "dimension" } + } +} diff --git a/portal/frontend/tokens/semantic.color.tokens.json b/portal/frontend/tokens/semantic.color.tokens.json new file mode 100644 index 0000000..eb86b3e --- /dev/null +++ b/portal/frontend/tokens/semantic.color.tokens.json @@ -0,0 +1,122 @@ +{ + "$meta": { + "ds": "neubat", + "tier": "semantic", + "version": "1.0", + "$description": "Roles de color con modos light y dark. Las referencias usan sintaxis DTCG {grupo.token}." + }, + "bg": { + "base": { + "$value": "{color.neutral.50}", + "$type": "color", + "$description": "Fondo de página", + "$extensions": { "mode": { "light": "{color.neutral.50}", "dark": "{color.neutral.950}" } } + }, + "surface": { + "$value": "{color.neutral.0}", + "$type": "color", + "$description": "Tarjetas y popovers", + "$extensions": { "mode": { "light": "{color.neutral.0}", "dark": "{color.neutral.920}" } } + }, + "muted": { + "$value": "{color.neutral.100}", + "$type": "color", + "$description": "Fondo atenuado", + "$extensions": { "mode": { "light": "{color.neutral.100}", "dark": "{color.neutral.850}" } } + } + }, + "text": { + "base": { + "$value": "{color.neutral.900}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.neutral.900}", "dark": "{color.neutral.100}" } } + }, + "muted": { + "$value": "{color.neutral.700}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.neutral.700}", "dark": "{color.neutral.300}" } } + }, + "link": { + "$value": "{color.brand.700}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.brand.700}", "dark": "{color.brand.300}" } } + } + }, + "border": { + "base": { + "$value": "{color.neutral.500}", + "$type": "color", + "$description": "Borde de UI, mínimo 3:1 sobre el fondo", + "$extensions": { "mode": { "light": "{color.neutral.500}", "dark": "{color.neutral.400}" } } + }, + "strong": { + "$value": "{color.neutral.600}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.neutral.600}", "dark": "{color.neutral.300}" } } + } + }, + "action": { + "primary-bg": { + "$value": "{color.brand.600}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.brand.600}", "dark": "{color.brand.300}" } } + }, + "primary-bg-hover": { + "$value": "{color.brand.800}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.brand.800}", "dark": "{color.brand.400}" } } + }, + "on-primary": { + "$value": "{color.neutral.0}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.neutral.0}", "dark": "{color.neutral.940}" } } + }, + "secondary-bg": { + "$value": "{color.neutral.100}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.neutral.100}", "dark": "{color.neutral.850}" } } + }, + "on-secondary": { + "$value": "{color.neutral.800}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.neutral.800}", "dark": "{color.neutral.100}" } } + }, + "focus-ring": { + "$value": "{color.focus.ring}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.focus.ring}", "dark": "{color.focus.ring-strong}" } } + } + }, + "feedback": { + "success-bg": { + "$value": "{color.success.600}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.success.600}", "dark": "{color.success.600}" } } + }, + "success-text": { + "$value": "{color.neutral.0}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.neutral.0}", "dark": "{color.neutral.0}" } } + }, + "warning-bg": { + "$value": "{color.warning.600}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.warning.600}", "dark": "{color.warning.600}" } } + }, + "warning-text": { + "$value": "{color.neutral.0}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.neutral.0}", "dark": "{color.neutral.0}" } } + }, + "danger-bg": { + "$value": "{color.danger.600}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.danger.600}", "dark": "{color.danger.600}" } } + }, + "danger-text": { + "$value": "{color.neutral.0}", + "$type": "color", + "$extensions": { "mode": { "light": "{color.neutral.0}", "dark": "{color.neutral.0}" } } + } + } +} diff --git a/portal/frontend/tokens/semantic.typography.tokens.json b/portal/frontend/tokens/semantic.typography.tokens.json new file mode 100644 index 0000000..906a8b9 --- /dev/null +++ b/portal/frontend/tokens/semantic.typography.tokens.json @@ -0,0 +1,43 @@ +{ + "$meta": { + "ds": "neubat", + "tier": "semantic", + "version": "1.0" + }, + "typography": { + "family": { + "sans": { + "$value": "system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "$type": "fontFamily" + }, + "mono": { + "$value": "ui-monospace, \"Fira Code\", Consolas, monospace", + "$type": "fontFamily" + } + }, + "weight": { + "regular": { "$value": "400", "$type": "fontWeight" }, + "medium": { "$value": "500", "$type": "fontWeight" }, + "semibold": { "$value": "600", "$type": "fontWeight" }, + "bold": { "$value": "700", "$type": "fontWeight" } + }, + "size": { + "caption": { "$value": "0.875rem", "$type": "fontSize" }, + "body": { "$value": "1rem", "$type": "fontSize" }, + "lead": { "$value": "1.125rem", "$type": "fontSize" }, + "h3": { "$value": "1.25rem", "$type": "fontSize" }, + "h2": { "$value": "1.5rem", "$type": "fontSize" }, + "h1": { "$value": "2.25rem", "$type": "fontSize" } + }, + "leading": { + "tight": { "$value": "1.2", "$type": "lineHeight" }, + "base": { "$value": "1.5", "$type": "lineHeight" }, + "relaxed": { "$value": "1.65", "$type": "lineHeight" } + }, + "tracking": { + "tight": { "$value": "-0.02em", "$type": "letterSpacing" }, + "base": { "$value": "0", "$type": "letterSpacing" }, + "wide": { "$value": "0.08em", "$type": "letterSpacing" } + } + } +}