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.
*/
import { themeStyle, type ThemeTokens } from "./theme";
import {
DEFAULT_SCHEME,
schemeOrNull,
themeStyle,
type ColorScheme,
type ThemeTokens,
} from "./theme";
import {
FIELD,
INFO_TITLE,
@@ -45,7 +51,7 @@ export function attr(value: string): string {
const PAGE_CSS = `
:root {
color-scheme: light dark;
color-scheme: light;
--bg: #f1f2f4;
--surface: #ffffff;
--text: #1a1a1a;
@@ -317,57 +323,86 @@ const PAGE_CSS = `
.success h1 { color: var(--success); }
.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) {
* { 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. */
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.
// Cascata, in quest'ordine: palette chiara di base -> schema colore scelto dal
// 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);
return `<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<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">
<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>
<body>
<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.
*/
/**
* 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 {
accent?: string | null; // link, focus
buttonBg?: string | null; // bottone primario
@@ -19,6 +43,7 @@ export interface ThemeTokens {
radius?: number | null; // px
font?: string | null; // chiave di FONT_PRESETS
width?: number | null; // px, larghezza max della card
scheme?: string | null; // ColorScheme; default DEFAULT_SCHEME
customCss?: string | null; // livello 3
}

View File

@@ -31,13 +31,16 @@ import { sendTestEmail } from "../lib/mailer.server";
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";
import {
@@ -88,6 +91,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
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 ?? "",
};
};
@@ -172,6 +176,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
themeRadius: intInRange(f.get("themeRadius"), RADIUS_MIN, RADIUS_MAX),
themeWidth: intInRange(f.get("themeWidth"), WIDTH_MIN, WIDTH_MAX),
themeFont: fontOrNull(f.get("themeFont")),
themeScheme: schemeOrNull(f.get("themeScheme")),
themeCustomCss: String(f.get("themeCustomCss") ?? "").trim() || null,
};
@@ -304,6 +309,7 @@ export default function SettingsPage() {
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);
@@ -359,6 +365,7 @@ export default function SettingsPage() {
radius: Number(themeRadius) || null,
font: themeFont,
width: Number(themeWidth) || null,
scheme: themeScheme,
customCss: themeCustomCss,
};
return renderStep2(
@@ -378,6 +385,7 @@ export default function SettingsPage() {
themeRadius,
themeFont,
themeWidth,
themeScheme,
themeCustomCss,
]);
@@ -426,6 +434,7 @@ export default function SettingsPage() {
fd.set("themeRadius", themeRadius);
fd.set("themeFont", themeFont);
fd.set("themeWidth", themeWidth);
fd.set("themeScheme", themeScheme);
fd.set("themeCustomCss", themeCustomCss);
submit(fd, { method: "post" });
};
@@ -866,6 +875,14 @@ export default function SettingsPage() {
</Text>
</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
label="Colore accento"
value={themeAccent}

View File

@@ -70,6 +70,7 @@ async function loadTheme(shop: string): Promise<ThemeTokens | null> {
radius: s.themeRadius,
font: s.themeFont,
width: s.themeWidth,
scheme: s.themeScheme,
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?
themeFont String?
themeWidth Int?
// "light" | "dark" | "auto". NULL = default applicativo (light).
themeScheme String?
themeCustomCss String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt