Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,16 @@ jobs:
<body class="dark">
<a href="#contenido" class="skip-link">Saltar al contenido</a>
<header><nav aria-label="Principal"><a href="/">Inicio</a></nav></header>
<main id="contenido"><h1>NEUBAT: tu Arch, tu ISO, tu red</h1>
<p>Configura desde el navegador una instalación desatendida.</p>
<a href="/configurar">Configurar instalación</a></main>
<main id="contenido">
<article>
<header><h1>NEUBAT</h1></header>
<section><h2>Propuesta</h2><p>NEUBAT define en el navegador una instalación desatendida de Arch Linux.</p></section>
<section><h2>Características</h2><p>Perfil JSON, btrfs y dos entradas de arranque.</p></section>
<section><h2>Funcionalidades</h2><p>Asistente, ISO y arranque por URL.</p></section>
<section><h2>Roadmap</h2><p>El instalador iPXE real sigue necesitando el portal.</p></section>
<section><h2>Cierre</h2><a href="/configurar">Configurar instalación</a></section>
</article>
</main>
<footer>NEUBAT</footer>
</body></html>`;
const dom = new JSDOM(html, { runScripts: 'dangerously', pretendToBeVisual: true });
Expand Down
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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; \
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/desarrollo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/runbooks/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions portal/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NEUBAT — Instalación desatendida de Arch Linux</title>
<script>
(function () {
try {
var stored = localStorage.getItem('neubat-theme');
var dark =
stored === 'dark' ||
(stored !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
5 changes: 5 additions & 0 deletions portal/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
131 changes: 131 additions & 0 deletions portal/frontend/scripts/build-tokens.mjs
Original file line number Diff line number Diff line change
@@ -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`);
71 changes: 71 additions & 0 deletions portal/frontend/scripts/check-contrast.mjs
Original file line number Diff line number Diff line change
@@ -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)");
Loading
Loading