diff --git a/app/app/lib/recesso.view.ts b/app/app/lib/recesso.view.ts
index b767c71..eaba592 100644
--- a/app/app/lib/recesso.view.ts
+++ b/app/app/lib/recesso.view.ts
@@ -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 `
-
+
${escapeHtml(PAGE_TITLE)}
-${override ? `\n` : ""}
+${schemeBlock ? `\n` : ""}${override ? `\n` : ""}
diff --git a/app/app/lib/theme.ts b/app/app/lib/theme.ts
index 85f27c9..22d8cec 100644
--- a/app/app/lib/theme.ts
+++ b/app/app/lib/theme.ts
@@ -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
}
diff --git a/app/app/routes/app.settings.tsx b/app/app/routes/app.settings.tsx
index 76f5971..c36709f 100644
--- a/app/app/routes/app.settings.tsx
+++ b/app/app/routes/app.settings.tsx
@@ -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() {
+
+
{
radius: s.themeRadius,
font: s.themeFont,
width: s.themeWidth,
+ scheme: s.themeScheme,
customCss: s.themeCustomCss,
};
}
diff --git a/app/prisma/migrations/20260710163000_theme_scheme/migration.sql b/app/prisma/migrations/20260710163000_theme_scheme/migration.sql
new file mode 100644
index 0000000..db677a2
--- /dev/null
+++ b/app/prisma/migrations/20260710163000_theme_scheme/migration.sql
@@ -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;
diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma
index 67f467a..ea650d1 100644
--- a/app/prisma/schema.prisma
+++ b/app/prisma/schema.prisma
@@ -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