feat: add customizable CSS variables and custom CSS to Styling settings

Adds a "CSS Variables" panel to the Styling tab with a color picker and
description for every color token in globals.css (base, sidebar, design
token aliases, event colors, all-day chips), plus a "Custom CSS" textarea
that loads after the default stylesheet and can override any rule.

Overrides are stored per-user (customCssVars, customCss) and applied on
the root container's inline style, so they take precedence over themes
and per-field settings.

v1.113.0
This commit is contained in:
mARTin-B78 2026-07-08 19:57:21 +02:00
parent 4a18027068
commit 02b2e40649
7 changed files with 291 additions and 1 deletions

12
CHANGELOG.md Normal file
View File

@ -0,0 +1,12 @@
# Changelog
All notable changes to this project are documented in this file.
## [1.113.0] - 2026-07-08
### Added
- Styling settings: "CSS Variables" panel with a color picker and description for every
customizable color token in `globals.css` (base colors, settings sidebar, design token
aliases, event colors, all-day chips).
- Styling settings: "Custom CSS" textarea to inject a personal stylesheet that loads after
the app's default styles and can override any rule.

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.112.2", "version": "1.113.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -0,0 +1,3 @@
-- User: per-user CSS variable overrides and free-form custom stylesheet (Styling settings tab)
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "customCssVars" JSONB;
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "customCss" TEXT;

View File

@ -116,6 +116,8 @@ model User {
weatherLon Float? weatherLon Float?
viewSettings Json? viewSettings Json?
weatherRecentCities Json? weatherRecentCities Json?
customCssVars Json?
customCss String? @db.Text
hasCompletedOnboarding Boolean @default(true) hasCompletedOnboarding Boolean @default(true)
showCalendarProviderIcon Boolean @default(false) showCalendarProviderIcon Boolean @default(false)
headerCurrentDayFormat String? @default("DDD, DD. MMMM YYYY") headerCurrentDayFormat String? @default("DDD, DD. MMMM YYYY")

View File

@ -111,6 +111,8 @@ export async function GET(request: NextRequest) {
weatherLocation: true, weatherLocation: true,
weatherRecentCities: true, weatherRecentCities: true,
viewSettings: true, viewSettings: true,
customCssVars: true,
customCss: true,
hasCompletedOnboarding: true, hasCompletedOnboarding: true,
createdAt: true createdAt: true
} }
@ -156,6 +158,7 @@ export async function PATCH(request: NextRequest) {
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, weekStartDay, quoteSourceUrls, quoteLanguages, showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, weekStartDay, quoteSourceUrls, quoteLanguages,
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale, kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings, weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings,
customCssVars, customCss,
hasCompletedOnboarding hasCompletedOnboarding
} = body; } = body;
@ -256,6 +259,8 @@ export async function PATCH(request: NextRequest) {
...(weatherLocation !== undefined && { weatherLocation }), ...(weatherLocation !== undefined && { weatherLocation }),
...(weatherRecentCities !== undefined && { weatherRecentCities }), ...(weatherRecentCities !== undefined && { weatherRecentCities }),
...(viewSettings !== undefined && { viewSettings }), ...(viewSettings !== undefined && { viewSettings }),
...(customCssVars !== undefined && { customCssVars }),
...(customCss !== undefined && { customCss }),
...(hasCompletedOnboarding !== undefined && { hasCompletedOnboarding }), ...(hasCompletedOnboarding !== undefined && { hasCompletedOnboarding }),
}; };
if (password && password.trim() !== "") { if (password && password.trim() !== "") {
@ -364,6 +369,8 @@ export async function PATCH(request: NextRequest) {
weatherLocation: true, weatherLocation: true,
weatherRecentCities: true, weatherRecentCities: true,
viewSettings: true, viewSettings: true,
customCssVars: true,
customCss: true,
hasCompletedOnboarding: true, hasCompletedOnboarding: true,
} }
}); });

View File

