diff --git a/CHANGELOG.md b/CHANGELOG.md
index 708c659e..5b3551fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,21 @@ All notable changes to CV Manager will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follows [Semantic Versioning](https://semver.org/).
+## [1.41.0] - 2026-04-19
+
+### Added
+- Theme picker now exposes a **Custom section corner radius** option. A toggle reveals a slider (0–32px, integer steps, default 16px) that controls the border-radius of every section box — both built-in (About, Experience, Certifications, Education, Skills, Projects, Timeline) and custom — via the new `--section-radius` CSS variable. When the toggle is off, sections fall back to the pre-1.41 `--radius-lg` value and are visually unchanged. The chosen radius is stored alongside the other theme fields in the per-dataset `data.theme` blob, follows the same `PUT /api/theme` propagation rules (applyToAll, language siblings) and is restored on dataset load. Print styles also honor the custom radius so the printed PDF matches the on-screen look.
+- The top CV header's corner radius scales in proportion to the section radius slider (1.5× the section value — matches the baseline 24px header : 16px section ratio) via a new `--header-radius` CSS variable. Picking 0px yields fully square corners on both sections and header; picking 32px yields heavily rounded corners throughout. When the toggle is off, the header keeps its pre-1.41 `--radius-xl` look via the CSS fallback. The scaling applies on the admin preview, the public read-only page, and printed PDFs.
+
+### Fixed
+- When a custom gradient is configured in the theme picker, the `--primary-dark` and `--accent` CSS variables now both follow the user's chosen gradient end color instead of staying derived from the primary. `--primary-dark` drives every item title in the CV (Experience job titles, Certification names, custom-section item titles and bullet titles); `--accent` drives the timeline branch strokes and the item-card highlight pulse. The gradient end is typically the darker of the two endpoints (matching the bundled pair presets), so this keeps body-text titles legible while pulling them inside the chosen palette. When no custom gradient is set, both variables continue to auto-derive from primary as before.
+- The admin top toolbar (and its mobile-menu drawer) now use `--header-gradient-start` / `--header-gradient-end` instead of the literal `--primary-dark` / `--dark` variables. In the default theme these resolve to the same colors so the toolbar looks identical, but when a custom gradient is set the toolbar adopts the user's start → end colors — matching the CV header — instead of mixing a custom start color with the default primary-derived dark end.
+
+## [1.40.0] - 2026-04-19
+
+### Added
+- Theme picker now exposes a **Custom section title color** option. A toggle reveals a dedicated color wheel with a brightness slider, hex input, and 12 preset swatches chosen to read well as heading text (black, charcoal, slate, gray, navy, dark blue, midnight, dark green, dark red, dark purple, brown, steel). When off, section titles continue to track the primary color as before — the new `--section-title-color` CSS variable falls back to `var(--primary)` so existing themes are visually unchanged. The chosen color applies to every built-in section heading (About, Experience, Certifications, Education, Skills, Projects, Timeline) and every custom section heading on both the admin preview and the public read-only CV page. The color is stored alongside the other theme fields in the per-dataset `data.theme` blob and follows the same propagation rule as the rest of the theme: `applyToAll: true` writes it into every saved dataset; `applyToAll: false` writes it into the current dataset and its language siblings only.
+
## [1.39.0] - 2026-04-19
### Added
diff --git a/package-lock.json b/package-lock.json
index 3ebbd165..e39b448a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cv-manager",
- "version": "1.39.0",
+ "version": "1.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cv-manager",
- "version": "1.39.0",
+ "version": "1.41.0",
"dependencies": {
"archiver": "^7.0.1",
"better-sqlite3": "^9.4.3",
diff --git a/package.json b/package.json
index 47f04166..0fafb74a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cv-manager",
- "version": "1.39.0",
+ "version": "1.41.0",
"description": "Professional CV Management System",
"main": "src/server.js",
"scripts": {
diff --git a/public-readonly/index.html b/public-readonly/index.html
index 1710f428..29778c00 100644
--- a/public-readonly/index.html
+++ b/public-readonly/index.html
@@ -892,7 +892,17 @@
${escapeHtml(proj.title)}
try {
bulletStyle = (allSettings && allSettings.themeBulletStyle !== undefined) ? allSettings.themeBulletStyle : (await api('/api/settings/themeBulletStyle')).value;
} catch (e) { /* optional */ }
- applyThemePublic({ primary: primary || '#0066ff', gradientStart: gradientStart || null, gradientEnd: gradientEnd || null, fontFamily: fontFamily || 'Inter', bulletStyle: bulletStyle || 'triangle' });
+ let sectionTitleColor = null;
+ try {
+ sectionTitleColor = (allSettings && allSettings.themeSectionTitleColor !== undefined) ? allSettings.themeSectionTitleColor : (await api('/api/settings/themeSectionTitleColor')).value;
+ } catch (e) { /* optional */ }
+ let sectionRadius = null;
+ try {
+ const raw = (allSettings && allSettings.themeSectionRadius !== undefined) ? allSettings.themeSectionRadius : (await api('/api/settings/themeSectionRadius')).value;
+ const n = raw == null ? null : parseInt(raw, 10);
+ if (Number.isInteger(n) && n >= 0 && n <= 32) sectionRadius = n;
+ } catch (e) { /* optional */ }
+ applyThemePublic({ primary: primary || '#0066ff', gradientStart: gradientStart || null, gradientEnd: gradientEnd || null, fontFamily: fontFamily || 'Inter', bulletStyle: bulletStyle || 'triangle', sectionTitleColor: sectionTitleColor || null, sectionRadius });
} catch (err) { /* Ignore — defaults stand */ }
}
@@ -908,6 +918,21 @@ ${escapeHtml(proj.title)}
document.body.dataset.bulletStyle = bullet;
document.body.dataset.bulletKind = BULLET_GLYPH_IDS.has(bullet) ? 'glyph' : 'icon';
}
+ const root = document.documentElement;
+ if (theme.sectionTitleColor && /^#[0-9a-fA-F]{6}$/.test(theme.sectionTitleColor)) {
+ root.style.setProperty('--section-title-color', theme.sectionTitleColor);
+ } else {
+ root.style.removeProperty('--section-title-color');
+ }
+ if (Number.isInteger(theme.sectionRadius) && theme.sectionRadius >= 0 && theme.sectionRadius <= 32) {
+ root.style.setProperty('--section-radius', theme.sectionRadius + 'px');
+ // Header scales 1.5× — matches the baseline 24:16 ratio so the
+ // top card stays visually consistent with the section boxes.
+ root.style.setProperty('--header-radius', Math.round(theme.sectionRadius * 1.5) + 'px');
+ } else {
+ root.style.removeProperty('--section-radius');
+ root.style.removeProperty('--header-radius');
+ }
}
function applyColorToCSSPublic(hex, gradientStart, gradientEnd, fontFamily) {
@@ -915,10 +940,9 @@ ${escapeHtml(proj.title)}
const hsl = hexToHSLPublic(hex);
root.style.setProperty('--primary', hex);
- root.style.setProperty('--primary-dark', hslToHexPublic(hsl.h, hsl.s, Math.max(hsl.l - 15, 10)));
root.style.setProperty('--primary-light', hslToHexPublic(hsl.h, Math.min(hsl.s + 10, 100), Math.min(hsl.l + 15, 80)));
- const accent = hslToHexPublic((hsl.h + 15) % 360, hsl.s, hsl.l);
- root.style.setProperty('--accent', accent);
+ const autoAccent = hslToHexPublic((hsl.h + 15) % 360, hsl.s, hsl.l);
+ const autoPrimaryDark = hslToHexPublic(hsl.h, hsl.s, Math.max(hsl.l - 15, 10));
root.style.setProperty('--dark', hslToHexPublic(hsl.h, hsl.s, 15));
root.style.setProperty('--light', hslToHexPublic(hsl.h, 30, 90));
root.style.setProperty('--very-light', hslToHexPublic(hsl.h, 20, 97));
@@ -928,16 +952,23 @@ ${escapeHtml(proj.title)}
// so the whole theme feels consistent.
const hasCustomGradient = !!(gradientStart || gradientEnd);
const gs = gradientStart || hex;
- const ge = gradientEnd || accent;
+ const ge = gradientEnd || autoAccent;
root.style.setProperty('--gradient-start', gs);
root.style.setProperty('--gradient-end', ge);
if (hasCustomGradient) {
root.style.setProperty('--header-gradient-start', gs);
root.style.setProperty('--header-gradient-end', ge);
} else {
- root.style.setProperty('--header-gradient-start', hslToHexPublic(hsl.h, hsl.s, Math.max(hsl.l - 15, 10)));
+ root.style.setProperty('--header-gradient-start', autoPrimaryDark);
root.style.setProperty('--header-gradient-end', hslToHexPublic(hsl.h, hsl.s, 15));
}
+ // When a custom gradient is active, both --primary-dark (item
+ // titles, cert names, custom-section titles) and --accent (timeline
+ // branch strokes, item-card highlight pulse) follow the user's
+ // chosen gradient end so the CV's body text and accent strokes
+ // stay inside the chosen palette.
+ root.style.setProperty('--primary-dark', hasCustomGradient ? ge : autoPrimaryDark);
+ root.style.setProperty('--accent', hasCustomGradient ? ge : autoAccent);
const family = fontFamily || 'Inter';
if (family !== 'Inter') ensureFontLoadedPublic(family);
root.style.setProperty('--font-family', "'" + family + "', var(--font-family-default)");
diff --git a/public/index.html b/public/index.html
index da2b72d5..0488534e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -127,6 +127,44 @@
+
+
+
+ Custom section title color
+
+
+
+
+ Brightness
+
+
+
+
+
+
+
+
+
+
+ Custom section corner radius
+
+
+
+
+ Corner radius
+ 16px
+
+
+
+
+
+
diff --git a/public/shared/admin.css b/public/shared/admin.css
index 10d32e92..86f3fc9a 100644
--- a/public/shared/admin.css
+++ b/public/shared/admin.css
@@ -9,7 +9,12 @@
left: 0;
right: 0;
height: 54px;
- background: linear-gradient(135deg, var(--primary-dark), var(--dark));
+ /* Mirrors the CV header gradient — in default mode the auto-derived
+ endpoints resolve to the same primary-dark → dark colors used pre-1.41,
+ so existing themes look identical. When the user picks a custom
+ gradient, the toolbar follows their start/end the same way the CV
+ header does. */
+ background: linear-gradient(135deg, var(--header-gradient-start), var(--header-gradient-end));
display: flex;
align-items: center;
justify-content: space-between;
@@ -2274,7 +2279,7 @@
flex-direction: column;
gap: 2px;
padding: 8px 12px;
- background: linear-gradient(135deg, var(--primary-dark), var(--dark));
+ background: linear-gradient(135deg, var(--header-gradient-start), var(--header-gradient-end));
box-shadow: var(--shadow-lg);
z-index: 999;
border-top: 1px solid rgba(255,255,255,0.1);
diff --git a/public/shared/admin.js b/public/shared/admin.js
index 4760f980..9104f507 100644
--- a/public/shared/admin.js
+++ b/public/shared/admin.js
@@ -3694,7 +3694,42 @@ const FONT_OPTIONS = [
{ family: 'JetBrains Mono', label: 'JetBrains Mono' }
];
-const THEME_DEFAULTS = { primary: '#0066ff', gradientStart: null, gradientEnd: null, fontFamily: 'Inter', bulletStyle: 'triangle' };
+const THEME_DEFAULTS = { primary: '#0066ff', gradientStart: null, gradientEnd: null, fontFamily: 'Inter', bulletStyle: 'triangle', sectionTitleColor: null, sectionRadius: null };
+
+// Slider bounds for the section corner-radius picker (pixels). 0 = sharp
+// corners; 32 = very rounded. Default rests at 16px — matches the pre-1.41
+// `--radius-lg` value so unchanged themes look identical.
+const SECTION_RADIUS_MIN = 0;
+const SECTION_RADIUS_MAX = 32;
+const SECTION_RADIUS_DEFAULT = 16;
+
+// Preset swatches for the "Section titles" color picker. These colors are
+// chosen to read well as heading text: neutrals (black, charcoal, slate, gray),
+// deep blues/navies, plus a few darker hues so users can pick a muted heading
+// color when they don't want section titles to match the primary accent.
+const SECTION_TITLE_PRESETS = [
+ { color: '#000000', labelKey: 'theme.title_color.black', fallback: 'Black' },
+ { color: '#111827', labelKey: 'theme.title_color.charcoal', fallback: 'Charcoal' },
+ { color: '#1f2937', labelKey: 'theme.title_color.slate', fallback: 'Slate' },
+ { color: '#374151', labelKey: 'theme.title_color.gray', fallback: 'Gray' },
+ { color: '#001a4d', labelKey: 'theme.title_color.navy', fallback: 'Navy' },
+ { color: '#1e3a8a', labelKey: 'theme.title_color.dark_blue', fallback: 'Dark Blue' },
+ { color: '#0f172a', labelKey: 'theme.title_color.midnight', fallback: 'Midnight' },
+ { color: '#064e3b', labelKey: 'theme.title_color.dark_green', fallback: 'Dark Green' },
+ { color: '#7f1d1d', labelKey: 'theme.title_color.dark_red', fallback: 'Dark Red' },
+ { color: '#581c87', labelKey: 'theme.title_color.dark_purple', fallback: 'Dark Purple' },
+ { color: '#78350f', labelKey: 'theme.title_color.brown', fallback: 'Brown' },
+ { color: '#334155', labelKey: 'theme.title_color.steel', fallback: 'Steel' }
+];
+
+// Lookup table used by the color-wheel machinery so the same setup/draw/pick
+// helpers work for the primary wheel, the gradient wheel, and the section-title
+// wheel. Adding a new wheel elsewhere is a matter of registering its DOM IDs.
+const WHEEL_IDS = {
+ primary: { canvas: 'colorWheel', cursor: 'colorWheelCursor', preview: 'colorPreview', input: 'colorHexInput', brightness: 'colorBrightness' },
+ gradient: { canvas: 'gradientWheel', cursor: 'gradientWheelCursor', preview: 'gradientPreview', input: 'gradientHexInput', brightness: 'gradientBrightness' },
+ sectionTitle: { canvas: 'sectionTitleWheel', cursor: 'sectionTitleWheelCursor', preview: 'sectionTitlePreview', input: 'sectionTitleHexInput', brightness: 'sectionTitleBrightness' }
+};
// Bullet styles offered by the theme picker. `glyph` renders as a raw character;
// `icon` renders via the Material Symbols Outlined font ligature. Server-side
@@ -3763,15 +3798,15 @@ let applyToAllDatasets = true;
// currently active ('start' or 'end'). We keep HSL state for each endpoint
// so switching tabs restores the wheel to the stored color/brightness.
let activeGradientEndpoint = 'end';
-let pickerHSL = { primary: { h: 214, s: 100, l: 50 }, gradient: { h: 214, s: 100, l: 35 } };
-let wheelDragState = { primary: false, gradient: false };
-let wheelCtx = { primary: null, gradient: null };
+let pickerHSL = { primary: { h: 214, s: 100, l: 50 }, gradient: { h: 214, s: 100, l: 35 }, sectionTitle: { h: 0, s: 0, l: 20 } };
+let wheelDragState = { primary: false, gradient: false, sectionTitle: false };
+let wheelCtx = { primary: null, gradient: null, sectionTitle: null };
const loadedFontSet = new Set(['Inter']);
// Preset grid is scoped by container ID so the selector for target=primary
// doesn't match .color-preset elements elsewhere. The gradient sub-picker
// uses a different preset style (pair swatches) handled separately.
-const PRESET_CONTAINER = { primary: 'primaryPresets' };
+const PRESET_CONTAINER = { primary: 'primaryPresets', sectionTitle: 'sectionTitlePresets' };
// Backward-compat alias kept because other parts of admin.js may still read currentColor
let currentColor = themeState.primary;
@@ -3812,6 +3847,68 @@ function initColorPicker() {
setupBrightness('gradient');
}
+ // Section-title sub-picker (built when user enables a custom heading color).
+ const stCanvas = document.getElementById('sectionTitleWheel');
+ if (stCanvas) {
+ wheelCtx.sectionTitle = stCanvas.getContext('2d');
+ drawColorWheel('sectionTitle');
+ setupWheelEvents('sectionTitle');
+ setupHexInput('sectionTitle');
+ setupPresets('sectionTitle');
+ setupBrightness('sectionTitle');
+ }
+
+ const useSectionTitleToggle = document.getElementById('themeUseSectionTitleColor');
+ if (useSectionTitleToggle) {
+ useSectionTitleToggle.addEventListener('change', () => {
+ const wrapper = document.getElementById('sectionTitleColorWrapper');
+ if (useSectionTitleToggle.checked) {
+ // Seed with a sensible default (black) if nothing chosen yet so
+ // the wheel/preview immediately reflect an actual color rather
+ // than flashing an empty swatch.
+ if (!themeState.sectionTitleColor) themeState.sectionTitleColor = '#000000';
+ pickerHSL.sectionTitle = hexToHSL(themeState.sectionTitleColor);
+ const slider = document.getElementById('sectionTitleBrightness');
+ if (slider) slider.value = pickerHSL.sectionTitle.l;
+ if (wrapper) wrapper.style.display = 'block';
+ drawColorWheel('sectionTitle');
+ updateColorPickerUI('sectionTitle', themeState.sectionTitleColor);
+ } else {
+ themeState.sectionTitleColor = null;
+ if (wrapper) wrapper.style.display = 'none';
+ }
+ applyThemeToCSS(themeState);
+ });
+ }
+
+ const useSectionRadiusToggle = document.getElementById('themeUseSectionRadius');
+ const sectionRadiusSlider = document.getElementById('themeSectionRadiusSlider');
+ const sectionRadiusLabel = document.getElementById('themeSectionRadiusValue');
+ if (useSectionRadiusToggle) {
+ useSectionRadiusToggle.addEventListener('change', () => {
+ const wrapper = document.getElementById('sectionRadiusWrapper');
+ if (useSectionRadiusToggle.checked) {
+ if (themeState.sectionRadius == null) themeState.sectionRadius = SECTION_RADIUS_DEFAULT;
+ if (sectionRadiusSlider) sectionRadiusSlider.value = themeState.sectionRadius;
+ if (sectionRadiusLabel) sectionRadiusLabel.textContent = `${themeState.sectionRadius}px`;
+ if (wrapper) wrapper.style.display = 'block';
+ } else {
+ themeState.sectionRadius = null;
+ if (wrapper) wrapper.style.display = 'none';
+ }
+ applyThemeToCSS(themeState);
+ });
+ }
+ if (sectionRadiusSlider) {
+ sectionRadiusSlider.addEventListener('input', () => {
+ const v = parseInt(sectionRadiusSlider.value, 10);
+ if (Number.isNaN(v)) return;
+ themeState.sectionRadius = v;
+ if (sectionRadiusLabel) sectionRadiusLabel.textContent = `${v}px`;
+ applyThemeToCSS(themeState);
+ });
+ }
+
const useGradientToggle = document.getElementById('themeUseGradient');
if (useGradientToggle) {
useGradientToggle.addEventListener('change', () => {
@@ -3863,8 +3960,9 @@ function initColorPicker() {
}
function setupWheelEvents(target) {
- const canvasId = target === 'primary' ? 'colorWheel' : 'gradientWheel';
- const canvas = document.getElementById(canvasId);
+ const ids = WHEEL_IDS[target];
+ if (!ids) return;
+ const canvas = document.getElementById(ids.canvas);
if (!canvas) return;
const start = (e) => { wheelDragState[target] = true; pickColorAt(target, e); };
const move = (e) => { if (wheelDragState[target]) pickColorAt(target, e); };
@@ -3882,13 +3980,14 @@ function setupWheelEvents(target) {
// this respects which endpoint tab is currently active.
function commitTargetColor(target, hex) {
if (target === 'primary') { themeState.primary = hex; currentColor = hex; return; }
+ if (target === 'sectionTitle') { themeState.sectionTitleColor = hex; return; }
if (activeGradientEndpoint === 'start') themeState.gradientStart = hex;
else themeState.gradientEnd = hex;
}
function setupBrightness(target) {
- const sliderId = target === 'primary' ? 'colorBrightness' : 'gradientBrightness';
- const slider = document.getElementById(sliderId);
+ const ids = WHEEL_IDS[target];
+ const slider = ids && document.getElementById(ids.brightness);
if (!slider) return;
slider.addEventListener('input', () => {
const newL = parseInt(slider.value, 10);
@@ -3903,16 +4002,15 @@ function setupBrightness(target) {
}
function setupHexInput(target) {
- const id = target === 'primary' ? 'colorHexInput' : 'gradientHexInput';
- const input = document.getElementById(id);
+ const ids = WHEEL_IDS[target];
+ const input = ids && document.getElementById(ids.input);
if (!input) return;
input.addEventListener('change', (e) => {
const hex = e.target.value;
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return;
commitTargetColor(target, hex.toLowerCase());
pickerHSL[target] = hexToHSL(hex);
- const sliderId = target === 'primary' ? 'colorBrightness' : 'gradientBrightness';
- const slider = document.getElementById(sliderId);
+ const slider = document.getElementById(ids.brightness);
if (slider) slider.value = pickerHSL[target].l;
drawColorWheel(target);
updateColorPickerUI(target, hex);
@@ -3926,13 +4024,13 @@ function setupPresets(target) {
// match both selectors).
const container = document.getElementById(PRESET_CONTAINER[target]);
if (!container) return;
+ const ids = WHEEL_IDS[target];
container.querySelectorAll('.color-preset').forEach(preset => {
preset.addEventListener('click', () => {
const color = preset.dataset.color;
commitTargetColor(target, color);
pickerHSL[target] = hexToHSL(color);
- const sliderId = target === 'primary' ? 'colorBrightness' : 'gradientBrightness';
- const slider = document.getElementById(sliderId);
+ const slider = ids && document.getElementById(ids.brightness);
if (slider) slider.value = pickerHSL[target].l;
drawColorWheel(target);
updateColorPickerUI(target, color);
@@ -3942,8 +4040,9 @@ function setupPresets(target) {
}
function pickColorAt(target, e) {
- const canvasId = target === 'primary' ? 'colorWheel' : 'gradientWheel';
- const canvas = document.getElementById(canvasId);
+ const ids = WHEEL_IDS[target];
+ if (!ids) return;
+ const canvas = document.getElementById(ids.canvas);
const rect = canvas.getBoundingClientRect();
const x = (e.clientX || e.pageX) - rect.left;
const y = (e.clientY || e.pageY) - rect.top;
@@ -4048,10 +4147,10 @@ function refreshGradientPairActiveState() {
}
function positionCursorFromHex(target, hex) {
- const cursorId = target === 'primary' ? 'colorWheelCursor' : 'gradientWheelCursor';
- const canvasId = target === 'primary' ? 'colorWheel' : 'gradientWheel';
- const cursor = document.getElementById(cursorId);
- const canvas = document.getElementById(canvasId);
+ const ids = WHEEL_IDS[target];
+ if (!ids) return;
+ const cursor = document.getElementById(ids.cursor);
+ const canvas = document.getElementById(ids.canvas);
if (!cursor || !canvas) return;
const cx = canvas.width / 2, cy = canvas.height / 2;
const R = Math.min(cx, cy);
@@ -4068,10 +4167,10 @@ function updateColorPickerUI(target, hex) {
hex = target;
target = 'primary';
}
- const previewId = target === 'primary' ? 'colorPreview' : 'gradientPreview';
- const inputId = target === 'primary' ? 'colorHexInput' : 'gradientHexInput';
- const preview = document.getElementById(previewId);
- const input = document.getElementById(inputId);
+ const ids = WHEEL_IDS[target];
+ if (!ids) return;
+ const preview = document.getElementById(ids.preview);
+ const input = document.getElementById(ids.input);
if (preview) preview.style.backgroundColor = hex;
if (input) input.value = hex.toUpperCase();
const presetContainer = document.getElementById(PRESET_CONTAINER[target]);
@@ -4086,14 +4185,14 @@ function updateColorPickerUI(target, hex) {
function drawColorWheel(target) {
target = target || 'primary';
- const canvasId = target === 'primary' ? 'colorWheel' : 'gradientWheel';
- const sliderId = target === 'primary' ? 'colorBrightness' : 'gradientBrightness';
- const canvas = document.getElementById(canvasId);
+ const ids = WHEEL_IDS[target];
+ if (!ids) return;
+ const canvas = document.getElementById(ids.canvas);
const ctx = wheelCtx[target];
if (!canvas || !ctx) return;
const cx = canvas.width / 2, cy = canvas.height / 2;
const radius = Math.min(cx, cy);
- const brightness = document.getElementById(sliderId)?.value || 50;
+ const brightness = document.getElementById(ids.brightness)?.value || 50;
for (let angle = 0; angle < 360; angle++) {
const startAngle = (angle - 1) * Math.PI / 180;
@@ -4203,6 +4302,8 @@ async function loadTheme() {
themeState.gradientEnd = theme.gradientEnd || null;
themeState.fontFamily = theme.fontFamily || THEME_DEFAULTS.fontFamily;
themeState.bulletStyle = theme.bulletStyle || THEME_DEFAULTS.bulletStyle;
+ themeState.sectionTitleColor = theme.sectionTitleColor || null;
+ themeState.sectionRadius = (typeof theme.sectionRadius === 'number' && Number.isFinite(theme.sectionRadius)) ? theme.sectionRadius : null;
}
try {
const allSetting = await api('/api/settings/applyThemeToAllDatasets');
@@ -4237,6 +4338,32 @@ async function loadTheme() {
renderBulletPicker();
updateBulletTrigger(themeState.bulletStyle);
+ renderSectionTitlePresets();
+ const hasCustomSectionTitle = !!themeState.sectionTitleColor;
+ const useSectionTitleToggle = document.getElementById('themeUseSectionTitleColor');
+ const stWrapper = document.getElementById('sectionTitleColorWrapper');
+ if (useSectionTitleToggle) useSectionTitleToggle.checked = hasCustomSectionTitle;
+ if (stWrapper) stWrapper.style.display = hasCustomSectionTitle ? 'block' : 'none';
+ if (hasCustomSectionTitle) {
+ pickerHSL.sectionTitle = hexToHSL(themeState.sectionTitleColor);
+ const slider = document.getElementById('sectionTitleBrightness');
+ if (slider) slider.value = pickerHSL.sectionTitle.l;
+ drawColorWheel('sectionTitle');
+ updateColorPickerUI('sectionTitle', themeState.sectionTitleColor);
+ }
+
+ const hasCustomSectionRadius = themeState.sectionRadius != null;
+ const useSectionRadiusToggle = document.getElementById('themeUseSectionRadius');
+ const radiusWrapper = document.getElementById('sectionRadiusWrapper');
+ const radiusSlider = document.getElementById('themeSectionRadiusSlider');
+ const radiusLabel = document.getElementById('themeSectionRadiusValue');
+ if (useSectionRadiusToggle) useSectionRadiusToggle.checked = hasCustomSectionRadius;
+ if (radiusWrapper) radiusWrapper.style.display = hasCustomSectionRadius ? 'block' : 'none';
+ if (hasCustomSectionRadius) {
+ if (radiusSlider) radiusSlider.value = themeState.sectionRadius;
+ if (radiusLabel) radiusLabel.textContent = `${themeState.sectionRadius}px`;
+ }
+
const applyAllToggle = document.getElementById('themeApplyToAll');
if (applyAllToggle) applyAllToggle.checked = applyToAllDatasets;
@@ -4244,6 +4371,19 @@ async function loadTheme() {
updateGradientSwatches();
}
+function renderSectionTitlePresets() {
+ const container = document.getElementById('sectionTitlePresets');
+ if (!container || container.dataset.rendered) return;
+ container.innerHTML = SECTION_TITLE_PRESETS.map(p => `
+
+ `).join('');
+ container.dataset.rendered = '1';
+ // Re-wire preset clicks now that the DOM exists.
+ setupPresets('sectionTitle');
+ // Re-run i18n on these newly-inserted nodes if I18n is present.
+ if (typeof I18n !== 'undefined' && I18n.refreshUI) I18n.refreshUI();
+}
+
function toggleColorPicker() {
const dropdown = document.getElementById('colorPickerDropdown');
document.getElementById('languagePickerDropdown').classList.remove('active');
@@ -4269,6 +4409,8 @@ async function applyThemeColor() {
gradientEnd: themeState.gradientEnd,
fontFamily: themeState.fontFamily,
bulletStyle: themeState.bulletStyle,
+ sectionTitleColor: themeState.sectionTitleColor,
+ sectionRadius: themeState.sectionRadius,
applyToAll: applyToAllDatasets,
currentDatasetId: (typeof activeDatasetId !== 'undefined') ? activeDatasetId : null
}});
@@ -4291,6 +4433,8 @@ async function resetThemeColor() {
gradientEnd: null,
fontFamily: 'Inter',
bulletStyle: 'triangle',
+ sectionTitleColor: null,
+ sectionRadius: null,
applyToAll: applyToAllDatasets,
currentDatasetId: (typeof activeDatasetId !== 'undefined') ? activeDatasetId : null
}});
@@ -4305,6 +4449,14 @@ async function resetThemeColor() {
if (useGradientToggle) useGradientToggle.checked = false;
const gradWrapper = document.getElementById('gradientPickerWrapper');
if (gradWrapper) gradWrapper.style.display = 'none';
+ const useSectionTitleToggle = document.getElementById('themeUseSectionTitleColor');
+ if (useSectionTitleToggle) useSectionTitleToggle.checked = false;
+ const stWrapper = document.getElementById('sectionTitleColorWrapper');
+ if (stWrapper) stWrapper.style.display = 'none';
+ const useSectionRadiusToggle = document.getElementById('themeUseSectionRadius');
+ if (useSectionRadiusToggle) useSectionRadiusToggle.checked = false;
+ const radiusWrapper = document.getElementById('sectionRadiusWrapper');
+ if (radiusWrapper) radiusWrapper.style.display = 'none';
updateFontTrigger('Inter');
updateBulletTrigger('triangle');
document.getElementById('colorPickerDropdown').classList.remove('active');
@@ -4317,9 +4469,7 @@ function applyThemeToCSS(theme) {
const hsl = hexToHSL(hex);
root.style.setProperty('--primary', hex);
- root.style.setProperty('--primary-dark', hslToHex(hsl.h, hsl.s, Math.max(hsl.l - 15, 10)));
root.style.setProperty('--primary-light', hslToHex(hsl.h, Math.min(hsl.s + 10, 100), Math.min(hsl.l + 15, 80)));
- root.style.setProperty('--accent', hslToHex((hsl.h + 15) % 360, hsl.s, hsl.l));
root.style.setProperty('--dark', hslToHex(hsl.h, hsl.s, 15));
root.style.setProperty('--light', hslToHex(hsl.h, 30, 90));
root.style.setProperty('--very-light', hslToHex(hsl.h, 20, 97));
@@ -4342,6 +4492,22 @@ function applyThemeToCSS(theme) {
root.style.setProperty('--header-gradient-end', hslToHex(hsl.h, hsl.s, 15));
}
+ // When a custom gradient is active, both --primary-dark (drives item
+ // titles, cert names, custom-section item titles) and --accent (timeline
+ // branch strokes, item-card highlight pulse) follow the user's chosen
+ // gradient end. The semantic name "primary-dark" stays meaningful — the
+ // gradient end is typically the darker of the two endpoints (see the
+ // built-in pair presets) and reads better as body-text color than the
+ // start would. When no custom gradient is set, both variables continue
+ // to auto-derive from primary so existing themes look unchanged.
+ if (hasCustomGradient) {
+ root.style.setProperty('--primary-dark', gradientEnd);
+ root.style.setProperty('--accent', gradientEnd);
+ } else {
+ root.style.setProperty('--primary-dark', hslToHex(hsl.h, hsl.s, Math.max(hsl.l - 15, 10)));
+ root.style.setProperty('--accent', hslToHex((hsl.h + 15) % 360, hsl.s, hsl.l));
+ }
+
const family = (theme && theme.fontFamily) || 'Inter';
root.style.setProperty('--font-family', `'${family}', var(--font-family-default)`);
@@ -4355,6 +4521,32 @@ function applyThemeToCSS(theme) {
const style = BULLET_STYLES.find(s => s.id === bullet);
document.body.dataset.bulletKind = (style && style.icon) ? 'icon' : 'glyph';
}
+
+ // Section titles default to --primary via CSS fallback. When the user
+ // picks a custom color we set --section-title-color; otherwise we remove
+ // the variable so the fallback kicks in and section titles track primary.
+ const titleColor = theme && theme.sectionTitleColor;
+ if (titleColor && /^#[0-9a-fA-F]{6}$/.test(titleColor)) {
+ root.style.setProperty('--section-title-color', titleColor);
+ } else {
+ root.style.removeProperty('--section-title-color');
+ }
+
+ // Section box corner radius. When set, overrides the default --radius-lg
+ // via the `--section-radius` CSS variable; otherwise the fallback in
+ // styles.css (`var(--section-radius, var(--radius-lg))`) kicks in.
+ // The header corner radius scales in proportion (1.5× — matches the
+ // baseline 24px header : 16px section ratio) via `--header-radius`, so
+ // the top card and section boxes stay visually consistent.
+ const radius = theme && theme.sectionRadius;
+ if (typeof radius === 'number' && Number.isFinite(radius)) {
+ const clamped = Math.max(0, Math.min(64, Math.round(radius)));
+ root.style.setProperty('--section-radius', `${clamped}px`);
+ root.style.setProperty('--header-radius', `${Math.round(clamped * 1.5)}px`);
+ } else {
+ root.style.removeProperty('--section-radius');
+ root.style.removeProperty('--header-radius');
+ }
}
// Backward-compat alias for any caller that still hands us a single hex
diff --git a/public/shared/i18n/de.json b/public/shared/i18n/de.json
index ca2697d3..d552c0ce 100644
--- a/public/shared/i18n/de.json
+++ b/public/shared/i18n/de.json
@@ -37,6 +37,21 @@
"theme.gradient_end": "Ende",
"theme.apply_to_all": "Auf alle gespeicherten Datensätze anwenden",
"theme.apply_to_all_desc": "Wenn aktiviert, gilt die Designänderung für jeden gespeicherten Datensatz.",
+ "theme.use_section_title_color": "Eigene Farbe für Abschnittstitel",
+ "theme.title_color.black": "Schwarz",
+ "theme.title_color.charcoal": "Anthrazit",
+ "theme.title_color.slate": "Schiefer",
+ "theme.title_color.gray": "Grau",
+ "theme.title_color.navy": "Marineblau",
+ "theme.title_color.dark_blue": "Dunkelblau",
+ "theme.title_color.midnight": "Mitternacht",
+ "theme.title_color.dark_green": "Dunkelgrün",
+ "theme.title_color.dark_red": "Dunkelrot",
+ "theme.title_color.dark_purple": "Dunkelviolett",
+ "theme.title_color.brown": "Braun",
+ "theme.title_color.steel": "Stahl",
+ "theme.use_section_radius": "Eigener Eckenradius für Abschnitte",
+ "theme.section_radius": "Eckenradius",
"banner.update": "Update verfügbar: v{{latest}} (Sie verwenden v{{current}})",
"banner.whats_new": "Was ist neu",
"banner.dismiss": "Ausblenden",
diff --git a/public/shared/i18n/en.json b/public/shared/i18n/en.json
index c8c9c322..62c0bb1d 100644
--- a/public/shared/i18n/en.json
+++ b/public/shared/i18n/en.json
@@ -38,6 +38,21 @@
"theme.gradient_end": "End",
"theme.apply_to_all": "Apply to all saved datasets",
"theme.apply_to_all_desc": "When enabled, changing the theme applies to every saved dataset.",
+ "theme.use_section_title_color": "Custom section title color",
+ "theme.title_color.black": "Black",
+ "theme.title_color.charcoal": "Charcoal",
+ "theme.title_color.slate": "Slate",
+ "theme.title_color.gray": "Gray",
+ "theme.title_color.navy": "Navy",
+ "theme.title_color.dark_blue": "Dark Blue",
+ "theme.title_color.midnight": "Midnight",
+ "theme.title_color.dark_green": "Dark Green",
+ "theme.title_color.dark_red": "Dark Red",
+ "theme.title_color.dark_purple": "Dark Purple",
+ "theme.title_color.brown": "Brown",
+ "theme.title_color.steel": "Steel",
+ "theme.use_section_radius": "Custom section corner radius",
+ "theme.section_radius": "Corner radius",
"banner.update": "Update available: v{{latest}} (you're on v{{current}})",
"banner.whats_new": "What's new",
"banner.dismiss": "Dismiss",
diff --git a/public/shared/i18n/es.json b/public/shared/i18n/es.json
index 9beddb32..8c30fb46 100644
--- a/public/shared/i18n/es.json
+++ b/public/shared/i18n/es.json
@@ -37,6 +37,21 @@
"theme.gradient_end": "Fin",
"theme.apply_to_all": "Aplicar a todos los conjuntos de datos guardados",
"theme.apply_to_all_desc": "Cuando está activado, el cambio de tema se aplica a cada conjunto de datos guardado.",
+ "theme.use_section_title_color": "Color personalizado para títulos de sección",
+ "theme.title_color.black": "Negro",
+ "theme.title_color.charcoal": "Carbón",
+ "theme.title_color.slate": "Pizarra",
+ "theme.title_color.gray": "Gris",
+ "theme.title_color.navy": "Azul marino",
+ "theme.title_color.dark_blue": "Azul oscuro",
+ "theme.title_color.midnight": "Medianoche",
+ "theme.title_color.dark_green": "Verde oscuro",
+ "theme.title_color.dark_red": "Rojo oscuro",
+ "theme.title_color.dark_purple": "Púrpura oscuro",
+ "theme.title_color.brown": "Marrón",
+ "theme.title_color.steel": "Acero",
+ "theme.use_section_radius": "Radio de esquina personalizado para secciones",
+ "theme.section_radius": "Radio de esquina",
"banner.update": "Actualización disponible: v{{latest}} (estás en v{{current}})",
"banner.whats_new": "Novedades",
"banner.dismiss": "Descartar",
diff --git a/public/shared/i18n/fr.json b/public/shared/i18n/fr.json
index 5f59185b..429614c0 100644
--- a/public/shared/i18n/fr.json
+++ b/public/shared/i18n/fr.json
@@ -37,6 +37,21 @@
"theme.gradient_end": "Fin",
"theme.apply_to_all": "Appliquer à tous les jeux de données enregistrés",
"theme.apply_to_all_desc": "Lorsque cette option est activée, la modification du thème s'applique à chaque jeu de données enregistré.",
+ "theme.use_section_title_color": "Couleur personnalisée des titres de section",
+ "theme.title_color.black": "Noir",
+ "theme.title_color.charcoal": "Anthracite",
+ "theme.title_color.slate": "Ardoise",
+ "theme.title_color.gray": "Gris",
+ "theme.title_color.navy": "Marine",
+ "theme.title_color.dark_blue": "Bleu foncé",
+ "theme.title_color.midnight": "Minuit",
+ "theme.title_color.dark_green": "Vert foncé",
+ "theme.title_color.dark_red": "Rouge foncé",
+ "theme.title_color.dark_purple": "Violet foncé",
+ "theme.title_color.brown": "Marron",
+ "theme.title_color.steel": "Acier",
+ "theme.use_section_radius": "Rayon d'angle personnalisé pour les sections",
+ "theme.section_radius": "Rayon d'angle",
"banner.update": "Mise à jour disponible : v{{latest}} (vous utilisez la v{{current}})",
"banner.whats_new": "Nouveautés",
"banner.dismiss": "Fermer",
diff --git a/public/shared/i18n/it.json b/public/shared/i18n/it.json
index 850fadbf..22923302 100644
--- a/public/shared/i18n/it.json
+++ b/public/shared/i18n/it.json
@@ -37,6 +37,21 @@
"theme.gradient_end": "Fine",
"theme.apply_to_all": "Applica a tutti i dataset salvati",
"theme.apply_to_all_desc": "Se attivo, la modifica del tema si applica a ogni dataset salvato.",
+ "theme.use_section_title_color": "Colore personalizzato per i titoli di sezione",
+ "theme.title_color.black": "Nero",
+ "theme.title_color.charcoal": "Antracite",
+ "theme.title_color.slate": "Ardesia",
+ "theme.title_color.gray": "Grigio",
+ "theme.title_color.navy": "Blu marino",
+ "theme.title_color.dark_blue": "Blu scuro",
+ "theme.title_color.midnight": "Mezzanotte",
+ "theme.title_color.dark_green": "Verde scuro",
+ "theme.title_color.dark_red": "Rosso scuro",
+ "theme.title_color.dark_purple": "Viola scuro",
+ "theme.title_color.brown": "Marrone",
+ "theme.title_color.steel": "Acciaio",
+ "theme.use_section_radius": "Raggio degli angoli personalizzato per le sezioni",
+ "theme.section_radius": "Raggio degli angoli",
"banner.update": "Aggiornamento disponibile: v{{latest}} (versione attuale v{{current}})",
"banner.whats_new": "Novità",
"banner.dismiss": "Ignora",
diff --git a/public/shared/i18n/nl.json b/public/shared/i18n/nl.json
index 8ffa345d..a7d219c8 100644
--- a/public/shared/i18n/nl.json
+++ b/public/shared/i18n/nl.json
@@ -37,6 +37,21 @@
"theme.gradient_end": "Einde",
"theme.apply_to_all": "Toepassen op alle opgeslagen datasets",
"theme.apply_to_all_desc": "Indien ingeschakeld wordt het thema toegepast op elke opgeslagen dataset.",
+ "theme.use_section_title_color": "Aangepaste kleur voor sectietitels",
+ "theme.title_color.black": "Zwart",
+ "theme.title_color.charcoal": "Antraciet",
+ "theme.title_color.slate": "Leisteen",
+ "theme.title_color.gray": "Grijs",
+ "theme.title_color.navy": "Marineblauw",
+ "theme.title_color.dark_blue": "Donkerblauw",
+ "theme.title_color.midnight": "Middernacht",
+ "theme.title_color.dark_green": "Donkergroen",
+ "theme.title_color.dark_red": "Donkerrood",
+ "theme.title_color.dark_purple": "Donkerpaars",
+ "theme.title_color.brown": "Bruin",
+ "theme.title_color.steel": "Staal",
+ "theme.use_section_radius": "Aangepaste hoekafronding voor secties",
+ "theme.section_radius": "Hoekafronding",
"banner.update": "Update beschikbaar: v{{latest}} (je gebruikt v{{current}})",
"banner.whats_new": "Wat is nieuw",
"banner.dismiss": "Sluiten",
diff --git a/public/shared/i18n/pt.json b/public/shared/i18n/pt.json
index 63311a4e..736c47bb 100644
--- a/public/shared/i18n/pt.json
+++ b/public/shared/i18n/pt.json
@@ -37,6 +37,21 @@
"theme.gradient_end": "Fim",
"theme.apply_to_all": "Aplicar a todos os conjuntos de dados guardados",
"theme.apply_to_all_desc": "Quando ativado, a alteração do tema aplica-se a cada conjunto de dados guardado.",
+ "theme.use_section_title_color": "Cor personalizada para títulos de secção",
+ "theme.title_color.black": "Preto",
+ "theme.title_color.charcoal": "Antracite",
+ "theme.title_color.slate": "Ardósia",
+ "theme.title_color.gray": "Cinzento",
+ "theme.title_color.navy": "Azul-marinho",
+ "theme.title_color.dark_blue": "Azul escuro",
+ "theme.title_color.midnight": "Meia-noite",
+ "theme.title_color.dark_green": "Verde escuro",
+ "theme.title_color.dark_red": "Vermelho escuro",
+ "theme.title_color.dark_purple": "Roxo escuro",
+ "theme.title_color.brown": "Castanho",
+ "theme.title_color.steel": "Aço",
+ "theme.use_section_radius": "Raio dos cantos personalizado para secções",
+ "theme.section_radius": "Raio dos cantos",
"banner.update": "Atualização disponível: v{{latest}} (está na v{{current}})",
"banner.whats_new": "Novidades",
"banner.dismiss": "Dispensar",
diff --git a/public/shared/i18n/zh.json b/public/shared/i18n/zh.json
index bd087aad..40baa83f 100644
--- a/public/shared/i18n/zh.json
+++ b/public/shared/i18n/zh.json
@@ -37,6 +37,21 @@
"theme.gradient_end": "终点",
"theme.apply_to_all": "应用到所有保存的数据集",
"theme.apply_to_all_desc": "启用后,主题更改将应用于每个保存的数据集。",
+ "theme.use_section_title_color": "自定义章节标题颜色",
+ "theme.title_color.black": "黑色",
+ "theme.title_color.charcoal": "炭黑",
+ "theme.title_color.slate": "石板灰",
+ "theme.title_color.gray": "灰色",
+ "theme.title_color.navy": "海军蓝",
+ "theme.title_color.dark_blue": "深蓝",
+ "theme.title_color.midnight": "午夜蓝",
+ "theme.title_color.dark_green": "深绿",
+ "theme.title_color.dark_red": "深红",
+ "theme.title_color.dark_purple": "深紫",
+ "theme.title_color.brown": "棕色",
+ "theme.title_color.steel": "钢灰",
+ "theme.use_section_radius": "自定义章节圆角",
+ "theme.section_radius": "圆角大小",
"banner.update": "有新版本可用:v{{latest}}(当前版本 v{{current}})",
"banner.whats_new": "更新内容",
"banner.dismiss": "忽略",
diff --git a/public/shared/styles.css b/public/shared/styles.css
index 07f010e2..2574e095 100644
--- a/public/shared/styles.css
+++ b/public/shared/styles.css
@@ -63,7 +63,10 @@ body {
/* Header Section */
.header {
background: linear-gradient(135deg, var(--header-gradient-start), var(--header-gradient-end));
- border-radius: var(--radius-xl);
+ /* Scales in proportion to --section-radius (1.5× ratio, matching the
+ baseline 24px : 16px relationship). When no custom radius is set, the
+ fallback keeps the pre-1.41 look. */
+ border-radius: var(--header-radius, var(--radius-xl));
padding: 28px;
margin-bottom: 20px;
box-shadow: var(--shadow-lg);
@@ -187,7 +190,9 @@ body {
/* Sections */
.section {
background: var(--white);
- border-radius: var(--radius-lg);
+ /* Theme picker can override the corner radius via `--section-radius`;
+ fallback keeps the pre-1.41 look. Applies to built-in and custom sections. */
+ border-radius: var(--section-radius, var(--radius-lg));
padding: 24px;
margin-bottom: 16px;
box-shadow: var(--shadow-md);
@@ -296,7 +301,9 @@ body {
.section-title {
font-size: 20px;
font-weight: 700;
- color: var(--primary);
+ /* Fallback to --primary so themes that don't set a custom heading color
+ keep the pre-1.40 behavior where section titles match the accent. */
+ color: var(--section-title-color, var(--primary));
}
/* About */
@@ -1786,11 +1793,14 @@ body.has-preview-banner .container {
.contact-badge { font-size: 9px; padding: 2px 6px; }
/* Sections - prevent unnecessary page breaks, except for experience which can be long */
- .section {
- box-shadow: none;
+ .section {
+ box-shadow: none;
margin-bottom: 10px;
padding: 14px;
- border-radius: var(--radius-md);
+ /* Honor the theme's custom radius in print as well so the printed PDF
+ matches the on-screen look; otherwise fall back to the tighter
+ print default. */
+ border-radius: var(--section-radius, var(--radius-md));
page-break-inside: avoid;
break-inside: avoid;
}
diff --git a/src/server.js b/src/server.js
index eaa68308..5423a8f8 100644
--- a/src/server.js
+++ b/src/server.js
@@ -1258,15 +1258,27 @@ const ALLOWED_BULLET_STYLES = new Set([
'local_fire_department','edit_note'
]);
+// Bounds for the section-box corner-radius theme field. Must stay in sync
+// with SECTION_RADIUS_MIN/MAX in public/shared/admin.js. Values outside the
+// range are rejected by PUT /api/theme; null = use default.
+const SECTION_RADIUS_MIN = 0;
+const SECTION_RADIUS_MAX = 32;
+
function gatherTheme() {
const get = (key) => db.prepare('SELECT value FROM settings WHERE key = ?').get(key)?.value || null;
const storedBullet = get('themeBulletStyle');
+ const storedSectionTitle = get('themeSectionTitleColor');
+ const storedRadius = get('themeSectionRadius');
+ const parsedRadius = storedRadius == null ? null : parseInt(storedRadius, 10);
+ const sectionRadius = (Number.isFinite(parsedRadius) && parsedRadius >= SECTION_RADIUS_MIN && parsedRadius <= SECTION_RADIUS_MAX) ? parsedRadius : null;
return {
primary: get('themeColor') || '#0066ff',
gradientStart: get('themeGradientStart') || null,
gradientEnd: get('themeGradientEnd') || null,
fontFamily: get('themeFontFamily') || 'Inter',
- bulletStyle: (storedBullet && ALLOWED_BULLET_STYLES.has(storedBullet)) ? storedBullet : 'triangle'
+ bulletStyle: (storedBullet && ALLOWED_BULLET_STYLES.has(storedBullet)) ? storedBullet : 'triangle',
+ sectionTitleColor: (storedSectionTitle && /^#[0-9a-fA-F]{6}$/.test(storedSectionTitle)) ? storedSectionTitle : null,
+ sectionRadius
};
}
@@ -2037,13 +2049,23 @@ if (PUBLIC_ONLY) {
try { res.json(gatherTheme()); } catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/theme', (req, res) => {
- const { primary, gradientStart, gradientEnd, fontFamily, bulletStyle, applyToAll, currentDatasetId } = req.body || {};
+ const { primary, gradientStart, gradientEnd, fontFamily, bulletStyle, sectionTitleColor, sectionRadius, applyToAll, currentDatasetId } = req.body || {};
if (!primary || !/^#[0-9a-fA-F]{6}$/.test(primary)) return res.status(400).json({ error: 'Invalid primary color' });
if (gradientStart !== null && gradientStart !== undefined && !/^#[0-9a-fA-F]{6}$/.test(gradientStart)) return res.status(400).json({ error: 'Invalid gradient start color' });
if (gradientEnd !== null && gradientEnd !== undefined && !/^#[0-9a-fA-F]{6}$/.test(gradientEnd)) return res.status(400).json({ error: 'Invalid gradient end color' });
if (bulletStyle !== null && bulletStyle !== undefined && !ALLOWED_BULLET_STYLES.has(bulletStyle)) return res.status(400).json({ error: 'Invalid bullet style' });
+ if (sectionTitleColor !== null && sectionTitleColor !== undefined && sectionTitleColor !== '' && !/^#[0-9a-fA-F]{6}$/.test(sectionTitleColor)) return res.status(400).json({ error: 'Invalid section title color' });
+ if (sectionRadius !== null && sectionRadius !== undefined) {
+ const n = typeof sectionRadius === 'number' ? sectionRadius : parseInt(sectionRadius, 10);
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < SECTION_RADIUS_MIN || n > SECTION_RADIUS_MAX) {
+ return res.status(400).json({ error: 'Invalid section radius' });
+ }
+ }
const font = typeof fontFamily === 'string' && fontFamily.trim() ? fontFamily.trim() : 'Inter';
const bullet = (bulletStyle && ALLOWED_BULLET_STYLES.has(bulletStyle)) ? bulletStyle : 'triangle';
+ const sectionTitle = (sectionTitleColor && /^#[0-9a-fA-F]{6}$/.test(sectionTitleColor)) ? sectionTitleColor : null;
+ const radiusNum = (sectionRadius === null || sectionRadius === undefined) ? null : (typeof sectionRadius === 'number' ? sectionRadius : parseInt(sectionRadius, 10));
+ const radius = (Number.isInteger(radiusNum) && radiusNum >= SECTION_RADIUS_MIN && radiusNum <= SECTION_RADIUS_MAX) ? radiusNum : null;
const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
try {
const writeAll = db.transaction(() => {
@@ -2054,8 +2076,12 @@ if (PUBLIC_ONLY) {
else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientStart');
if (gradientEnd) upsert.run('themeGradientEnd', gradientEnd);
else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientEnd');
+ if (sectionTitle) upsert.run('themeSectionTitleColor', sectionTitle);
+ else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionTitleColor');
+ if (radius !== null) upsert.run('themeSectionRadius', String(radius));
+ else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionRadius');
upsert.run('applyThemeToAllDatasets', applyToAll ? 'true' : 'false');
- const themeBlob = { primary, gradientStart: gradientStart || null, gradientEnd: gradientEnd || null, fontFamily: font, bulletStyle: bullet };
+ const themeBlob = { primary, gradientStart: gradientStart || null, gradientEnd: gradientEnd || null, fontFamily: font, bulletStyle: bullet, sectionTitleColor: sectionTitle, sectionRadius: radius };
const update = db.prepare('UPDATE saved_datasets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
const writeTheme = (id, dataStr) => {
try {
@@ -2648,7 +2674,7 @@ if (PUBLIC_ONLY) {
res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, is_default: !!dataset.is_default, is_public: !!dataset.is_public });
} catch (err) { res.status(500).json({ error: err.message }); }
});
- app.post('/api/datasets/:id/load', (req, res) => { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); try { const data = JSON.parse(dataset.data); const importData = db.transaction(() => { if (data.theme && typeof data.theme === 'object') { const t = data.theme; const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); if (t.primary && /^#[0-9a-fA-F]{6}$/.test(t.primary)) upsert.run('themeColor', t.primary); if (t.fontFamily) upsert.run('themeFontFamily', t.fontFamily); if (t.bulletStyle && ALLOWED_BULLET_STYLES.has(t.bulletStyle)) upsert.run('themeBulletStyle', t.bulletStyle); if (t.gradientStart && /^#[0-9a-fA-F]{6}$/.test(t.gradientStart)) upsert.run('themeGradientStart', t.gradientStart); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientStart'); if (t.gradientEnd && /^#[0-9a-fA-F]{6}$/.test(t.gradientEnd)) upsert.run('themeGradientEnd', t.gradientEnd); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientEnd'); } if (data.profile) { const p = data.profile; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, profile_picture_enabled = ?, picture_filename = ?, picture_propagate = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages, p.profile_picture_enabled == null ? 1 : (p.profile_picture_enabled ? 1 : 0), p.picture_filename || null, p.picture_propagate == null ? 1 : (p.picture_propagate ? 1 : 0)); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, s.key); }); } else if (data.sectionVisibility) { for (const [section, visible] of Object.entries(data.sectionVisibility)) { db.prepare('UPDATE section_visibility SET visible = ? WHERE section_name = ?').run(visible ? 1 : 0, section); } } }); importData(); res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, is_default: !!dataset.is_default, is_public: !!dataset.is_public, theme: data.theme || null }); } catch (err) { res.status(500).json({ error: err.message }); } });
+ app.post('/api/datasets/:id/load', (req, res) => { const dataset = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id); if (!dataset) return res.status(404).json({ error: 'Dataset not found' }); try { const data = JSON.parse(dataset.data); const importData = db.transaction(() => { if (data.theme && typeof data.theme === 'object') { const t = data.theme; const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); if (t.primary && /^#[0-9a-fA-F]{6}$/.test(t.primary)) upsert.run('themeColor', t.primary); if (t.fontFamily) upsert.run('themeFontFamily', t.fontFamily); if (t.bulletStyle && ALLOWED_BULLET_STYLES.has(t.bulletStyle)) upsert.run('themeBulletStyle', t.bulletStyle); if (t.gradientStart && /^#[0-9a-fA-F]{6}$/.test(t.gradientStart)) upsert.run('themeGradientStart', t.gradientStart); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientStart'); if (t.gradientEnd && /^#[0-9a-fA-F]{6}$/.test(t.gradientEnd)) upsert.run('themeGradientEnd', t.gradientEnd); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeGradientEnd'); if (t.sectionTitleColor && /^#[0-9a-fA-F]{6}$/.test(t.sectionTitleColor)) upsert.run('themeSectionTitleColor', t.sectionTitleColor); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionTitleColor'); if (Number.isInteger(t.sectionRadius) && t.sectionRadius >= SECTION_RADIUS_MIN && t.sectionRadius <= SECTION_RADIUS_MAX) upsert.run('themeSectionRadius', String(t.sectionRadius)); else db.prepare('DELETE FROM settings WHERE key = ?').run('themeSectionRadius'); } if (data.profile) { const p = data.profile; db.prepare(`UPDATE profile SET name = ?, initials = ?, title = ?, subtitle = ?, bio = ?, location = ?, linkedin = ?, email = ?, phone = ?, languages = ?, profile_picture_enabled = ?, picture_filename = ?, picture_propagate = ? WHERE id = 1`).run(p.name, p.initials, p.title, p.subtitle, p.bio, p.location, p.linkedin, p.email, p.phone, p.languages, p.profile_picture_enabled == null ? 1 : (p.profile_picture_enabled ? 1 : 0), p.picture_filename || null, p.picture_propagate == null ? 1 : (p.picture_propagate ? 1 : 0)); } if (data.experiences) { db.prepare('DELETE FROM experiences').run(); const stmt = db.prepare(`INSERT INTO experiences (job_title, company_name, start_date, end_date, location, country_code, highlights, summary, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.experiences.forEach((e, idx) => { stmt.run(e.job_title, e.company_name, e.start_date, e.end_date, e.location, e.country_code || '', JSON.stringify(e.highlights || []), e.summary || null, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.certifications) { db.prepare('DELETE FROM certifications').run(); const stmt = db.prepare(`INSERT INTO certifications (name, provider, issue_date, expiry_date, credential_id, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.certifications.forEach((c, idx) => { stmt.run(c.name, c.provider, c.issue_date, c.expiry_date, c.credential_id, idx, c.visible != false ? 1 : 0, c.logo_filename || null, c.logo_propagate ? 1 : 0); }); } if (data.education) { db.prepare('DELETE FROM education').run(); const stmt = db.prepare(`INSERT INTO education (degree_title, institution_name, start_date, end_date, description, sort_order, visible, logo_filename, logo_propagate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.education.forEach((e, idx) => { stmt.run(e.degree_title, e.institution_name, e.start_date, e.end_date, e.description, idx, e.visible != false ? 1 : 0, e.logo_filename || null, e.logo_propagate ? 1 : 0); }); } if (data.skills) { db.prepare('DELETE FROM skills').run(); db.prepare('DELETE FROM skill_categories').run(); const catStmt = db.prepare('INSERT INTO skill_categories (name, icon, sort_order, visible) VALUES (?, ?, ?, ?)'); const skillStmt = db.prepare('INSERT INTO skills (category_id, name, sort_order) VALUES (?, ?, ?)'); data.skills.forEach((cat, catIdx) => { const result = catStmt.run(cat.name, cat.icon || 'default', catIdx, cat.visible != false ? 1 : 0); const categoryId = result.lastInsertRowid; if (cat.skills) { cat.skills.forEach((skill, skillIdx) => { skillStmt.run(categoryId, skill, skillIdx); }); } }); } if (data.projects) { db.prepare('DELETE FROM projects').run(); const stmt = db.prepare(`INSERT INTO projects (title, description, technologies, link, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?)`); data.projects.forEach((p, idx) => { stmt.run(p.title, p.description, JSON.stringify(p.technologies || []), p.link, idx, p.visible != false ? 1 : 0); }); } if (data.customSections && Array.isArray(data.customSections)) { db.prepare('DELETE FROM custom_section_items').run(); db.prepare('DELETE FROM custom_sections').run(); db.prepare("DELETE FROM section_visibility WHERE section_name LIKE 'custom_%'").run(); const sectionStmt = db.prepare(`INSERT INTO custom_sections (name, section_key, layout_type, icon, sort_order, visible, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`); const itemStmt = db.prepare(`INSERT INTO custom_section_items (section_id, title, subtitle, description, link, icon, image, metadata, sort_order, visible) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); data.customSections.forEach((s, idx) => { const sectionKey = s.section_key || `custom_${Date.now()}_${idx}`; const sectionMetadata = s.metadata ? (typeof s.metadata === 'string' ? s.metadata : JSON.stringify(s.metadata)) : null; const result = sectionStmt.run(s.name, sectionKey, s.layout_type || 'grid-3', s.icon || 'layers', s.sort_order !== undefined ? s.sort_order : idx, s.visible != false ? 1 : 0, sectionMetadata); const sectionId = result.lastInsertRowid; db.prepare('INSERT OR REPLACE INTO section_visibility (section_name, visible, sort_order, display_name) VALUES (?, ?, ?, ?)').run(sectionKey, s.visible != false ? 1 : 0, s.sort_order !== undefined ? s.sort_order : idx, s.display_name || null); if (s.items && Array.isArray(s.items)) { s.items.forEach((item, itemIdx) => { itemStmt.run(sectionId, item.title || null, item.subtitle || null, item.description || null, item.link || null, item.icon || null, item.image || null, item.metadata ? (typeof item.metadata === 'string' ? item.metadata : JSON.stringify(item.metadata)) : null, item.sort_order !== undefined ? item.sort_order : itemIdx, item.visible != false ? 1 : 0); }); } }); } if (data.sectionOrder && Array.isArray(data.sectionOrder)) { data.sectionOrder.forEach(s => { db.prepare('UPDATE section_visibility SET visible = ?, sort_order = ?, display_name = ? WHERE section_name = ?').run(s.visible != false ? 1 : 0, s.sort_order || 0, s.display_name || null, s.key); }); } else if (data.sectionVisibility) { for (const [section, visible] of Object.entries(data.sectionVisibility)) { db.prepare('UPDATE section_visibility SET visible = ? WHERE section_name = ?').run(visible ? 1 : 0, section); } } }); importData(); res.json({ success: true, id: dataset.id, name: dataset.name, language: dataset.language || 'en', language_group: dataset.language_group, version_group: dataset.version_group, version: dataset.version || 1, is_default: !!dataset.is_default, is_public: !!dataset.is_public, theme: data.theme || null }); } catch (err) { res.status(500).json({ error: err.message }); } });
app.delete('/api/datasets/:id', (req, res) => {
try {
const ds = db.prepare('SELECT * FROM saved_datasets WHERE id = ?').get(req.params.id);
diff --git a/tests/backend.test.js b/tests/backend.test.js
index 5e96ca53..23f3f8a2 100644
--- a/tests/backend.test.js
+++ b/tests/backend.test.js
@@ -1618,6 +1618,132 @@ describe('Backend API', () => {
assert.strictEqual(theme.bulletStyle, 'triangle');
});
+ it('PUT /api/theme rejects invalid sectionTitleColor', async () => {
+ const r = await fetch(`${BASE_URL}/api/theme`, {
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ primary: '#0066ff', sectionTitleColor: 'not-a-color' })
+ });
+ assert.strictEqual(r.status, 400);
+ });
+
+ it('PUT /api/theme persists sectionTitleColor into settings and every dataset when applyToAll=true', async () => {
+ const a = await createDs('Section Title Bulk A');
+ const b = await createDs('Section Title Bulk B');
+ const result = await putJson(`${BASE_URL}/api/theme`, {
+ primary: '#0066ff', sectionTitleColor: '#001a4d', applyToAll: true
+ });
+ assert.strictEqual(result.success, true);
+ const settingsTheme = await getJson(`${BASE_URL}/api/theme`);
+ assert.strictEqual(settingsTheme.sectionTitleColor, '#001a4d');
+ const aData = await getJson(`${BASE_URL}/api/datasets/id/${a.id}`);
+ const bData = await getJson(`${BASE_URL}/api/datasets/id/${b.id}`);
+ assert.strictEqual(aData.theme.sectionTitleColor, '#001a4d');
+ assert.strictEqual(bData.theme.sectionTitleColor, '#001a4d');
+ await delDs(a.id); await delDs(b.id);
+ });
+
+ it('PUT /api/theme with applyToAll=false only updates currentDatasetId sectionTitleColor', async () => {
+ const a = await createDs('Section Title Solo A');
+ const b = await createDs('Section Title Solo B');
+ await putJson(`${BASE_URL}/api/theme`, { primary: '#0066ff', sectionTitleColor: '#111827', applyToAll: true });
+ await putJson(`${BASE_URL}/api/theme`, { primary: '#0066ff', sectionTitleColor: '#000000', applyToAll: false, currentDatasetId: a.id });
+ const aData = await getJson(`${BASE_URL}/api/datasets/id/${a.id}`);
+ const bData = await getJson(`${BASE_URL}/api/datasets/id/${b.id}`);
+ assert.strictEqual(aData.theme.sectionTitleColor, '#000000', 'A picks up the new sectionTitleColor');
+ assert.strictEqual(bData.theme.sectionTitleColor, '#111827', 'B retains the prior bulk-applied sectionTitleColor');
+ await delDs(a.id); await delDs(b.id);
+ });
+
+ it('PUT /api/theme with applyToAll=false propagates sectionTitleColor to language siblings', async () => {
+ const en = await createDs('Section Title Sibling Group');
+ const frRes = await fetch(`${BASE_URL}/api/datasets`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'Section Title Sibling Group', language: 'fr', language_group: en.language_group })
+ });
+ const fr = await frRes.json();
+ await putJson(`${BASE_URL}/api/theme`, {
+ primary: '#0066ff', sectionTitleColor: '#1e3a8a', applyToAll: false, currentDatasetId: en.id
+ });
+ const enData = await getJson(`${BASE_URL}/api/datasets/id/${en.id}`);
+ const frData = await getJson(`${BASE_URL}/api/datasets/id/${fr.id}`);
+ assert.strictEqual(enData.theme.sectionTitleColor, '#1e3a8a');
+ assert.strictEqual(frData.theme.sectionTitleColor, '#1e3a8a', 'sibling FR variant inherits sectionTitleColor');
+ await delDs(en.id); await delDs(fr.id);
+ });
+
+ it('PUT /api/theme clears sectionTitleColor when set to null', async () => {
+ await putJson(`${BASE_URL}/api/theme`, { primary: '#0066ff', sectionTitleColor: '#000000', applyToAll: false });
+ let theme = await getJson(`${BASE_URL}/api/theme`);
+ assert.strictEqual(theme.sectionTitleColor, '#000000');
+ await putJson(`${BASE_URL}/api/theme`, { primary: '#0066ff', sectionTitleColor: null, applyToAll: false });
+ theme = await getJson(`${BASE_URL}/api/theme`);
+ assert.strictEqual(theme.sectionTitleColor, null);
+ });
+
+ it('PUT /api/theme rejects invalid sectionRadius (out of range, non-integer, non-numeric)', async () => {
+ for (const bad of [-1, 33, 1000, 3.5, 'foo']) {
+ const r = await fetch(`${BASE_URL}/api/theme`, {
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ primary: '#0066ff', sectionRadius: bad })
+ });
+ assert.strictEqual(r.status, 400, `expected 400 for sectionRadius=${bad}`);
+ }
+ });
+
+ it('PUT /api/theme persists sectionRadius into settings and every dataset when applyToAll=true', async () => {
+ const a = await createDs('Section Radius Bulk A');
+ const b = await createDs('Section Radius Bulk B');
+ const result = await putJson(`${BASE_URL}/api/theme`, {
+ primary: '#0066ff', sectionRadius: 4, applyToAll: true
+ });
+ assert.strictEqual(result.success, true);
+ const settingsTheme = await getJson(`${BASE_URL}/api/theme`);
+ assert.strictEqual(settingsTheme.sectionRadius, 4);
+ const aData = await getJson(`${BASE_URL}/api/datasets/id/${a.id}`);
+ const bData = await getJson(`${BASE_URL}/api/datasets/id/${b.id}`);
+ assert.strictEqual(aData.theme.sectionRadius, 4);
+ assert.strictEqual(bData.theme.sectionRadius, 4);
+ await delDs(a.id); await delDs(b.id);
+ });
+
+ it('PUT /api/theme with applyToAll=false only updates currentDatasetId sectionRadius', async () => {
+ const a = await createDs('Section Radius Solo A');
+ const b = await createDs('Section Radius Solo B');
+ await putJson(`${BASE_URL}/api/theme`, { primary: '#0066ff', sectionRadius: 20, applyToAll: true });
+ await putJson(`${BASE_URL}/api/theme`, { primary: '#0066ff', sectionRadius: 0, applyToAll: false, currentDatasetId: a.id });
+ const aData = await getJson(`${BASE_URL}/api/datasets/id/${a.id}`);
+ const bData = await getJson(`${BASE_URL}/api/datasets/id/${b.id}`);
+ assert.strictEqual(aData.theme.sectionRadius, 0, 'A picks up the new sectionRadius');
+ assert.strictEqual(bData.theme.sectionRadius, 20, 'B retains the prior bulk-applied sectionRadius');
+ await delDs(a.id); await delDs(b.id);
+ });
+
+ it('PUT /api/theme with applyToAll=false propagates sectionRadius to language siblings', async () => {
+ const en = await createDs('Section Radius Sibling Group');
+ const frRes = await fetch(`${BASE_URL}/api/datasets`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: 'Section Radius Sibling Group', language: 'fr', language_group: en.language_group })
+ });
+ const fr = await frRes.json();
+ await putJson(`${BASE_URL}/api/theme`, {
+ primary: '#0066ff', sectionRadius: 24, applyToAll: false, currentDatasetId: en.id
+ });
+ const enData = await getJson(`${BASE_URL}/api/datasets/id/${en.id}`);
+ const frData = await getJson(`${BASE_URL}/api/datasets/id/${fr.id}`);
+ assert.strictEqual(enData.theme.sectionRadius, 24);
+ assert.strictEqual(frData.theme.sectionRadius, 24, 'sibling FR variant inherits sectionRadius');
+ await delDs(en.id); await delDs(fr.id);
+ });
+
+ it('PUT /api/theme clears sectionRadius when set to null', async () => {
+ await putJson(`${BASE_URL}/api/theme`, { primary: '#0066ff', sectionRadius: 12, applyToAll: false });
+ let theme = await getJson(`${BASE_URL}/api/theme`);
+ assert.strictEqual(theme.sectionRadius, 12);
+ await putJson(`${BASE_URL}/api/theme`, { primary: '#0066ff', sectionRadius: null, applyToAll: false });
+ theme = await getJson(`${BASE_URL}/api/theme`);
+ assert.strictEqual(theme.sectionRadius, null);
+ });
+
it('Dataset load with embedded theme writes theme into settings', async () => {
// Create a dataset, manually set its data.theme, then load it
await putJson(`${BASE_URL}/api/theme`, { primary: '#aaaaaa', fontFamily: 'Inter', applyToAll: false });
diff --git a/version.json b/version.json
index 1307e32b..574a6f47 100644
--- a/version.json
+++ b/version.json
@@ -1,4 +1,4 @@
{
- "version": "1.39.0",
+ "version": "1.41.0",
"changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md"
}