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() { +