feat(theme): let the merchant choose the form color scheme

PAGE_CSS carried an unconditional `@media (prefers-color-scheme: dark)`
block that redefined --bg, --surface, --text and --border. The merchant
theme override only sets accent, button and typography tokens, so a
visitor whose OS is in dark mode saw a dark card with the merchant's
button colour on it, and no setting could change that. The form is
rendered in a modal on top of the shop theme, which the app cannot read
and which is almost always light.

Move the dark palette out of PAGE_CSS into DARK_VARS and let renderShell
apply it according to a new "themeScheme" setting: light (default), dark,
or auto (follows prefers-color-scheme, the previous behaviour).

The cascade is now base light palette -> chosen scheme -> merchant
override, so custom accent and button colours win under either scheme.
`color-scheme` and its meta tag follow the same setting, which keeps the
native controls consistent.

Existing rows have themeScheme NULL and fall back to the light default.

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 14:56:39 +02:00
parent cadf88683e
commit 168b11c73b
6 changed files with 121 additions and 38 deletions

View File

@@ -9,7 +9,13 @@
* recesso.server.ts ri-esporta renderStep1..4, quindi nessun chiamante cambia. * recesso.server.ts ri-esporta renderStep1..4, quindi nessun chiamante cambia.
*/ */
import { themeStyle, type ThemeTokens } from "./theme"; import {
DEFAULT_SCHEME,
schemeOrNull,
themeStyle,
type ColorScheme,
type ThemeTokens,
} from "./theme";
import { import {
FIELD, FIELD,
INFO_TITLE, INFO_TITLE,
@@ -45,7 +51,7 @@ export function attr(value: string): string {
const PAGE_CSS = ` const PAGE_CSS = `
:root { :root {
color-scheme: light dark; color-scheme: light;
--bg: #f1f2f4; --bg: #f1f2f4;
--surface: #ffffff; --surface: #ffffff;
--text: #1a1a1a; --text: #1a1a1a;
@@ -317,57 +323,86 @@ const PAGE_CSS = `
.success h1 { color: var(--success); } .success h1 { color: var(--success); }
.success p { color: var(--text-muted); } .success p { color: var(--text-muted); }
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1114;
--surface: #1b1d21;
--text: #e7e9ec;
--text-muted: #a1a6ad;
--border: #34373d;
--border-input: #4c5058;
--border-input-hover: #676c75;
--accent: #5aa2ff;
--focus-ring: rgba(90, 162, 255, 0.34);
--primary-bg: #e7e9ec;
--primary-bg-hover: #ffffff;
--primary-text: #16181c;
--secondary-text: #e7e9ec;
--subtle-bg: #212429;
--tag-bg: #23374f;
--tag-text: #bcd6f7;
--info-bg: #15243a;
--info-border: #2d4a6b;
--info-text: #cfe0f5;
--coexist-bg: #212429;
--coexist-border: #3a3e45;
--coexist-text: #a1a6ad;
--error-bg: #3a1512;
--error-border: #7a2a1c;
--error-text: #ffb4a2;
--success: #5fd08a;
--success-bg: #163021;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 34px rgba(0, 0, 0, 0.45);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { transition: none !important; } * { transition: none !important; }
} }
`; `;
/**
* Palette scura: solo le dichiarazioni, senza selettore.
*
* Sta fuori da PAGE_CSS perche' il merchant decide *se* e *quando* applicarla
* (impostazione "Schema colore"). Prima era una `@media (prefers-color-scheme:
* dark)` incondizionata dentro PAGE_CSS: seguiva l'OS del visitatore e nessun
* override del merchant poteva spegnerla.
*/
const DARK_VARS = `
color-scheme: dark;
--bg: #0f1114;
--surface: #1b1d21;
--text: #e7e9ec;
--text-muted: #a1a6ad;
--border: #34373d;
--border-input: #4c5058;
--border-input-hover: #676c75;
--accent: #5aa2ff;
--focus-ring: rgba(90, 162, 255, 0.34);
--primary-bg: #e7e9ec;
--primary-bg-hover: #ffffff;
--primary-text: #16181c;
--secondary-text: #e7e9ec;
--subtle-bg: #212429;
--tag-bg: #23374f;
--tag-text: #bcd6f7;
--info-bg: #15243a;
--info-border: #2d4a6b;
--info-text: #cfe0f5;
--coexist-bg: #212429;
--coexist-border: #3a3e45;
--coexist-text: #a1a6ad;
--error-bg: #3a1512;
--error-border: #7a2a1c;
--error-text: #ffb4a2;
--success: #5fd08a;
--success-bg: #163021;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 34px rgba(0, 0, 0, 0.45);
`;
/**
* CSS dello schema colore. Va iniettato DOPO PAGE_CSS (che porta la palette
* chiara) e PRIMA dell'override del merchant, cosi' accento e bottone
* personalizzati vincono in entrambi gli schemi.
*/
function schemeCss(scheme: ColorScheme): string {
if (scheme === "dark") return `:root{${DARK_VARS}}`;
if (scheme === "auto")
return `@media (prefers-color-scheme: dark){:root{${DARK_VARS}}}`;
return "";
}
/** `color-scheme` per i controlli nativi (scrollbar, date picker, select). */
function schemeMeta(scheme: ColorScheme): string {
return scheme === "auto" ? "light dark" : scheme;
}
/** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */ /** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */
export function renderShell(inner: string, theme?: ThemeTokens | null): string { export function renderShell(inner: string, theme?: ThemeTokens | null): string {
// Gli override del merchant vengono DOPO il CSS di base: se sbaglia una // Cascata, in quest'ordine: palette chiara di base -> schema colore scelto dal
// configurazione, il default resta comunque valido. // merchant -> suoi override. L'override arriva per ultimo, quindi accento e
// bottone personalizzati valgono anche in tema scuro; e se sbaglia una
// configurazione, sotto resta comunque un form leggibile.
const scheme = schemeOrNull(theme?.scheme) ?? DEFAULT_SCHEME;
const schemeBlock = schemeCss(scheme);
const override = themeStyle(theme); const override = themeStyle(theme);
return `<!doctype html> return `<!doctype html>
<html lang="it"> <html lang="it">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="${schemeMeta(scheme)}">
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
<title>${escapeHtml(PAGE_TITLE)}</title> <title>${escapeHtml(PAGE_TITLE)}</title>
<style>${PAGE_CSS}</style>${override ? `\n<style>${override}</style>` : ""} <style>${PAGE_CSS}</style>${schemeBlock ? `\n<style>${schemeBlock}</style>` : ""}${override ? `\n<style>${override}</style>` : ""}
</head> </head>
<body> <body>
<script>(function(){if(window.self!==window.top){try{document.body.className="embed";}catch(e){}}})();</script> <script>(function(){if(window.self!==window.top){try{document.body.className="embed";}catch(e){}}})();</script>

View File

@@ -12,6 +12,30 @@
* ⚠ Tutto cio' che arriva dal merchant e' sanificato qui, non a valle. * ⚠ Tutto cio' che arriva dal merchant e' sanificato qui, non a valle.
*/ */
/**
* Chiaro / scuro / segue il sistema del visitatore.
*
* Il default e' `light`, non `auto`: il form vive dentro un modal sovrapposto al
* tema del negozio, che l'app non puo' leggere ed e' quasi sempre chiaro. Con
* `auto` un visitatore con OS in tema scuro vedrebbe un riquadro scuro dentro
* una pagina chiara.
*/
export type ColorScheme = "light" | "dark" | "auto";
export const SCHEME_OPTIONS = [
{ label: "Chiaro (consigliato)", value: "light" },
{ label: "Scuro", value: "dark" },
{ label: "Segue il sistema del visitatore", value: "auto" },
];
export const DEFAULT_SCHEME: ColorScheme = "light";
/** Valore ammesso? Tutto il resto ricade sul default. */
export function schemeOrNull(v: unknown): ColorScheme | null {
const s = String(v ?? "").trim();
return s === "light" || s === "dark" || s === "auto" ? s : null;
}
export interface ThemeTokens { export interface ThemeTokens {
accent?: string | null; // link, focus accent?: string | null; // link, focus
buttonBg?: string | null; // bottone primario buttonBg?: string | null; // bottone primario
@@ -19,6 +43,7 @@ export interface ThemeTokens {
radius?: number | null; // px radius?: number | null; // px
font?: string | null; // chiave di FONT_PRESETS font?: string | null; // chiave di FONT_PRESETS
width?: number | null; // px, larghezza max della card width?: number | null; // px, larghezza max della card
scheme?: string | null; // ColorScheme; default DEFAULT_SCHEME
customCss?: string | null; // livello 3 customCss?: string | null; // livello 3
} }

View File

@@ -31,13 +31,16 @@ import { sendTestEmail } from "../lib/mailer.server";
import { renderStep2 } from "../lib/recesso.view"; import { renderStep2 } from "../lib/recesso.view";
import { statementTemplate } from "../lib/recesso.copy"; import { statementTemplate } from "../lib/recesso.copy";
import { import {
DEFAULT_SCHEME,
FONT_OPTIONS, FONT_OPTIONS,
FONT_PRESETS, FONT_PRESETS,
RADIUS_MAX, RADIUS_MAX,
RADIUS_MIN, RADIUS_MIN,
SCHEME_OPTIONS,
WIDTH_MAX, WIDTH_MAX,
WIDTH_MIN, WIDTH_MIN,
isHexColor, isHexColor,
schemeOrNull,
type ThemeTokens, type ThemeTokens,
} from "../lib/theme"; } from "../lib/theme";
import { import {
@@ -88,6 +91,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
themeRadius: s?.themeRadius != null ? String(s.themeRadius) : "", themeRadius: s?.themeRadius != null ? String(s.themeRadius) : "",
themeFont: s?.themeFont ?? "system", themeFont: s?.themeFont ?? "system",
themeWidth: s?.themeWidth != null ? String(s.themeWidth) : "", themeWidth: s?.themeWidth != null ? String(s.themeWidth) : "",
themeScheme: s?.themeScheme ?? DEFAULT_SCHEME,
themeCustomCss: s?.themeCustomCss ?? "", themeCustomCss: s?.themeCustomCss ?? "",
}; };
}; };
@@ -172,6 +176,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
themeRadius: intInRange(f.get("themeRadius"), RADIUS_MIN, RADIUS_MAX), themeRadius: intInRange(f.get("themeRadius"), RADIUS_MIN, RADIUS_MAX),
themeWidth: intInRange(f.get("themeWidth"), WIDTH_MIN, WIDTH_MAX), themeWidth: intInRange(f.get("themeWidth"), WIDTH_MIN, WIDTH_MAX),
themeFont: fontOrNull(f.get("themeFont")), themeFont: fontOrNull(f.get("themeFont")),
themeScheme: schemeOrNull(f.get("themeScheme")),
themeCustomCss: String(f.get("themeCustomCss") ?? "").trim() || null, themeCustomCss: String(f.get("themeCustomCss") ?? "").trim() || null,
}; };
@@ -304,6 +309,7 @@ export default function SettingsPage() {
const [themeRadius, setThemeRadius] = useState(d.themeRadius); const [themeRadius, setThemeRadius] = useState(d.themeRadius);
const [themeFont, setThemeFont] = useState(d.themeFont); const [themeFont, setThemeFont] = useState(d.themeFont);
const [themeWidth, setThemeWidth] = useState(d.themeWidth); const [themeWidth, setThemeWidth] = useState(d.themeWidth);
const [themeScheme, setThemeScheme] = useState(d.themeScheme);
const [themeCustomCss, setThemeCustomCss] = useState(d.themeCustomCss); const [themeCustomCss, setThemeCustomCss] = useState(d.themeCustomCss);
const [showSaved, setShowSaved] = useState(false); const [showSaved, setShowSaved] = useState(false);
@@ -359,6 +365,7 @@ export default function SettingsPage() {
radius: Number(themeRadius) || null, radius: Number(themeRadius) || null,
font: themeFont, font: themeFont,
width: Number(themeWidth) || null, width: Number(themeWidth) || null,
scheme: themeScheme,
customCss: themeCustomCss, customCss: themeCustomCss,
}; };
return renderStep2( return renderStep2(
@@ -378,6 +385,7 @@ export default function SettingsPage() {
themeRadius, themeRadius,
themeFont, themeFont,
themeWidth, themeWidth,
themeScheme,
themeCustomCss, themeCustomCss,
]); ]);
@@ -426,6 +434,7 @@ export default function SettingsPage() {
fd.set("themeRadius", themeRadius); fd.set("themeRadius", themeRadius);
fd.set("themeFont", themeFont); fd.set("themeFont", themeFont);
fd.set("themeWidth", themeWidth); fd.set("themeWidth", themeWidth);
fd.set("themeScheme", themeScheme);
fd.set("themeCustomCss", themeCustomCss); fd.set("themeCustomCss", themeCustomCss);
submit(fd, { method: "post" }); submit(fd, { method: "post" });
}; };
@@ -866,6 +875,14 @@ export default function SettingsPage() {
</Text> </Text>
</BlockStack> </BlockStack>
<Select
label="Schema colore"
options={SCHEME_OPTIONS}
value={themeScheme}
onChange={setThemeScheme}
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 <ColorField
label="Colore accento" label="Colore accento"
value={themeAccent} value={themeAccent}

View File

@@ -70,6 +70,7 @@ async function loadTheme(shop: string): Promise<ThemeTokens | null> {
radius: s.themeRadius, radius: s.themeRadius,
font: s.themeFont, font: s.themeFont,
width: s.themeWidth, width: s.themeWidth,
scheme: s.themeScheme,
customCss: s.themeCustomCss, customCss: s.themeCustomCss,
}; };
} }

View File

@@ -0,0 +1,3 @@
-- Schema colore del form: "light" | "dark" | "auto".
-- NULL sulle righe esistenti: l'app applica il default (light).
ALTER TABLE "Settings" ADD COLUMN "themeScheme" TEXT;

View File

@@ -76,6 +76,8 @@ model Settings {
themeRadius Int? themeRadius Int?
themeFont String? themeFont String?
themeWidth Int? themeWidth Int?
// "light" | "dark" | "auto". NULL = default applicativo (light).
themeScheme String?
themeCustomCss String? themeCustomCss String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt