feat(theme): reduce Aspetto to a color-scheme preset, drop the token editor

The per-token editor (accent, button background, button text, font,
radius, max width, custom CSS) let a merchant produce an unreadable or
inaccessible form: the accent token only drives the keyboard focus ring,
so a light accent hid it; button background/text had no contrast guard;
custom CSS was arbitrary CSS injected into the page. Per the merchant's
call, the appearance is now a fixed, accessible design with a single
choice: light (default), dark, or auto.

- Settings "Aspetto" tab: keep only the Schema colore select; remove the
  color fields, ColorField, the hex/int/font validators and their state.
- proxy loadTheme: read only themeScheme; stop applying the other tokens.
- Preview iframe made non-interactive (sandbox="allow-scripts", inert,
  tabIndex -1, pointer-events:none) with scrolling moved to the wrapper,
  since it demonstrates a fixed design and must not look clickable.

The token columns stay in the schema, unread, so the customization
surface can be re-exposed later without a migration. theme.ts (the
engine) is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
2026-07-10 18:04:56 +02:00
parent 168b11c73b
commit 86a901d598
2 changed files with 46 additions and 201 deletions

View File

@@ -32,14 +32,7 @@ import { renderStep2 } from "../lib/recesso.view";
import { statementTemplate } from "../lib/recesso.copy";
import {
DEFAULT_SCHEME,
FONT_OPTIONS,
FONT_PRESETS,
RADIUS_MAX,
RADIUS_MIN,
SCHEME_OPTIONS,
WIDTH_MAX,
WIDTH_MIN,
isHexColor,
schemeOrNull,
type ThemeTokens,
} from "../lib/theme";
@@ -85,14 +78,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
// Esiste un SMTP di default a livello app (env/fly secret)? In prod oggi NO:
// senza SMTP per-shop non parte nessuna ricevuta.
appDefaultSmtp: !!process.env.SMTP_HOST,
themeAccent: s?.themeAccent ?? "",
themeButtonBg: s?.themeButtonBg ?? "",
themeButtonText: s?.themeButtonText ?? "",
themeRadius: s?.themeRadius != null ? String(s.themeRadius) : "",
themeFont: s?.themeFont ?? "system",
themeWidth: s?.themeWidth != null ? String(s.themeWidth) : "",
themeScheme: s?.themeScheme ?? DEFAULT_SCHEME,
themeCustomCss: s?.themeCustomCss ?? "",
};
};
@@ -168,16 +154,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
smtpUser: String(f.get("smtpUser") ?? "").trim() || null,
smtpSecure: f.get("smtpSecure") === "true",
smtpFrom: String(f.get("smtpFrom") ?? "").trim() || null,
// Tema: si salva solo cio' che e' valido. Un valore sbagliato non viene
// scritto, cosi' il form ricade sul default invece di rompersi.
themeAccent: hexOrNull(f.get("themeAccent")),
themeButtonBg: hexOrNull(f.get("themeButtonBg")),
themeButtonText: hexOrNull(f.get("themeButtonText")),
themeRadius: intInRange(f.get("themeRadius"), RADIUS_MIN, RADIUS_MAX),
themeWidth: intInRange(f.get("themeWidth"), WIDTH_MIN, WIDTH_MAX),
themeFont: fontOrNull(f.get("themeFont")),
// Aspetto: solo lo schema colore. Le colonne dei token restano nel DB,
// inerti, per poter riesporre la personalizzazione senza migrazioni.
themeScheme: schemeOrNull(f.get("themeScheme")),
themeCustomCss: String(f.get("themeCustomCss") ?? "").trim() || null,
};
// Password SMTP: cifrata solo se fornita; vuota = invariata.
@@ -194,64 +173,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return { ok: true, tested: false, error: null };
};
function hexOrNull(v: FormDataEntryValue | null): string | null {
const s = String(v ?? "").trim();
return isHexColor(s) ? s : null;
}
function intInRange(
v: FormDataEntryValue | null,
min: number,
max: number,
): number | null {
const n = Number(String(v ?? "").trim());
if (!Number.isFinite(n) || n === 0) return null;
return Math.min(max, Math.max(min, Math.round(n)));
}
function fontOrNull(v: FormDataEntryValue | null): string | null {
const s = String(v ?? "").trim();
return s && FONT_PRESETS[s] ? s : null;
}
function ColorField(props: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
helpText?: string;
}) {
const valid = isHexColor(props.value);
const invalid = !valid && props.value.trim().length > 0;
return (
<InlineStack gap="300" blockAlign="end" wrap={false}>
<div style={{ flexGrow: 1 }}>
<TextField
label={props.label}
value={props.value}
onChange={props.onChange}
autoComplete="off"
placeholder={props.placeholder}
helpText={props.helpText}
error={invalid ? "Serve un esadecimale, es. #005bd3" : undefined}
/>
</div>
<input
type="color"
aria-label={props.label}
value={valid ? props.value : "#000000"}
onChange={(e) => props.onChange(e.currentTarget.value)}
style={{
width: 40,
height: 36,
padding: 0,
border: "1px solid #c9cccf",
borderRadius: 8,
background: "none",
cursor: "pointer",
}}
/>
</InlineStack>
);
}
function opFrame(html: string, height = 130) {
const doc = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">${html}</table>`;
@@ -303,14 +224,7 @@ export default function SettingsPage() {
const [smtpSecure, setSmtpSecure] = useState(d.smtpSecure);
const [smtpFrom, setSmtpFrom] = useState(d.smtpFrom);
const [testTo, setTestTo] = useState(d.notifyEmail);
const [themeAccent, setThemeAccent] = useState(d.themeAccent);
const [themeButtonBg, setThemeButtonBg] = useState(d.themeButtonBg);
const [themeButtonText, setThemeButtonText] = useState(d.themeButtonText);
const [themeRadius, setThemeRadius] = useState(d.themeRadius);
const [themeFont, setThemeFont] = useState(d.themeFont);
const [themeWidth, setThemeWidth] = useState(d.themeWidth);
const [themeScheme, setThemeScheme] = useState(d.themeScheme);
const [themeCustomCss, setThemeCustomCss] = useState(d.themeCustomCss);
const [showSaved, setShowSaved] = useState(false);
const saving = nav.state === "submitting";
@@ -358,16 +272,7 @@ export default function SettingsPage() {
// Anteprima FEDELE: stesso renderer e stesso CSS dello storefront (recesso.view).
const themePreview = useMemo(() => {
const tokens: ThemeTokens = {
accent: themeAccent,
buttonBg: themeButtonBg,
buttonText: themeButtonText,
radius: Number(themeRadius) || null,
font: themeFont,
width: Number(themeWidth) || null,
scheme: themeScheme,
customCss: themeCustomCss,
};
const tokens: ThemeTokens = { scheme: themeScheme };
return renderStep2(
{
orderId: "gid://shopify/Order/0",
@@ -378,16 +283,7 @@ export default function SettingsPage() {
},
tokens,
);
}, [
themeAccent,
themeButtonBg,
themeButtonText,
themeRadius,
themeFont,
themeWidth,
themeScheme,
themeCustomCss,
]);
}, [themeScheme]);
const handleTest = () => {
setShowSaved(false);
@@ -428,25 +324,12 @@ export default function SettingsPage() {
fd.set("smtpPass", smtpPass);
fd.set("smtpSecure", String(smtpSecure));
fd.set("smtpFrom", smtpFrom);
fd.set("themeAccent", themeAccent);
fd.set("themeButtonBg", themeButtonBg);
fd.set("themeButtonText", themeButtonText);
fd.set("themeRadius", themeRadius);
fd.set("themeFont", themeFont);
fd.set("themeWidth", themeWidth);
fd.set("themeScheme", themeScheme);
fd.set("themeCustomCss", themeCustomCss);
submit(fd, { method: "post" });
};
const resetTheme = () => {
setThemeAccent("");
setThemeButtonBg("");
setThemeButtonText("");
setThemeRadius("");
setThemeFont("system");
setThemeWidth("");
setThemeCustomCss("");
setThemeScheme(DEFAULT_SCHEME);
setShowSaved(false);
};
@@ -870,8 +753,8 @@ export default function SettingsPage() {
Aspetto del form
</Text>
<Text as="p" tone="subdued">
Campi vuoti = default. Un valore non valido non viene
salvato: il form ricade sul default invece di rompersi.
Il form usa una grafica gia' pronta, leggibile e
accessibile. Scegli solo se mostrarla chiara o scura.
</Text>
</BlockStack>
@@ -883,66 +766,6 @@ export default function SettingsPage() {
helpText="Il form si apre in un riquadro sopra il tuo tema, che l'app non puo' leggere. Scegli 'Segue il sistema' solo se il tuo tema ha una versione scura."
/>
<ColorField
label="Colore accento"
value={themeAccent}
onChange={setThemeAccent}
placeholder="#005bd3"
helpText="Link e anello di focus."
/>
<ColorField
label="Sfondo del bottone"
value={themeButtonBg}
onChange={setThemeButtonBg}
placeholder="#1a1a1a"
helpText="Lo stato hover viene calcolato da qui."
/>
<ColorField
label="Testo del bottone"
value={themeButtonText}
onChange={setThemeButtonText}
placeholder="#ffffff"
/>
<Select
label="Carattere"
options={FONT_OPTIONS}
value={themeFont}
onChange={setThemeFont}
/>
<TextField
label="Raggio degli angoli"
type="number"
value={themeRadius}
onChange={setThemeRadius}
autoComplete="off"
min={RADIUS_MIN}
max={RADIUS_MAX}
suffix="px"
placeholder="14"
/>
<TextField
label="Larghezza massima"
type="number"
value={themeWidth}
onChange={setThemeWidth}
autoComplete="off"
min={WIDTH_MIN}
max={WIDTH_MAX}
suffix="px"
placeholder="520"
/>
<TextField
label="CSS personalizzato (avanzato)"
value={themeCustomCss}
onChange={setThemeCustomCss}
autoComplete="off"
multiline={5}
monospaced
helpText="Iniettato dopo il CSS di base. Vietati @import e tag di chiusura; massimo 4000 caratteri."
/>
<ButtonGroup>
<Button
variant="primary"
@@ -962,19 +785,47 @@ export default function SettingsPage() {
Anteprima
</Text>
<Text as="p" tone="subdued">
Stesso markup e stesso CSS che vedra' il cliente.
Stesso markup e stesso CSS che vedra' il cliente. Non e'
interattiva: i campi e i bottoni non rispondono.
</Text>
<iframe
title="Anteprima form di recesso"
srcDoc={themePreview}
{/*
sandbox senza allow-forms / allow-same-origin /
allow-top-navigation: l'anteprima non puo' inviare il
form ne' navigare. `allow-scripts` serve solo allo script
inline che riconosce l'iframe e applica il layout
compatto, lo stesso che il cliente vede nel modal.
pointer-events + inert tolgono anche mouse e tastiera,
cosi' non sembra cliccabile.
*/}
<div
style={{
width: "100%",
height: "720px",
maxHeight: "720px",
overflowY: "auto",
border: "1px solid #e1e1e1",
borderRadius: "8px",
background: "#fff",
}}
/>
>
<iframe
title="Anteprima form di recesso (non interattiva)"
srcDoc={themePreview}
sandbox="allow-scripts"
// @ts-expect-error inert e' valido in HTML, non ancora nei tipi React 18
inert=""
tabIndex={-1}
scrolling="no"
style={{
display: "block",
width: "100%",
// Alto abbastanza da contenere il form: l'iframe non
// puo' scrollare (pointer-events: none), scrolla il
// contenitore.
height: "1100px",
border: 0,
pointerEvents: "none",
}}
/>
</div>
</BlockStack>
</Card>
</BlockStack>

View File

@@ -63,16 +63,10 @@ async function loadTheme(shop: string): Promise<ThemeTokens | null> {
.findUnique({ where: { shop } })
.catch(() => null);
if (!s) return null;
return {
accent: s.themeAccent,
buttonBg: s.themeButtonBg,
buttonText: s.themeButtonText,
radius: s.themeRadius,
font: s.themeFont,
width: s.themeWidth,
scheme: s.themeScheme,
customCss: s.themeCustomCss,
};
// Solo lo schema colore e' configurabile. Le colonne dei token (accento,
// bottone, raggio, carattere, larghezza, CSS custom) restano nel DB ma non
// vengono piu' lette: la grafica del form e' fissa e accessibile.
return { scheme: s.themeScheme };
}
/** Errore diagnosticabile ma senza PII: maschera gli indirizzi email. */