// Editable design settings for the generated application documents. // // Same contract as lib/prompts.js: the defaults live here, an override is a row // in the `design` table, and deleting the row restores the default. // // Three layouts ship today: // // sidebar — the classic: neutral greys, tinted sidebar, DIN-5008 letter. The // accent is a single restrained colour; hierarchy comes from weight // and tracking. Right for almost every office application. // // social — a playful media-kit look (rounded cards, pills, hearts, a script // headline, a full-page tinted background). Right for creative, // social-media and design-adjacent roles — and deliberately loud. // // sunny — an editorial poster look: white page, oversized display headings // in a deep accent, pastel accent bars that alternate left/right, a // photo breaking out of a tinted hero band, a solid role badge and // level bars for the languages. Bold but tidy — right for marketing, // media and design roles that still want a readable, gridded page. // // tupfen — the quiet one with a wink: a white page, the name centred and set // large in plain ink, airy body text — and scattered coral dots in // the margins, some running off the page edge. The dots are the only // colour on the sheet; the résumé picks the motif back up as section // markers, bullets and five-dot language meters. Restrained enough // for any office, but nobody mistakes it for a template. // // The cover letter always follows the résumé's layout, so both documents arrive // as one coherent set — that is the whole point of picking a design here. // // The palette stays closed (no free colour picker): every tone is one that still // works when an HR department prints it in greyscale. // Accent colours. In `sidebar` the accent is the *only* colour; in `social` and // `sunny` the whole page palette (bands, cards, pills) is derived from it, so // those layouts work in berry as well as in deep blue. const AKZENTE = [ { key: 'anthrazit', label: 'Anthrazit', rgb: [23, 23, 23] }, { key: 'tiefblau', label: 'Tiefblau', rgb: [30, 58, 95] }, { key: 'bordeaux', label: 'Bordeaux', rgb: [122, 31, 47] }, { key: 'waldgruen', label: 'Waldgrün', rgb: [27, 67, 50] }, { key: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] }, { key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] }, { key: 'beere', label: 'Beere (Sunny)', rgb: [109, 24, 74] }, // Deliberately deeper than the pastel dots of Tupfen's reference: in `sidebar` // the accent *is* the text colour, and a pale coral would be unreadable there. // Tupfen's dots are tinted up from this tone, which lands them back at pastel. { key: 'koralle', label: 'Koralle (Tupfen)', rgb: [222, 106, 106] }, { key: 'pink', label: 'Pink (verspielt)', rgb: [232, 54, 143] }, { key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] }, ]; const LAYOUTS = [ { key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' }, { key: 'social', label: 'Social Media (verspielt)' }, { key: 'sunny', label: 'Sunny (plakativ, Farbbänder)' }, { key: 'tupfen', label: 'Tupfen (dezent, verspielte Punkte)' }, ]; const SIDEBAR_VARIANTEN = [ { key: 'neutral', label: 'Neutral grau (Standard)' }, { key: 'getoent', label: 'Im Akzent getönt' }, { key: 'weiss', label: 'Weiß (ohne Band)' }, ]; const FOTO_FORMEN = [ { key: 'eckig', label: 'Eckig' }, { key: 'rund', label: 'Rund' }, ]; const SCHRIFT_STUFEN = [ { key: '90', label: '90 % (kompakt)' }, { key: '95', label: '95 %' }, { key: '100', label: '100 % (Standard)' }, { key: '105', label: '105 %' }, { key: '110', label: '110 % (groß)' }, ]; // Defaults per layout: the playful layout is pink and round by default, because // anthracite-and-square would defeat the point of choosing it. Any of these can // still be overridden individually. const LAYOUT_DEFAULTS = { sidebar: { akzent: 'anthrazit', foto_form: 'eckig' }, social: { akzent: 'pink', foto_form: 'rund' }, // Sunny is built around the berry/pastel pairing of its reference; the photo // sits in a square frame that breaks out of the hero band, so square it is. sunny: { akzent: 'beere', foto_form: 'eckig' }, // Tupfen scatters coral dots; a round portrait is simply the largest of them, // so it falls in with the motif instead of fighting it. tupfen: { akzent: 'koralle', foto_form: 'rund' }, }; const DEFAULTS = { layout: 'sidebar', akzent: 'anthrazit', sidebar: 'neutral', foto_anzeigen: '1', foto_form: 'eckig', schrift: '100', }; const FELDER = [ { key: 'layout', label: 'Layout', optionen: LAYOUTS }, { key: 'akzent', label: 'Akzentfarbe', optionen: AKZENTE }, { key: 'sidebar', label: 'Sidebar-Hintergrund', optionen: SIDEBAR_VARIANTEN, nurLayout: 'sidebar' }, { key: 'foto_form', label: 'Form des Bewerberfotos', optionen: FOTO_FORMEN }, { key: 'schrift', label: 'Schriftgröße', optionen: SCHRIFT_STUFEN }, ]; const NEUTRAL_SIDEBAR = [242, 243, 245]; // Mix a colour towards white. amount = 0 -> white, 1 -> the colour itself. function tint(rgb, amount) { return rgb.map((c) => Math.round(c * amount + 255 * (1 - amount))); } // Mix a colour towards black. function shade(rgb, amount) { return rgb.map((c) => Math.round(c * amount)); } function findOption(list, key, fallbackKey) { return list.find((o) => o.key === key) || list.find((o) => o.key === fallbackKey); } // A checkbox paired with a hidden fallback submits *both* values, which Express // hands over as an array — the last entry is the effective one. function one(v) { return Array.isArray(v) ? v[v.length - 1] : v; } // Fold the stored overrides onto the defaults, dropping unknown keys/values so a // stale row, a hand-crafted query string or a checkbox pair can never produce an // invalid theme. Fields not explicitly set fall back to the *layout's* default, // which is why switching to "social" turns pink without touching anything else. function settings(overrides = {}) { const layoutRaw = one(overrides.layout); const layout = LAYOUTS.some((l) => l.key === layoutRaw) ? String(layoutRaw) : DEFAULTS.layout; const out = { ...DEFAULTS, ...(LAYOUT_DEFAULTS[layout] || {}), layout }; for (const f of FELDER) { if (f.key === 'layout') continue; const v = one(overrides[f.key]); if (v && f.optionen.some((o) => o.key === String(v))) out[f.key] = String(v); } const foto = one(overrides.foto_anzeigen); if (foto === '0' || foto === '1') out.foto_anzeigen = foto; return out; } // Build the concrete theme the renderer draws with. Everything the PDF code // needs to know about the user's choices is in here — the renderer itself has // no idea these are configurable. function resolve(overrides = {}) { const s = settings(overrides); const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb; let sidebarBg; if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255]; else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08); else sidebarBg = NEUTRAL_SIDEBAR; // Résumé palettes, one per layout. Every key exists in every palette so the // renderer can read `t.rc.` without knowing which layout it is drawing. const RC = { sidebar: { ink: [33, 33, 33], sub: [92, 96, 100], hair: [208, 210, 214], accent, pageBg: null, // no page tint in the classic layout cardBg: [255, 255, 255], cardBorder: [208, 210, 214], deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity dekoHell: [230, 232, 235], band: tint(accent, 0.14), pillBg: tint(accent, 0.12), pillInk: accent, sidebarBg, track: [199, 202, 208], onSide: [38, 40, 44], onSideSub: [96, 100, 106], }, // The playful layout derives its whole palette from the accent: a tinted // page, near-white cards with a soft border, pill chips, and a warm dark ink // that is the accent shaded down rather than plain black. social: { ink: shade(accent, 0.35), sub: tint(shade(accent, 0.55), 0.75), hair: tint(accent, 0.30), accent, pageBg: tint(accent, 0.10), cardBg: [255, 255, 255], cardBorder: tint(accent, 0.28), // Sparkles/blob sit on the tinted page, so they need more punch than the // card border or they simply vanish. deko: tint(accent, 0.45), dekoHell: tint(accent, 0.22), band: tint(accent, 0.22), pillBg: tint(accent, 0.18), pillInk: shade(accent, 0.6), sidebarBg, // unused in this layout, kept so the shape stays stable track: tint(accent, 0.45), onSide: shade(accent, 0.35), onSideSub: tint(shade(accent, 0.55), 0.75), }, // Sunny keeps the page white and puts the colour into solid blocks: pastel // bands and rails on one side, the full-strength accent in the display // headings and the role badge. Only two tones, used at maximum contrast. sunny: { ink: [40, 36, 42], sub: [124, 118, 126], hair: tint(accent, 0.22), accent, pageBg: null, // white page — the colour lives in the bands cardBg: [255, 255, 255], cardBorder: tint(accent, 0.22), deko: tint(accent, 0.30), dekoHell: tint(accent, 0.16), band: tint(accent, 0.24), // the pastel hero band and section bars pillBg: accent, // solid badge… pillInk: [255, 255, 255], // …with the text knocked out white sidebarBg, // unused track: tint(accent, 0.16), // rail behind a language level bar onSide: [40, 36, 42], onSideSub: [124, 118, 126], }, // Tupfen is a monochrome page that happens to have confetti on it. The text // is ink on white throughout — the accent never touches a paragraph, only // the dots: the scattered ones in the margins, the section markers, the // bullets, the language meters. The hairline stays a *neutral* grey rather // than a tinted one, or the restraint collapses and the page turns pink. tupfen: { ink: [26, 26, 28], sub: [112, 114, 120], hair: [226, 228, 232], accent, pageBg: null, // white page — the dots are the whole design cardBg: [255, 255, 255], cardBorder: [226, 228, 232], deko: tint(accent, 0.62), // the dots' base tone dekoHell: tint(accent, 0.34), // …and the paler ones behind them, for depth band: tint(accent, 0.14), pillBg: tint(accent, 0.12), pillInk: shade(accent, 0.72), sidebarBg, // unused track: tint(accent, 0.20), // an unfilled dot of a language meter onSide: [26, 26, 28], onSideSub: [112, 114, 120], }, }; const rc = RC[s.layout] || RC.sidebar; // The cover letter always mirrors the résumé, so the two documents read as one // set — a playful CV never arrives with a stiff monochrome letter. const LC = { sidebar: { ink: [26, 26, 26], muted: [92, 96, 100], hair: [206, 208, 212], accent, pageBg: null, cardBg: [255, 255, 255], cardBorder: [206, 208, 212], deko: [206, 208, 212], dekoHell: [230, 232, 235], band: rc.band, pillBg: tint(accent, 0.12), pillInk: accent, }, social: { ink: rc.ink, muted: rc.sub, hair: rc.hair, accent, pageBg: rc.pageBg, cardBg: rc.cardBg, cardBorder: rc.cardBorder, deko: rc.deko, dekoHell: rc.dekoHell, band: rc.band, pillBg: rc.pillBg, pillInk: rc.pillInk, }, sunny: { ink: rc.ink, muted: rc.sub, hair: rc.hair, accent, pageBg: rc.pageBg, cardBg: rc.cardBg, cardBorder: rc.cardBorder, deko: rc.deko, dekoHell: rc.dekoHell, band: rc.band, // The letter's role badge sits on the pastel head band, so a solid accent // slug reads too loud next to the résumé's poster. A white pill with the // accent knocked out keeps the motif but softens it — the letter is the // quiet twin, not a second poster. pillBg: rc.cardBg, pillInk: accent, }, // Tupfen's letter is not the quieter twin of anything — the résumé is already // quiet. Both sheets are the same white page with the same confetti, so the // letter simply takes the résumé's tones unchanged. tupfen: { ink: rc.ink, muted: rc.sub, hair: rc.hair, accent, pageBg: rc.pageBg, cardBg: rc.cardBg, cardBorder: rc.cardBorder, deko: rc.deko, dekoHell: rc.dekoHell, band: rc.band, pillBg: rc.pillBg, pillInk: rc.pillInk, }, }; const lc = LC[s.layout] || LC.sidebar; // Which typefaces the renderer registers and draws with. The playful layout // adds a script face for its "Hallo, ich bin" line; Sunny lives off Poppins // Bold alone (its whole hierarchy is size, not style); the classic one stays // on Lato and embeds nothing extra. Tupfen takes Poppins too — the reference's // geometric grotesk, whose round, wide letterforms rhyme with the dots. const FONTS = { sidebar: { sans: 'Lato', script: null }, social: { sans: 'Poppins', script: 'Pacifico' }, sunny: { sans: 'Poppins', script: null }, tupfen: { sans: 'Poppins', script: null }, }; return { layout: s.layout, fonts: FONTS[s.layout] || FONTS.sidebar, rc, lc, foto: { anzeigen: s.foto_anzeigen !== '0', rund: s.foto_form === 'rund', }, // Starting scale for the one-page fit. The auto-scaler may still shrink // below this when there is a lot of content — it never grows past it. scale: (Number(s.schrift) || 100) / 100, }; } // Field list for the Vorlagen UI, with the current selection marked. Fields that // only apply to one layout carry `nurLayout` so the form can hide them. function list(overrides = {}) { const s = settings(overrides); return FELDER.map((f) => ({ key: f.key, label: f.label, aktuell: s[f.key], nurLayout: f.nurLayout || null, optionen: f.optionen.map((o) => ({ key: o.key, label: o.label, gewaehlt: o.key === s[f.key] })), })); } // True when the user deviates from what the chosen layout would ship with. function isAngepasst(overrides = {}) { const s = settings(overrides); const basis = { ...DEFAULTS, ...(LAYOUT_DEFAULTS[s.layout] || {}), layout: s.layout }; return s.layout !== DEFAULTS.layout || Object.keys(basis).some((k) => s[k] !== basis[k]); } module.exports = { DEFAULTS, LAYOUT_DEFAULTS, AKZENTE, LAYOUTS, FELDER, settings, resolve, list, isAngepasst, defaultTheme: () => resolve({}), };