diff --git a/app/app/lib/recesso.server.ts b/app/app/lib/recesso.server.ts index 7e85307..7f23108 100644 --- a/app/app/lib/recesso.server.ts +++ b/app/app/lib/recesso.server.ts @@ -16,6 +16,7 @@ import { createHash } from "node:crypto"; import type { AdminApiContext } from "@shopify/shopify-app-remix/server"; import type { ExclusionRule } from "@prisma/client"; +import { themeStyle, type ThemeTokens } from "./theme"; import { FIELD, INFO_TITLE, @@ -690,19 +691,21 @@ const PAGE_CSS = ` --shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(18, 24, 40, 0.08); --radius: 14px; --radius-sm: 9px; + --font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif; + --card-max: 520px; } * { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; } body { margin: 0; - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif; + font-family: var(--font); line-height: 1.55; color: var(--text); background: var(--bg); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } - .wrap { max-width: 520px; margin: 0 auto; padding: 32px 16px 72px; } + .wrap { max-width: var(--card-max); margin: 0 auto; padding: 32px 16px 72px; } .card { background: var(--surface); border: 1px solid var(--border); @@ -967,7 +970,10 @@ const PAGE_CSS = ` `; /** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */ -export function renderShell(inner: string): string { +export function renderShell(inner: string, theme?: ThemeTokens | null): string { + // Gli override del merchant vengono DOPO il CSS di base: se sbaglia una + // configurazione, il default resta comunque valido. + const override = themeStyle(theme); return ` @@ -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