@ -145,6 +145,219 @@ function ExtraTimezonesEditor({
); );
} }
// All customizable color custom-properties defined in globals.css :root, grouped for the
// "Styling" tab's CSS Variables editor. Overrides are stored as a flat { "--var-name": "#hex" }
// map in profile.customCssVars and applied on top of containerStyle in WeeklyView.
const CSS_VARIABLE_GROUPS: { title: string; vars: { name: string; label: string; default: string }[] }[] = [
{
title: "Base Colors",
vars: [
{ name: "--weekly-teal", label: "Accent", default: "#009a9a" },
{ name: "--weekly-bg", label: "Background", default: "#ffffff" },
{ name: "--weekly-text", label: "Text", default: "#000000" },
{ name: "--weekly-text-light", label: "Text (light)", default: "#767676" },
{ name: "--weekly-border", label: "Border", default: "#cccccc" },
{ name: "--weekly-completed", label: "Completed task", default: "#cccccc" },
],
},
{
title: "Settings Sidebar",
vars: [
{ name: "--weekly-settings-bg", label: "Background", default: "#ffffff" },
{ name: "--weekly-settings-item-bg", label: "Item background", default: "#f8fafc" },
{ name: "--weekly-settings-label", label: "Label", default: "#475569" },
{ name: "--weekly-settings-title", label: "Title", default: "#111827" },
{ name: "--weekly-settings-input-bg", label: "Input background", default: "#ffffff" },
{ name: "--weekly-settings-input-border", label: "Input border", default: "#dddddd" },
{ name: "--weekly-settings-text", label: "Text", default: "#111827" },
{ name: "--weekly-settings-toggle-bg", label: "Toggle background", default: "#f3f4f6" },
{ name: "--weekly-settings-toggle-active-bg", label: "Toggle active background", default: "#ffffff" },
{ name: "--weekly-settings-toggle-text", label: "Toggle text", default: "#6b7280" },
{ name: "--weekly-settings-toggle-active-text", label: "Toggle active text", default: "#111827" },
],
},
{
title: "Design Token Aliases",
vars: [
{ name: "--bg", label: "Background (alias)", default: "#ffffff" },
{ name: "--paper", label: "Paper (alias)", default: "#ffffff" },
{ name: "--ink", label: "Text (alias)", default: "#000000" },
{ name: "--ink-2", label: "Text variant 2", default: "#2a2a28" },
{ name: "--ink-3", label: "Text light (alias)", default: "#767676" },
{ name: "--ink-4", label: "Text variant 4", default: "#a8a8a2" },
{ name: "--ink-5", label: "Text variant 5", default: "#c8c8c2" },
{ name: "--accent", label: "Accent (alias)", default: "#009a9a" },
{ name: "--line", label: "Border (alias)", default: "#cccccc" },
{ name: "--line-soft", label: "Border (soft)", default: "#f3f3f0" },
{ name: "--weekend", label: "Weekend", default: "#dc2626" },
{ name: "--tasks-bg", label: "Tasks background", default: "#f4f2ee" },
],
},
{
title: "Event Colors — Default",
vars: [
{ name: "--ev-default-bg", label: "Background", default: "#eef2f7" },
{ name: "--ev-default-border", label: "Border", default: "#6c87a8" },
{ name: "--ev-default-title", label: "Title", default: "#2b3f57" },
{ name: "--ev-default-meta", label: "Meta", default: "#6c87a8" },
],
},
{
title: "Event Colors — Family",
vars: [
{ name: "--ev-fam-bg", label: "Background", default: "#fbeef0" },
{ name: "--ev-fam-border", label: "Border", default: "#b85a6a" },
{ name: "--ev-fam-title", label: "Title", default: "#5a2530" },
{ name: "--ev-fam-meta", label: "Meta", default: "#99536a" },
],
},
{
title: "Event Colors — Finance",
vars: [
{ name: "--ev-fin-bg", label: "Background", default: "#f6f0e2" },
{ name: "--ev-fin-border", label: "Border", default: "#a88a3c" },
{ name: "--ev-fin-title", label: "Title", default: "#4a3a14" },
{ name: "--ev-fin-meta", label: "Meta", default: "#8a6f2a" },
],
},
{
title: "Event Colors — Development",
vars: [
{ name: "--ev-dev-bg", label: "Background", default: "#ecf3ed" },
{ name: "--ev-dev-border", label: "Border", default: "#5a7a4a" },
{ name: "--ev-dev-title", label: "Title", default: "#2c4527" },
{ name: "--ev-dev-meta", label: "Meta", default: "#5a7a4a" },
],
},
{
title: "All-Day Chips",
vars: [
{ name: "--chip-default-bg", label: "Default", default: "#b8b8b0" },
{ name: "--chip-fam-bg", label: "Family", default: "#e88a8a" },
{ name: "--chip-special-bg", label: "Special background", default: "#f4d588" },
{ name: "--chip-special-text", label: "Special text", default: "#6b4f10" },
],
},
];
// Lets the user override any globals.css :root color variable individually. Overrides are
// merged into profile.customCssVars (a flat map) and spread onto the root container's inline
// style in WeeklyView, so they take precedence over every other color source (theme, per-field
// settings, etc.).
function CssVariablesEditor({
profile,
setProfile,
saveSetting,
}: {
profile: any;
setProfile: React.Dispatch<React.SetStateAction<any>>;
saveSetting: (key: string, value: any) => void;
}) {
const de = profile.language === "de";
const vars: Record<string, string> = profile.customCssVars || {};
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
const setVar = (name: string, value: string) => {
const next = { ...(profile.customCssVars || {}), [name]: value };
setProfile((p: any) => ({ ...p, customCssVars: next }));
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => saveSetting("customCssVars", next), 500);
};
const reset = () => {
setProfile((p: any) => ({ ...p, customCssVars: {} }));
saveSetting("customCssVars", {});
};
return (
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" }}>
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-label)" }}>
{de ? "CSS-Variablen" : "CSS Variables"}
</label>
<button
type="button"
onClick={reset}
style={{
fontSize: "0.7rem",
background: "none",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "6px",
padding: "3px 8px",
cursor: "pointer",
color: "var(--weekly-settings-label)",
}}
>
{de ? "Zurücksetzen" : "Reset"}
</button>
</div>
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 10px", opacity: 0.8 }}>
{de
? "Passe jede Design-Farbvariable des Stylesheets einzeln an."
: "Fine-tune every design-token color used by the stylesheet."}
</p>
{CSS_VARIABLE_GROUPS.map((group) => (
<div key={group.title} style={{ marginBottom: "12px" }}>
<div
style={{
fontSize: "0.7rem",
fontWeight: 600,
color: "var(--weekly-settings-title)",
marginBottom: "6px",
textTransform: "uppercase",
letterSpacing: "0.02em",
}}
>
{group.title}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px 12px" }}>
{group.vars.map((v) => (
<div
key={v.name}
title={v.name}
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}
>
<span
style={{
fontSize: "0.72rem",
color: "var(--weekly-settings-label)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{v.label}
</span>
<input
type="color"
value={vars[v.name] || v.default}
onChange={(e) => setVar(v.name, e.target.value)}
style={{
width: "32px",
height: "22px",
cursor: "pointer",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "transparent",
flexShrink: 0,
}}
/>
</div>
))}
</div>
</div>
))}
</div>
);
}
// Minimal ProjectIcon — resolves an icon name from the unified registry. // Minimal ProjectIcon — resolves an icon name from the unified registry.
function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) { function ProjectIcon({ icon, size = 18, color }: { icon?: string | null; size?: number; color?: string }) {
if (!icon) return <FontAwesomeIcon icon={faFolder} style={{ fontSize: size, color }} />; if (!icon) return <FontAwesomeIcon icon={faFolder} style={{ fontSize: size, color }} />;
@ -3712,6 +3925,55 @@ function SettingsSidebar({
</div> </div>
</div> </div>
{/* CSS Variables */}
<CssVariablesEditor profile={profile} setProfile={setProfile} saveSetting={saveSetting} />
{/* Custom CSS */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "4px",
}}
>
{profile.language === "de" ? "Eigenes CSS" : "Custom CSS"}
</label>
<p style={{ fontSize: "0.72rem", color: "var(--weekly-settings-label)", margin: "0 0 8px", opacity: 0.8 }}>
{profile.language === "de"
? "Wird nach dem Stylesheet geladen und kann jede Regel überschreiben."
: "Loaded after the stylesheet — can override any rule."}
</p>
<textarea
value={profile.customCss || ""}
onChange={(e) => saveFieldDebounced("customCss", e.target.value)}
rows={10}
spellCheck={false}
placeholder={".weekly-task-item.completed .weekly-task-text {\n color: #cccccc;\n}"}
style={{
width: "100%",
fontFamily: "monospace",
fontSize: "0.75rem",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "6px",
padding: "8px",
resize: "vertical",
boxSizing: "border-box",
}}
/>
</div>
{/* All styling settings auto-save */} {/* All styling settings auto-save */}
</div> </div>
) : activeTab === "motivation" ? ( ) : activeTab === "motivation" ? (

View File

@ -5499,6 +5499,7 @@ export default function WeeklyView() {
"--weekly-past-color": activeTheme?.color8 || (darkMode "--weekly-past-color": activeTheme?.color8 || (darkMode
? invertColor(profile.pastDayColor || "#a6a6a7") ? invertColor(profile.pastDayColor || "#a6a6a7")
: profile.pastDayColor || "#a6a6a7"), : profile.pastDayColor || "#a6a6a7"),
...((profile as any).customCssVars || {}),
...(useLeftRail ? { paddingLeft: leftRailExpanded ? `${expandedRailWidth}px` : `${collapsedRailWidth}px`, transition: "padding-left 0.2s ease" } : {}), ...(useLeftRail ? { paddingLeft: leftRailExpanded ? `${expandedRailWidth}px` : `${collapsedRailWidth}px`, transition: "padding-left 0.2s ease" } : {}),
} as React.CSSProperties; } as React.CSSProperties;
@ -5718,6 +5719,9 @@ export default function WeeklyView() {
className={`weekly-container ${darkMode ? "dark-mode" : ""} font-size-${(profile.fontSize ?? "M").toLowerCase()} ${profile.viewStyle}-view ${profile.showTimeGrid ? "time-grid-on" : "time-grid-off"}${(() => { const hd = isMobile ? (isPortrait ? (profile.mobilePortraitHeaderDisplay || "current_day") : (profile.mobileLandscapeHeaderDisplay || profile.headerDisplay || "kw")) : (profile.headerDisplay || "kw"); return (viewDays === 1 && hd === "current_day") ? " header-current-day-single" : ""; })()}`} className={`weekly-container ${darkMode ? "dark-mode" : ""} font-size-${(profile.fontSize ?? "M").toLowerCase()} ${profile.viewStyle}-view ${profile.showTimeGrid ? "time-grid-on" : "time-grid-off"}${(() => { const hd = isMobile ? (isPortrait ? (profile.mobilePortraitHeaderDisplay || "current_day") : (profile.mobileLandscapeHeaderDisplay || profile.headerDisplay || "kw")) : (profile.headerDisplay || "kw"); return (viewDays === 1 && hd === "current_day") ? " header-current-day-single" : ""; })()}`}
style={containerStyle} style={containerStyle}
> >
{(profile as any).customCss ? (
<style suppressHydrationWarning>{(profile as any).customCss}</style>
) : null}
{/* Visually hidden live region for screen reader announcements */} {/* Visually hidden live region for screen reader announcements */}
<div <div
role="status" role="status"