Sunny: Anschreiben als Gegenstück zum Poster-Lebenslauf gestalten
Der Brief trug bisher nur dieselbe Farbe wie der Lebenslauf, nicht dieselbe Sprache. Jetzt bekommt er denselben Kopf: Name plakativ auf dem Farbband, Ziel- position als solides Badge, Kontaktdaten als weiße Chips mit Icons rechts gestapelt, dazu Konfetti in den Randstreifen. Das Datum ist ein Pastell-Chip neben dem Anschriftfeld statt einer einsamen rechtsbündigen Zeile, der Betreff bekommt Balken und Größe einer Sektionsüberschrift, die Anrede die Akzentfarbe, die Anlagen werden zu Chips, und der getippte Name liegt auf einem Pastell- Streifen wie mit dem Marker gezogen – der Ersatz für die fehlende Handschrift. DIN-5008 bleibt unangetastet: Anschriftfeld, Datum, Betreff, Anrede, Fließtext, Gruß, Unterschrift und Anlagen stehen, wo ein deutscher Leser sie erwartet. Zwei Dinge, die erst im Render auffielen: - Die Konfetti-Punkte saßen in der linken Rinne direkt neben dem Namen und lasen sich als Aufzählungszeichen davor statt als Deko. Sie liegen jetzt rechts; nur ein Funkeln bleibt links, tief im Band und weit weg vom Namen. - Die Kontaktzeile der Fußzeile lief bei langer Anschrift plus langer E-Mail über das Band hinaus und schob sich über die Punkte. Sie skaliert jetzt passend und lässt notfalls die Anschrift weg, damit Telefon und E-Mail lesbar bleiben. suBar las seine Farbe fest aus der Lebenslauf-Palette (t.rc), obwohl der Brief t.lc benutzt – das ging nur gut, weil beide Werte zufällig gleich sind. Die Palette wird jetzt übergeben. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+150
-37
@@ -1562,20 +1562,43 @@ const suColX = (i) => SU.mx + (i % 2) * (SU.col + SU.gap);
|
||||
|
||||
// A pastel bar — the layout's one repeating motif. Sits on the same edge as the
|
||||
// heading it announces and runs about two thirds of the way across, so the
|
||||
// opposite corner stays open and the page keeps breathing.
|
||||
function suBar(doc, t, S, dry, y, align, frac = 0.62) {
|
||||
// opposite corner stays open and the page keeps breathing. `p` is the palette
|
||||
// (t.rc for the résumé, t.lc for the letter), so both documents draw the same
|
||||
// motif from their own colours.
|
||||
function suBar(doc, p, S, dry, y, align, frac = 0.62) {
|
||||
if (dry) return;
|
||||
const w = SU.w * frac;
|
||||
const x = align === 'right' ? SU.r - w : SU.mx;
|
||||
doc.setFillColor(t.rc.band[0], t.rc.band[1], t.rc.band[2]);
|
||||
doc.setFillColor(p.band[0], p.band[1], p.band[2]);
|
||||
doc.rect(x, y, w, 2.4 * S, 'F');
|
||||
}
|
||||
|
||||
// A filled dot. The confetti scattered through the Sunny documents is built from
|
||||
// these — a designer's flourish, kept to the margins where no text can collide.
|
||||
function suDot(doc, cx, cy, r, color) {
|
||||
doc.setFillColor(color[0], color[1], color[2]);
|
||||
doc.circle(cx, cy, r, 'F');
|
||||
}
|
||||
|
||||
// Confetti in the band's gutters — the strips between its edges and the text
|
||||
// column, empty by construction, so the decoration can never run into a word.
|
||||
//
|
||||
// It all lives on the *right*: dots down the left gutter sat directly beside the
|
||||
// name and read as bullet points in front of it, not as decoration. The only
|
||||
// thing on the left is a sparkle low in the band, well clear of the name.
|
||||
function suKonfetti(doc, p, S, bandY, bandH) {
|
||||
const gx = SU.r - 3 * S;
|
||||
suDot(doc, gx, bandY + bandH * 0.26, 1.4 * S, p.accent);
|
||||
suDot(doc, gx, bandY + bandH * 0.48, 0.9 * S, [255, 255, 255]);
|
||||
suDot(doc, gx, bandY + bandH * 0.66, 1.9 * S, [255, 255, 255]);
|
||||
sparkle(doc, SU.mx + 3.4 * S, bandY + bandH - 5.5 * S, 4.4 * S, [255, 255, 255]);
|
||||
}
|
||||
|
||||
// Display heading: the poster type. Set as large as the layout's rhythm allows
|
||||
// and shrunk only if a long word (Berufsausbildung…) would not otherwise fit.
|
||||
function suHeading(doc, t, S, dry, cur, title, align = 'left') {
|
||||
cur.y += 6.5 * S;
|
||||
suBar(doc, t, S, dry, cur.y, align);
|
||||
suBar(doc, t.rc, S, dry, cur.y, align);
|
||||
cur.y += 2.4 * S + 4.5 * S;
|
||||
|
||||
const label = String(title);
|
||||
@@ -1909,7 +1932,7 @@ function composeCVSunny(doc, t, S, dry, ctx) {
|
||||
|
||||
if (cv.hobbys.length) {
|
||||
cur.y += 5 * S;
|
||||
suBar(doc, t, S, dry, cur.y, 'left', 0.28);
|
||||
suBar(doc, t.rc, S, dry, cur.y, 'left', 0.28);
|
||||
cur.y += 2.4 * S + 3 * S;
|
||||
write(doc, S, dry, cur, `Interessen: ${cv.hobbys.join(' · ')}`,
|
||||
{ pt: 8.8, color: t.rc.sub, x: SU.mx, width: SU.w, factor: 1.35 });
|
||||
@@ -2226,57 +2249,101 @@ function composeLetterSunny(doc, t, S, dry, cur, { letter, header, job, anlagen,
|
||||
const betreff = letter.betreff || (job.stelle ? `Bewerbung als ${job.stelle}` : 'Bewerbung');
|
||||
const stadt = cityName(header);
|
||||
|
||||
// --- Head: name and role badge on the pastel band ---
|
||||
// --- Head: the CV's hero, minus the portrait. Name set as a poster on the
|
||||
// pastel band, the role as a solid badge, the contact details as white
|
||||
// chips stacked down the right — the letter is the résumé's twin, so a
|
||||
// recruiter sees one designed set, not two documents that share a colour.
|
||||
const bandY = cur.y;
|
||||
const chips = buildContactLines(header)
|
||||
.filter((k) => k.typ === 'email' || k.typ === 'telefon' || k.typ === 'ort' || k.typ === 'web')
|
||||
.map((k) => ({ text: k.text, icon: k.typ }));
|
||||
|
||||
// Size the chips first: a long e-mail address must not push the name column
|
||||
// into a one-word-per-line sliver. Shrink the chips until they fit their half.
|
||||
let chipPt = 8.4;
|
||||
let chipW = 0;
|
||||
if (chips.length) {
|
||||
const maxW = width * 0.46;
|
||||
for (; chipPt > 6; chipPt -= 0.2) {
|
||||
chipW = Math.max(...chips.map((c) => pill(doc, t, S, true, 0, 0, c, { style: 'normal', pt: chipPt }).w));
|
||||
if (chipW <= maxW) break;
|
||||
}
|
||||
chipW = Math.min(chipW, maxW);
|
||||
}
|
||||
const nameW = width - (chips.length ? chipW + 5 * S : 0);
|
||||
|
||||
const inner = (c, isDry) => {
|
||||
c.y += SU.pad * S;
|
||||
const top = c.y + SU.pad * S;
|
||||
|
||||
const cl = { y: top };
|
||||
const name = String(header.name || '');
|
||||
let namePt = 26;
|
||||
let namePt = 27;
|
||||
doc.setFont(doc.__sans, 'bold');
|
||||
for (; namePt > 14; namePt -= 0.5) {
|
||||
doc.setFontSize(namePt * S);
|
||||
if (doc.getTextWidth(name) <= width) break;
|
||||
// Fit the longest word: a two-part name may break, a single word may not.
|
||||
const longest = name.split(/\s+/).reduce((a, b) => (doc.getTextWidth(a) > doc.getTextWidth(b) ? a : b), '');
|
||||
if (doc.getTextWidth(longest) <= nameW) break;
|
||||
}
|
||||
write(doc, S, isDry, c, name, { pt: namePt, style: 'bold', color: t.lc.accent, x, width, factor: 1.04 });
|
||||
write(doc, S, isDry, cl, name, { pt: namePt, style: 'bold', color: t.lc.accent, x, width: nameW, factor: 1.02 });
|
||||
if (header.headline) {
|
||||
c.y += 2.4 * S;
|
||||
suBadge(doc, t.lc, S, isDry, c, header.headline, x, width);
|
||||
cl.y += 2.6 * S;
|
||||
suBadge(doc, t.lc, S, isDry, cl, header.headline, x, nameW);
|
||||
}
|
||||
c.y += SU.pad * S;
|
||||
|
||||
const cr = { y: top + 1 * S };
|
||||
for (const ch of chips) {
|
||||
const probe = pill(doc, t, S, true, 0, 0, ch, { style: 'normal', pt: chipPt });
|
||||
pill(doc, t, S, isDry, right - probe.w, cr.y, ch,
|
||||
{ style: 'normal', pt: chipPt, bg: t.lc.cardBg, ink: t.lc.accent });
|
||||
cr.y += probe.h + 1.8 * S;
|
||||
}
|
||||
|
||||
c.y = Math.max(cl.y, cr.y - 1.8 * S) + SU.pad * S;
|
||||
};
|
||||
|
||||
const probe = { y: bandY };
|
||||
inner(probe, true);
|
||||
const bandH = probe.y - bandY;
|
||||
if (!dry) {
|
||||
doc.setFillColor(t.lc.band[0], t.lc.band[1], t.lc.band[2]);
|
||||
doc.rect(SU.mx, bandY, SU.w, bandH, 'F');
|
||||
suKonfetti(doc, t.lc, S, bandY, bandH);
|
||||
inner({ y: bandY }, false);
|
||||
}
|
||||
cur.y = bandY + bandH + 10 * S;
|
||||
cur.y = bandY + bandH + 9 * S;
|
||||
|
||||
// --- Recipient (Anschriftfeld) ---
|
||||
// --- Recipient (Anschriftfeld) with the date as a pastel chip on the same
|
||||
// line — DIN keeps its address block, the date just stops being a lonely
|
||||
// right-aligned string and becomes part of the layout's chip language.
|
||||
const emp = letter.empfaenger || {};
|
||||
const empFirma = emp.firma || job.firma || '';
|
||||
const empOrt = emp.ort || job.ort || '';
|
||||
if (empFirma) write(doc, S, dry, cur, empFirma, { pt: 10.2, style: 'bold', color: t.lc.ink, x, width, factor: 1.32 });
|
||||
if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.32 });
|
||||
if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.32 });
|
||||
if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.32 });
|
||||
const datum = stadt ? `${stadt}, ${today}` : today;
|
||||
if (!dry) {
|
||||
const p = pill(doc, t, S, true, 0, 0, datum, { pt: 8.6, style: 'normal' });
|
||||
pill(doc, t, S, false, right - p.w, cur.y - 0.5 * S, datum,
|
||||
{ pt: 8.6, style: 'normal', bg: t.lc.band, ink: t.lc.accent });
|
||||
}
|
||||
const empW = width * 0.6; // keep the address clear of the date chip
|
||||
if (empFirma) write(doc, S, dry, cur, empFirma, { pt: 10.2, style: 'bold', color: t.lc.ink, x, width: empW, factor: 1.32 });
|
||||
if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.2, color: t.lc.ink, x, width: empW, factor: 1.32 });
|
||||
if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.2, color: t.lc.ink, x, width: empW, factor: 1.32 });
|
||||
if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.2, color: t.lc.ink, x, width: empW, factor: 1.32 });
|
||||
|
||||
// --- Date (right-aligned) ---
|
||||
cur.y += 7 * S;
|
||||
write(doc, S, dry, cur, stadt ? `${stadt}, den ${today}` : today,
|
||||
{ pt: 9.6, color: t.lc.muted, x, width, align: 'right', right, factor: 1.2 });
|
||||
// --- Subject as a display heading under the bar: the same motif that opens
|
||||
// every section of the résumé, so the letter's one headline lands with the
|
||||
// same weight as "Berufserfahrung" does over there.
|
||||
cur.y += 9 * S;
|
||||
suBar(doc, t.lc, S, dry, cur.y, 'left', 0.34);
|
||||
cur.y += 2.4 * S + 4.2 * S;
|
||||
write(doc, S, dry, cur, betreff, { pt: 15, style: 'bold', color: t.lc.accent, x, width, factor: 1.18 });
|
||||
|
||||
// --- Subject, under a short bar: the section motif, letter-sized ---
|
||||
cur.y += 6 * S;
|
||||
suBar(doc, t, S, dry, cur.y, 'left', 0.22);
|
||||
cur.y += 2.4 * S + 3.6 * S;
|
||||
write(doc, S, dry, cur, betreff, { pt: 13, style: 'bold', color: t.lc.accent, x, width, factor: 1.24 });
|
||||
|
||||
// --- Salutation + body ---
|
||||
// --- Salutation + body. The salutation carries the accent: it is the moment
|
||||
// the letter starts speaking, and it echoes the coloured entry titles of
|
||||
// the résumé.
|
||||
cur.y += 5.5 * S;
|
||||
if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.4 }); cur.y += 2.8 * S; }
|
||||
if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.2, style: 'bold', color: t.lc.accent, x, width, factor: 1.4 }); cur.y += 2.8 * S; }
|
||||
letter.absaetze.forEach((p, i) => {
|
||||
if (i > 0) cur.y += 3 * S;
|
||||
write(doc, S, dry, cur, p, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.52 });
|
||||
@@ -2302,15 +2369,36 @@ function composeLetterSunny(doc, t, S, dry, cur, { letter, header, job, anlagen,
|
||||
if (!dry) doc.addImage(signatur.dataUrl, signatur.format || 'PNG', x, cur.y, sigW, sigH);
|
||||
cur.y += sigH;
|
||||
} else {
|
||||
// No signature image: the typed name gets a pastel highlight swiped behind
|
||||
// it, like a marker stroke — the flourish that stands in for the missing
|
||||
// handwriting instead of leaving an empty gap.
|
||||
cur.y += 12 * S;
|
||||
write(doc, S, dry, cur, header.name, { pt: 10.2, style: 'bold', color: t.lc.accent, x, width, factor: 1.2 });
|
||||
const pt = 10.6;
|
||||
if (!dry) {
|
||||
doc.setFont(doc.__sans, 'bold');
|
||||
doc.setFontSize(pt * S);
|
||||
const w = doc.getTextWidth(String(header.name || ''));
|
||||
// Centre the swipe on the glyphs: `write` puts the baseline at
|
||||
// cur.y + pt*PT*0.76, so a box hung off cur.y alone sits too low and the
|
||||
// capitals poke out over its top edge.
|
||||
const h = pt * S * PT * 1.2;
|
||||
const y = cur.y + pt * S * PT * 0.76 - h * 0.72;
|
||||
doc.setFillColor(t.lc.band[0], t.lc.band[1], t.lc.band[2]);
|
||||
doc.roundedRect(x - 1.8 * S, y, w + 3.6 * S, h, 1.4 * S, 1.4 * S, 'F');
|
||||
}
|
||||
write(doc, S, dry, cur, header.name, { pt, style: 'bold', color: t.lc.accent, x, width, factor: 1.25 });
|
||||
}
|
||||
|
||||
// --- Enclosures ---
|
||||
// --- Enclosures as chips, in the same pill language as the contact details ---
|
||||
if (anlagen && anlagen.length) {
|
||||
cur.y += 7 * S;
|
||||
write(doc, S, dry, cur, (anlagen.length > 1 ? 'Anlagen: ' : 'Anlage: ') + anlagen.join(', '),
|
||||
{ pt: 8.8, color: t.lc.muted, x, width, factor: 1.3 });
|
||||
write(doc, S, dry, cur, anlagen.length > 1 ? 'Anlagen' : 'Anlage',
|
||||
{ pt: 8.2, style: 'bold', color: t.lc.muted, x, width, factor: 1.4, charSpace: 0.5, upper: true });
|
||||
cur.y += 1.4 * S;
|
||||
const c = { y: cur.y };
|
||||
pillRow(doc, t, S, dry, c, anlagen, x, width,
|
||||
{ style: 'normal', pt: 8.4, bg: t.lc.band, ink: t.lc.accent });
|
||||
cur.y = c.y;
|
||||
}
|
||||
|
||||
// --- Footer: the contact strip on a pastel bar, in the reserved bottom margin ---
|
||||
@@ -2327,10 +2415,35 @@ function composeLetterSunny(doc, t, S, dry, cur, { letter, header, job, anlagen,
|
||||
const fy = PAGE.h - SU.bottom - fh;
|
||||
doc.setFillColor(t.lc.band[0], t.lc.band[1], t.lc.band[2]);
|
||||
doc.rect(SU.mx, fy, SU.w, fh, 'F');
|
||||
// Dots closing the strip at both ends — the head's confetti, answered.
|
||||
suDot(doc, SU.mx + 4 * S, fy + fh / 2, 1.1 * S, t.lc.accent);
|
||||
suDot(doc, SU.r - 4 * S, fy + fh / 2, 1.1 * S, t.lc.accent);
|
||||
|
||||
// Fit the strip to the band. A long address plus a long e-mail address
|
||||
// overruns it at a fixed size — the line then slides out over the dots and
|
||||
// off the band's edge. Shrink to fit; if even the floor is too wide, drop
|
||||
// the postal address and keep the two ways to actually reach the person.
|
||||
const raum = SU.w - 16 * S; // band minus the dots at both ends
|
||||
doc.setFont(doc.__sans, 'normal');
|
||||
doc.setFontSize(8 * S);
|
||||
const passt = (text, pt) => {
|
||||
doc.setFontSize(pt * S);
|
||||
return doc.getTextWidth(text) <= raum;
|
||||
};
|
||||
const fit = (text) => {
|
||||
for (let pt = 8; pt > 5.4; pt -= 0.2) if (passt(text, pt)) return pt;
|
||||
return null;
|
||||
};
|
||||
let strip = parts.join(' · ');
|
||||
let fpt = fit(strip);
|
||||
if (fpt === null && parts.length > 1) {
|
||||
strip = parts.slice(1).join(' · '); // ohne Anschrift
|
||||
fpt = fit(strip);
|
||||
}
|
||||
if (fpt === null) fpt = 5.4; // Notnagel: lieber klein als über den Rand
|
||||
|
||||
doc.setFontSize(fpt * S);
|
||||
doc.setTextColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
|
||||
doc.text(parts.join(' · '), PAGE.w / 2, fy + fh / 2 + 8 * S * PT * 0.35, { align: 'center' });
|
||||
doc.text(strip, PAGE.w / 2, fy + fh / 2 + fpt * S * PT * 0.35, { align: 'center' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user