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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user