Files
thomasandClaude Opus 4.8 d20a265ebc Neues Layout "Tupfen": dezent, mit verstreuten Punkten
Weiße Seite, Name groß und mittig in Tinte, luftiger Blocksatz — und
korallfarbene Punkte in den Rändern, teils vom Seitenrand angeschnitten,
als einzige Farbe des Blattes. Der Lebenslauf greift das Motiv auf: die
Punkte markieren die Abschnitte, führen die Bullets und bilden als
Fünfer-Reihe die Sprach-Skala (gespeist aus niveauLevel(), also aus der
Formulierung des Lebenslaufs selbst).

Die Punkte skalieren bewusst nicht mit der Schrift: sie gehören zur Seite
wie die Ränder. Das Anschreiben reserviert dafür unten ein Band, in das
der Text nicht hineinläuft.

Der Brief behält das DIN-Anschriftenfeld, das der Vorlage fehlt — es sitzt
in deren zweispaltiger Meta-Zeile (Empfänger links, Datum und Kontakt
rechts), so bleibt die Komposition erhalten und die Bewerbung deutsch.

Dazu:
- neue Akzentfarbe "Koralle"
- write() kann optional Blocksatz. jsPDF stretcht eine Zeile nur, wenn es
  weiß, dass eine weitere folgt — der Absatz geht deshalb in einem Aufruf
  rüber, wodurch die Schlusszeile ungestretcht bleibt.
- Infobox unter /vorlagen beschreibt jetzt auch Sunny (fehlte) und Tupfen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:15:20 +02:00

376 lines
15 KiB
JavaScript

// 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.<x>` 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({}),
};