@jun/soul/ui
UI
Shell HTML, theme completo y escapado automatico.
El modelo#
soul emite esqueleto semantico —clases .soul-btn, .soul-card,
.soul-table…— sin estilo propio, y todo el diseño sale del theme. Cada proyecto
controla el aspecto completo, incluido el panel de administracion, cambiando tokens.
Crear el shell#
import { createShell } from "@jun/soul/ui";
export const { shell, errorPage } = createShell({
siteName: "Mi SaaS",
theme: {
primary: "#2f6f4f",
bg: "#f7faf8",
fontDisplay: "'Fraunces', serif",
},
});
shell({ title, body, nav? })- Documento HTML completo.
bodyynavse pasan porraw(). errorPage(titulo, mensaje)- Pagina de error ya estilizada.
El theme#
SoulTheme tiene unos 55 tokens y se pasa parcial: lo que no toques queda con el valor por
defecto (marfil y dorado, Fraunces).
| Grupo | Tokens |
|---|---|
| Superficies | bg, surface, surfaceAlt, border, borderStrong |
| Texto | text, textMuted, textFaint, textInverse |
| Acentos | primary, primaryHover, onPrimary, danger*, success*, warning*, link, linkHover |
| Barra lateral | sidebarBg, sidebarText, sidebarActiveBg, sidebarBrand… |
| Tipografia | fontBody, fontDisplay, fontMono, fontSizeXs…3xl, weight*, headingWeight |
| Forma y espacio | radius, radiusSm/Lg/Pill, controlHeight, gap |
| Layout | sidebarWidth, contentMaxWidth, contentPadX/Y |
| Efectos | shadowSm, shadow, cardBorder, cardShadow |
Control total con CSS propio#
El css que pases se inyecta despues de la hoja de soul, asi que sobreescribe
cualquier .soul-*. Es la valvula de escape para el detalle fino.
export const { shell } = createShell({
siteName: "Mi SaaS",
theme: { primary: "#b4452f" },
css: `
.soul-btn { text-transform: uppercase; letter-spacing: .06em; }
.soul-card { border-radius: 2px; }
`,
});
Piezas de layout#
sidebar({ siteName, sections, userInitial, userName, userEmail, logoutHref? })- Barra lateral con secciones. Un item con
href: nullqueda inerte (util para lo que aun no existe). shellLayout({ sidebar, content })- Combina barra lateral y contenido.
ui.{input,select,label,btnPrimary,btnSecondary,btnDanger,card,link}- Nombres de clase
.soul-*, no funciones. icons.{grid,shield,users,plus,upload,trash,close,qr,settings,billing,help…}- Iconos SVG en linea.
logoMark(), DEFAULT_FONT_LINKS, AJAX_PANELS_SCRIPT- Extras.
Cabeceras de seguridad#
import { securityHeaders } from "@jun/soul/ui";
app.use("*", securityHeaders({
tailwindCdn: true, // solo si cargas Tailwind por CDN
turnstile: true, // si usas el captcha
extraImgSrc: ["https://cdn.midominio.com"],
}));
Pone CSP, X-Frame-Options, X-Content-Type-Options y Referrer-Policy. Si solo
quieres la cadena de CSP, defaultCsp(config) la arma sin el middleware.
Escapado automatico#
Todo el modulo esta escrito con html\`\`: cualquier \${valor} interpolado se escapa
solo, como en JSX. Para meter markup de confianza se usa raw().
import { html, raw } from "@jun/soul/ui";
// Seguro: el nombre se escapa aunque traiga <script>
const saludo = html`<p>Hola \${usuario.nombre}</p>`;
// raw() SOLO para markup propio, jamas para datos del usuario
const pagina = html`<div>\${raw(saludo)}</div>`;
Identidad completa de un proyecto#
import { createShell } from "@jun/soul/ui";
export const { shell, errorPage } = createShell({
siteName: "NutriLabel",
fontLinks: `<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">`,
theme: {
bg: "#faf8f3",
surface: "#ffffff",
surfaceAlt: "#f1f5ee",
border: "#e2e8dc",
text: "#1f2a22",
textMuted: "#5c6b5f",
primary: "#2f6f4f",
primaryHover: "#25583f",
onPrimary: "#ffffff",
link: "#2f6f4f",
sidebarBg: "#1f2a22",
sidebarText: "#e8efe9",
sidebarActiveBg: "#2f6f4f",
fontDisplay: "'Fraunces', serif",
fontBody: "'Inter', sans-serif",
radius: "8px",
},
});
Portal con barra lateral#
import { sidebar, shellLayout, icons } from "@jun/soul/ui";
export function dashboardSidebar(opts: {
siteName: string;
active: "inicio" | "recetas" | "admin-users";
user: { name: string; email: string; isAdmin: boolean };
}) {
return sidebar({
siteName: opts.siteName,
userInitial: opts.user.name.charAt(0).toUpperCase(),
userName: opts.user.name,
userEmail: opts.user.email,
logoutHref: "/auth/logout",
sections: [
{
items: [
{ icon: icons.grid, label: "Inicio", href: "/app", active: opts.active === "inicio" },
{ icon: icons.plus, label: "Recetas", href: "/recetas", active: opts.active === "recetas" },
],
},
// La seccion de admin solo existe para el superadmin.
...(opts.user.isAdmin
? [{
heading: "Admin",
items: [
{ icon: icons.users, label: "Usuarios", href: "/admin/users", active: opts.active === "admin-users" },
],
}]
: []),
{
items: [
{ icon: icons.settings, label: "Ajustes", href: null }, // inerte: aun no existe
{ icon: icons.help, label: "Ayuda", href: "/ayuda" },
],
},
],
});
}
export function pagina(opts: { title: string; content: string; sidebar: string }) {
return shell({
title: opts.title,
body: shellLayout({ sidebar: opts.sidebar, content: opts.content }),
});
}
Formulario con las clases de soul#
import { ui } from "@jun/soul/ui";
import { escapeHtml } from "@jun/soul/lib";
function formularioReceta(receta?: { id: number; title: string }) {
return `<form method="post" action="${receta ? `/recetas/${receta.id}` : "/recetas"}" class="${ui.card}">
<div class="soul-field">
<label class="${ui.label}">Nombre</label>
<input type="text" name="title" class="${ui.input}" required
value="${escapeHtml(receta?.title ?? "")}"/>
</div>
<div class="soul-form-actions">
<a href="/recetas" class="${ui.btnSecondary}">Cancelar</a>
<button type="submit" class="${ui.btnPrimary}">Guardar</button>
</div>
</form>`;
}
Paneles AJAX sin framework#
Un formulario con data-ajax-target se envia por fetch y su respuesta reemplaza el
outerHTML del objetivo. El servidor decide si devuelve el fragmento o redirige.
import { AJAX_PANELS_SCRIPT } from "@jun/soul/ui";
// isAjax NO viene en soul: son dos lineas en tu proyecto.
const isAjax = (c: Context) => c.req.header("X-Requested-With") === "fetch";
recetas.post("/:id/favorita", async (c) => {
await marcarFavorita(c);
const panel = await panelDeReceta(c); // conserva el mismo id para poder re-reemplazarse
return isAjax(c) ? c.html(panel) : c.redirect("/recetas");
});
// El script se inyecta una vez por pagina.
return c.html(shell({ title: "Recetas", body: `${lista}${AJAX_PANELS_SCRIPT}` }));
<!-- El fragmento devuelto debe conservar el id del objetivo -->
<form method="post" action="/recetas/7/favorita"
data-ajax-target="#receta-7"
data-confirm="¿Marcar como favorita?">
<button type="submit">★</button>
</form>
Componer sin romper el escapado#
import { html } from "@jun/soul/ui";
// Helper INTERMEDIO: devuelve el objeto de html\`\`, sin String()
function campo(label: string, name: string) {
return html`<div class="soul-field">
<label class="soul-label">\${label}</label>
<input class="soul-input" name="\${name}"/>
</div>`;
}
// Funcion TERMINAL: aqui si se coerciona, porque el resultado sale al mundo
export function formulario() {
return String(html`<form method="post">
\${campo("Nombre", "name")}
\${campo("Email", "email")}
</form>`);
}
CSP con dominios extra#
app.use("*", securityHeaders({
tailwindCdn: true,
turnstile: true,
extraImgSrc: ["https://imagedelivery.net"],
extraConnectSrc: ["https://api.midominio.com"],
extraScriptSrc: ["'unsafe-eval'"], // solo si alguna libreria lo exige de verdad
}));