From d331e609a57112dd9fac67f8a339fe7cfb424713 Mon Sep 17 00:00:00 2001 From: tommaso Date: Fri, 10 Jul 2026 13:07:43 +0200 Subject: [PATCH] R6 L1+L3: motore di stile del form (token + CSS custom) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il form era gia' tokenizzato (~30 var CSS in :root): non riscriviamo nulla, iniettiamo un blocco di override DOPO il CSS di base, cosi' un errore di configurazione del merchant non puo' rompere il default. - theme.ts (modulo PURO, condiviso con la futura anteprima admin): ThemeTokens, FONT_PRESETS, themeStyle(). Sanificazione a monte: colori solo esadecimali, raggio e larghezza clampati, font da whitelist, CSS custom con rimozione di @@ -976,7 +982,7 @@ export function renderShell(inner: string): string { ${escapeHtml(PAGE_TITLE)} - +${override ? `\n` : ""} @@ -1048,7 +1054,7 @@ function stepIndicator(current: number): string { * (pattern dei modal moderni). I bottoni stanno nel footer e referenziano la form * via attributo `form=` (HTML5), così restano sempre visibili. */ -function stepLayout(parts: { head: string; body: string; foot?: string }): string { +function stepLayout(parts: { head: string; body: string; foot?: string }, theme?: ThemeTokens | null): string { return `
${parts.head}
@@ -1069,7 +1075,7 @@ export function renderStep1(opts?: { error?: string; orderName?: string; email?: string; -}): string { +}, theme?: ThemeTokens | null): string { const orderName = opts?.orderName ?? ""; const email = opts?.email ?? ""; return renderShell( @@ -1086,6 +1092,7 @@ ${errorBanner(opts?.error)} `, foot: ``, }), + theme, ); } @@ -1098,7 +1105,7 @@ export function renderStep2(data: { statementText: string; error?: string; notice?: string; -}): string { +}, theme?: ThemeTokens | null): string { const customerName = data.customerName ?? ""; return renderShell( stepLayout({ @@ -1122,6 +1129,7 @@ export function renderStep2(data: { `, foot: ``, }), + theme, ); } @@ -1133,7 +1141,7 @@ export function renderStep3(data: { customerName: string; statementText: string; error?: string; -}): string { +}, theme?: ThemeTokens | null): string { return renderShell( stepLayout({ head: stepHead(3, "Controlla i dati prima di confermare."), @@ -1169,6 +1177,7 @@ export function renderStep3(data: { `, }), + theme, ); } @@ -1177,7 +1186,7 @@ export function renderStep4(data: { line1: string; line2: string; line3: string; -}): string { +}, theme?: ThemeTokens | null): string { return renderShell( stepLayout({ head: `${stepIndicator(4)}`, @@ -1188,6 +1197,7 @@ export function renderStep4(data: {

${escapeHtml(data.line3)}

`, }), + theme, ); } diff --git a/app/app/lib/theme.ts b/app/app/lib/theme.ts new file mode 100644 index 0000000..85f27c9 --- /dev/null +++ b/app/app/lib/theme.ts @@ -0,0 +1,146 @@ +/** + * Motore di stile del form di recesso — Livello 1 (token) e Livello 3 (CSS custom). + * + * Il form e' gia' interamente tokenizzato (`:root` in recesso.server). Qui NON + * riscriviamo il CSS: generiamo un blocco di override che viene iniettato DOPO + * quello di base. Cosi' il default resta sempre valido anche se il merchant + * sbaglia una configurazione. + * + * Modulo PURO (niente node/server): lo usa sia il render dello storefront sia + * l'anteprima nell'admin. + * + * ⚠ Tutto cio' che arriva dal merchant e' sanificato qui, non a valle. + */ + +export interface ThemeTokens { + accent?: string | null; // link, focus + buttonBg?: string | null; // bottone primario + buttonText?: string | null; + radius?: number | null; // px + font?: string | null; // chiave di FONT_PRESETS + width?: number | null; // px, larghezza max della card + customCss?: string | null; // livello 3 +} + +export const FONT_PRESETS: Record = { + system: + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + sans: 'Helvetica, Arial, "Helvetica Neue", sans-serif', + serif: 'Georgia, "Times New Roman", Times, serif', + mono: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', +}; + +export const FONT_OPTIONS = [ + { label: "Di sistema (consigliato)", value: "system" }, + { label: "Sans serif", value: "sans" }, + { label: "Serif", value: "serif" }, + { label: "Monospazio", value: "mono" }, +]; + +export const RADIUS_MIN = 0; +export const RADIUS_MAX = 32; +export const WIDTH_MIN = 360; +export const WIDTH_MAX = 900; +export const CUSTOM_CSS_MAX = 4000; + +const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +/** Colore valido? Accettiamo solo esadecimali: niente url(), niente espressioni. */ +export function isHexColor(v: unknown): v is string { + return typeof v === "string" && HEX.test(v.trim()); +} + +function expand(hex: string): [number, number, number] { + let h = hex.trim().slice(1); + if (h.length === 3) h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!; + return [ + parseInt(h.slice(0, 2), 16), + parseInt(h.slice(2, 4), 16), + parseInt(h.slice(4, 6), 16), + ]; +} + +/** rgba() dal colore, per l'anello di focus. */ +export function hexToRgba(hex: string, alpha: number): string { + const [r, g, b] = expand(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** Scurisce (amount<0) o schiarisce (amount>0) verso nero/bianco. */ +export function shade(hex: string, amount: number): string { + const [r, g, b] = expand(hex); + const t = amount < 0 ? 0 : 255; + const p = Math.abs(amount); + const mix = (c: number) => Math.round((t - c) * p + c); + const to2 = (c: number) => mix(c).toString(16).padStart(2, "0"); + return `#${to2(r)}${to2(g)}${to2(b)}`; +} + +function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +/** + * CSS custom: non possiamo permettere che il merchant esca dal blocco