Zweites Lebenslauf-Design "Social Media" + Anschreiben folgt dem Layout

Der Lebenslauf kann jetzt zwischen zwei Layouts waehlen, und das Anschreiben
uebernimmt immer das Layout des Lebenslaufs - beide Dokumente kommen als ein Set
beim Unternehmen an.

"Social Media" ist die verspielte Variante nach der Media-Kit-Vorlage: getoente
Seite, rundes Portrait mit Blob, Schreibschrift-Zeile ueber einem grossen Namen,
Herzchen als Abschnittsmarker und Bullets, abgerundete Karten je Station,
Pill-Chips fuer Kompetenzen und Sprachen, Glitzer-Sterne als Deko. Bewusst laut -
gedacht fuer kreative und Social-Media-Stellen.

Die gesamte Farbwelt (Seite, Karten, Pills, Deko) wird aus der Akzentfarbe
abgeleitet, das Layout funktioniert also auch in Violett oder Tiefblau. Neue
Akzente Pink und Violett; das Layout bringt eigene Defaults mit (pink, rundes
Foto), sodass ein Wechsel sofort stimmig aussieht.

Neue Schriften (SIL OFL): Poppins fuer die geometrische Sans, Pacifico fuer die
Schreibschrift. Es werden nur die Fonts eingebettet, die das gewaehlte Layout
nutzt - ein klassischer Lebenslauf traegt Poppins/Pacifico nicht mit.

Herz und Sparkle sind als Bezier-Pfade gezeichnet, weil sich auf ein Herz-Glyph
in der Schrift nicht verlassen werden kann. Das neue Layout nutzt dieselbe
Mess- und Skalier-Mechanik wie das klassische, erbt also die Ein-Seiten-Garantie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 01:15:02 +02:00
co-authored by Claude Opus 4.8
parent 9f7349af4e
commit 0e1d9b3322
9 changed files with 917 additions and 75 deletions
+121 -32
View File
@@ -1,24 +1,41 @@
// 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. What stays
// in code is the *layout* — the sidebar grid, the DIN-5008 letter, the one-page
// auto-scaling. What the user picks is a narrow, curated set of knobs.
// in the `design` table, and deleting the row restores the default.
//
// The palette is deliberately closed. A free colour picker invites a neon CV,
// which makes an application worse, not better; every option here is a muted
// tone that survives being printed in greyscale by an HR department.
// Two 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.
//
// 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. `ink`/`sub`/`hair` stay neutral in every theme — the accent
// only carries the name, section labels, company names and markers, exactly as
// the monochrome original did.
// Accent colours. In `sidebar` the accent is the *only* colour; in `social` the
// whole page palette (background, cards, pills) is derived from it, so the
// playful layout works in pink as well as in deep blue.
const AKZENTE = [
{ key: 'anthrazit', label: 'Anthrazit (Standard)', rgb: [23, 23, 23] },
{ 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: '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)' },
];
const SIDEBAR_VARIANTEN = [
@@ -28,7 +45,7 @@ const SIDEBAR_VARIANTEN = [
];
const FOTO_FORMEN = [
{ key: 'eckig', label: 'Eckig (Standard)' },
{ key: 'eckig', label: 'Eckig' },
{ key: 'rund', label: 'Rund' },
];
@@ -40,11 +57,13 @@ const SCHRIFT_STUFEN = [
{ key: '110', label: '110 % (groß)' },
];
// Layout is fixed for now; the field exists so a second variant can be added
// later without another migration.
const LAYOUTS = [
{ key: 'sidebar', label: 'Sidebar (Standard)' },
];
// 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' },
};
const DEFAULTS = {
layout: 'sidebar',
@@ -58,7 +77,7 @@ const DEFAULTS = {
const FELDER = [
{ key: 'layout', label: 'Layout', optionen: LAYOUTS },
{ key: 'akzent', label: 'Akzentfarbe', optionen: AKZENTE },
{ key: 'sidebar', label: 'Sidebar-Hintergrund', optionen: SIDEBAR_VARIANTEN },
{ 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 },
];
@@ -69,6 +88,10 @@ const NEUTRAL_SIDEBAR = [242, 243, 245];
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);
@@ -82,10 +105,16 @@ function one(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.
// 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 out = { ...DEFAULTS };
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);
}
@@ -100,32 +129,90 @@ function settings(overrides = {}) {
function resolve(overrides = {}) {
const s = settings(overrides);
const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb;
const social = s.layout === 'social';
let sidebarBg;
if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255];
else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08);
else sidebarBg = NEUTRAL_SIDEBAR;
return {
layout: s.layout,
// Résumé palette — neutral base, configurable accent.
rc: {
// 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.
const rc = 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),
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),
}
: {
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
pillBg: tint(accent, 0.12),
pillInk: accent,
sidebarBg,
track: [199, 202, 208],
onSide: [38, 40, 44],
onSideSub: [96, 100, 106],
},
// Cover-letter palette — same accent, so both documents read as one set.
lc: {
};
// 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 = social
? {
ink: rc.ink,
muted: rc.sub,
hair: rc.hair,
accent,
pageBg: rc.pageBg,
cardBg: rc.cardBg,
cardBorder: rc.cardBorder,
deko: rc.deko,
pillBg: rc.pillBg,
pillInk: rc.pillInk,
}
: {
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],
pillBg: tint(accent, 0.12),
pillInk: accent,
};
return {
layout: s.layout,
// Which typefaces the renderer registers and draws with. The playful layout
// uses a geometric sans plus a script face for the "Hallo, ich bin" line;
// the classic one stays on Lato and embeds nothing extra.
fonts: social
? { sans: 'Poppins', script: 'Pacifico' }
: { sans: 'Lato', script: null },
rc,
lc,
foto: {
anzeigen: s.foto_anzeigen !== '0',
rund: s.foto_form === 'rund',
@@ -136,26 +223,28 @@ function resolve(overrides = {}) {
};
}
// Field list for the Vorlagen UI, with the current selection marked.
// 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 the shipped defaults.
// True when the user deviates from what the chosen layout would ship with.
function isAngepasst(overrides = {}) {
const s = settings(overrides);
return Object.keys(DEFAULTS).some((k) => s[k] !== DEFAULTS[k]);
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, AKZENTE, FELDER,
DEFAULTS, LAYOUT_DEFAULTS, AKZENTE, LAYOUTS, FELDER,
settings, resolve, list, isAngepasst,
// The default theme, for callers that pass no overrides at all.
defaultTheme: () => resolve({}),
};