diff --git a/src/templates/job/index.ts b/src/templates/job/index.ts index bc72cc1..2d7de53 100644 --- a/src/templates/job/index.ts +++ b/src/templates/job/index.ts @@ -11,3 +11,4 @@ export { default as jobInterviewProcess } from './interview-process'; export { default as jobBenefits } from './benefits'; export { default as jobInternship } from './internship'; export { default as jobReferral } from './referral'; +export { default as jobNewHire } from './new-hire'; diff --git a/src/templates/job/new-hire.ts b/src/templates/job/new-hire.ts new file mode 100644 index 0000000..6e5f1e1 --- /dev/null +++ b/src/templates/job/new-hire.ts @@ -0,0 +1,269 @@ +import type { TemplateDefinition } from '../types'; +import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils'; + +// An omitted field falls back to its default, but a blank one is hidden. +function optionalText(value: unknown, fallback: string, maxLength: number): string { + return truncate((value === undefined ? fallback : String(value)).trim(), maxLength); +} + +// The first letter or digit of the first and of the last word ("Priya Sharma" -> "PS", +// one letter for a one-word name). Leading punctuation, "@" and emoji are skipped, and +// a character is taken by code point so an astral letter is never cut in half. +function initialsOf(name: string): string { + const letters = name + .split(/\s+/) + .map((word) => Array.from(word).find((ch) => /[\p{L}\p{N}]/u.test(ch)) ?? '') + .filter(Boolean); + if (letters.length === 0) return ''; + const first = letters[0]; + return (letters.length === 1 ? first : first + letters[letters.length - 1]).toUpperCase(); +} + +const template: TemplateDefinition = { + id: 'job-new-hire', + name: 'New Hire', + category: 'job', + description: 'Welcome a new teammate with an initials avatar, their name and role, the team, the start date and a short note', + tags: ['job', 'new hire', 'welcome', 'onboarding', 'team'], + fields: [ + { ...commonFields.title, label: 'Name', defaultValue: 'Priya Sharma', placeholder: 'Priya Sharma' }, + { key: 'role', label: 'Role', type: 'text', defaultValue: 'Senior Backend Engineer', placeholder: 'Senior Backend Engineer', group: 'Content' }, + { key: 'team', label: 'Team', type: 'text', defaultValue: 'Platform team', placeholder: 'Platform team', group: 'Content' }, + { key: 'startDate', label: 'Start Date', type: 'text', defaultValue: 'Starts Nov 3', placeholder: 'Starts Nov 3', group: 'Content' }, + { ...commonFields.description, label: 'Note', type: 'text', defaultValue: 'Excited to help build the payments stack', placeholder: 'Excited to help build the payments stack' }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: 'Priya Sharma', + role: 'Senior Backend Engineer', + team: 'Platform team', + startDate: 'Starts Nov 3', + description: 'Excited to help build the payments stack', + siteName: 'acme.com', + bgColor: '#0a0a0a', + accentColor: '#E07A5F', + textColor: '#FFFFFF', + }, + render: (params) => { + const rawName = String(params.title || 'Priya Sharma').trim() || 'Priya Sharma'; + const nameSize = autoFontSize(rawName, [ + { maxLen: 18, size: 64 }, + { maxLen: 26, size: 54 }, + { maxLen: 36, size: 46 }, + { maxLen: 48, size: 38 }, + ]); + const name = truncate(rawName, 48); + const initials = initialsOf(rawName); + const rawRole = optionalText(params.role, 'Senior Backend Engineer', 200); + const roleSize = autoFontSize(rawRole, [ + { maxLen: 30, size: 30 }, + { maxLen: 46, size: 26 }, + { maxLen: 60, size: 22 }, + ]); + const role = truncate(rawRole, 60); + const team = optionalText(params.team, 'Platform team', 32); + const startDate = optionalText(params.startDate, 'Starts Nov 3', 32); + const rawNote = optionalText(params.description, 'Excited to help build the payments stack', 400); + const noteSize = autoFontSize(rawNote, [ + { maxLen: 60, size: 28 }, + { maxLen: 90, size: 24 }, + { maxLen: 120, size: 22 }, + ]); + const note = truncate(rawNote, 120); + const siteName = optionalText(params.siteName, 'acme.com', 50); + const bgColor = String(params.bgColor || '#0a0a0a'); + const accentColor = String(params.accentColor || '#E07A5F'); + const textColor = String(params.textColor || '#FFFFFF'); + // The label, the circle and the start date are drawn in the accent on the page + // background, so the accent is darkened when that background is light. + const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor; + + const details = [ + ...(team + ? [{ + type: 'span', + props: { style: { fontSize: '26px', fontWeight: 600, color: textColor }, children: team }, + }] + : []), + ...(team && startDate + ? [{ + type: 'span', + props: { style: { fontSize: '26px', fontWeight: 600, color: `${textColor}66` }, children: '·' }, + }] + : []), + ...(startDate + ? [{ + type: 'span', + props: { style: { fontSize: '26px', fontWeight: 600, color: accentInk }, children: startDate }, + }] + : []), + ]; + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + width: '1200px', + height: '630px', + boxSizing: 'border-box', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + padding: '64px 80px', + }, + children: [ + // Label, initials circle, name and role + { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'column' }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '20px', + fontWeight: 800, + color: accentInk, + letterSpacing: '0.14em', + textTransform: 'uppercase' as const, + }, + children: 'Welcome to the team', + }, + }, + { + type: 'div', + props: { + style: { display: 'flex', alignItems: 'center', marginTop: '28px' }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + width: '128px', + height: '128px', + borderRadius: '64px', + backgroundColor: accentInk, + marginRight: '36px', + }, + children: { + type: 'span', + props: { + style: { + fontSize: '52px', + fontWeight: 700, + color: contrastText(accentInk), + }, + children: initials, + }, + }, + }, + }, + { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'column', flexGrow: 1, flexShrink: 1, minWidth: 0 }, + children: [ + { + type: 'h1', + props: { + style: { + fontSize: `${nameSize}px`, + fontWeight: 700, + color: textColor, + lineHeight: 1.1, + margin: 0, + }, + children: name, + }, + }, + ...(role + ? [{ + type: 'span', + props: { + style: { + fontSize: `${roleSize}px`, + fontWeight: 500, + color: `${textColor}CC`, + marginTop: '10px', + }, + children: role, + }, + }] + : []), + ], + }, + }, + ], + }, + }, + ], + }, + }, + // Team, start date and note + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + justifyContent: 'center', + }, + children: [ + ...(details.length + ? [{ + type: 'div', + props: { + style: { display: 'flex', alignItems: 'center', columnGap: '16px' }, + children: details, + }, + }] + : []), + ...(note + ? [{ + type: 'span', + props: { + style: { + fontSize: `${noteSize}px`, + fontWeight: 400, + color: `${textColor}99`, + marginTop: details.length ? '22px' : '0px', + }, + children: note, + }, + }] + : []), + ], + }, + }, + // Site name + ...(siteName + ? [{ + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 600, + color: `${textColor}80`, + letterSpacing: '0.06em', + }, + children: siteName, + }, + }] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 59430a5..7d71171 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -308,11 +308,12 @@ import jobInterviewProcess from './job/interview-process'; import jobBenefits from './job/benefits'; import jobInternship from './job/internship'; import jobReferral from './job/referral'; +import jobNewHire from './job/new-hire'; [ jobListingCard, jobTechStackRole, jobRemoteBadge, jobCompanyCulture, jobSalaryHighlight, jobTeamPhoto, jobStartupHiring, jobDeveloperRole, - jobOpenRoles, jobInterviewProcess, jobBenefits, jobInternship, jobReferral, + jobOpenRoles, jobInterviewProcess, jobBenefits, jobInternship, jobReferral, jobNewHire, ].forEach(registerTemplate); // Tutorial diff --git a/tests/templates/job-new-hire.test.ts b/tests/templates/job-new-hire.test.ts new file mode 100644 index 0000000..c14eb01 --- /dev/null +++ b/tests/templates/job-new-hire.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest'; +import newHire from '@/templates/job/new-hire'; +import { getInitials } from '@/templates/github/contributors-wall'; +import { getTemplate } from '@/templates/registry'; +import { contrastText, isLightColor, mixColors } from '@/templates/utils'; +import type { SatoriElement } from '@/templates/types'; + +function elements(node: unknown): SatoriElement[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!node || typeof node !== 'object' || !('type' in node)) return []; + + const element = node as SatoriElement; + return [element, ...elements(element.props.children)]; +} + +function textContent(node: unknown): string[] { + if (typeof node === 'string') return [node]; + if (Array.isArray(node)) return node.flatMap(textContent); + if (node && typeof node === 'object' && 'props' in node) { + return textContent((node as SatoriElement).props.children); + } + return []; +} + +const render = (overrides: Record = {}) => + newHire.render({ ...newHire.defaults, ...overrides }); + +const span = (tree: SatoriElement, text: string) => + elements(tree).find((element) => element.type === 'span' && textContent(element)[0] === text); + +// The circle is the one div with a fixed, equal width and height. +const circle = (tree: SatoriElement) => + elements(tree).find((element) => element.type === 'div' && element.props.style?.borderRadius === '64px'); + +const initialsOf = (title: string) => textContent(circle(render({ title })))[0] ?? ''; + +describe('job-new-hire', () => { + it('is registered in the job category and re-exported', async () => { + expect(getTemplate('job-new-hire')?.category).toBe('job'); + const exports = await import('@/templates/job'); + expect(exports.jobNewHire.id).toBe('job-new-hire'); + }); + + it('has the fields the issue asks for, with the defaults it names', () => { + expect(newHire.fields.map((field) => field.key)).toEqual([ + 'title', 'role', 'team', 'startDate', 'description', 'siteName', 'bgColor', 'accentColor', 'textColor', + ]); + expect(newHire.defaults).toMatchObject({ + title: 'Priya Sharma', + role: 'Senior Backend Engineer', + team: 'Platform team', + startDate: 'Starts Nov 3', + description: 'Excited to help build the payments stack', + }); + }); + + it('is 1200 by 630 and every div is flex', () => { + const tree = render(); + expect(tree.props.style).toMatchObject({ width: '1200px', height: '630px' }); + for (const element of elements(tree).filter((el) => el.type === 'div')) { + expect(element.props.style?.display).toBe('flex'); + } + }); + + it('renders the label, initials, name, role, team, start date, note and site name in order', () => { + expect(textContent(render())).toEqual([ + 'Welcome to the team', + 'PS', + 'Priya Sharma', + 'Senior Backend Engineer', + 'Platform team', + '·', + 'Starts Nov 3', + 'Excited to help build the payments stack', + 'acme.com', + ]); + }); + + it('hides a blank role, team, start date, note or site name, and one of only spaces', () => { + expect(textContent(render({ role: '' }))).not.toContain('Senior Backend Engineer'); + expect(textContent(render({ role: ' ' }))).not.toContain('Senior Backend Engineer'); + expect(textContent(render({ description: ' ' }))).not.toContain('Excited to help build the payments stack'); + expect(textContent(render({ siteName: ' ' }))).not.toContain('acme.com'); + expect(textContent(render({ siteName: '' }))).not.toContain('acme.com'); + }); + + it('drops the separator together with whichever of team and start date is blank', () => { + expect(textContent(render({ team: ' ' }))).toEqual([ + 'Welcome to the team', 'PS', 'Priya Sharma', 'Senior Backend Engineer', + 'Starts Nov 3', 'Excited to help build the payments stack', 'acme.com', + ]); + expect(textContent(render({ startDate: '' }))).toEqual([ + 'Welcome to the team', 'PS', 'Priya Sharma', 'Senior Backend Engineer', + 'Platform team', 'Excited to help build the payments stack', 'acme.com', + ]); + expect(textContent(render({ team: '', startDate: '' }))).not.toContain('·'); + }); + + it('leaves no gap where an optional block is hidden', () => { + const noDetails = render({ team: '', startDate: '' }); + const note = span(noDetails, 'Excited to help build the payments stack'); + expect(note?.props.style?.marginTop).toBe('0px'); + const withDetails = span(render(), 'Excited to help build the payments stack'); + expect(withDetails?.props.style?.marginTop).toBe('22px'); + + const name = elements(render({ role: '' })).find((element) => element.type === 'h1'); + const roleless = elements(render({ role: '' })).filter((element) => element.type === 'span' && + element.props.style?.marginTop === '10px'); + expect(textContent(name)[0]).toBe('Priya Sharma'); + expect(roleless).toHaveLength(0); + }); + + it('renders only the label, the initials and the name when every optional field is blank', () => { + expect(textContent(render({ role: '', team: '', startDate: '', description: '', siteName: '' }))).toEqual([ + 'Welcome to the team', + 'PS', + 'Priya Sharma', + ]); + }); + + it('falls back to the defaults when optional fields are omitted, and to the default name when the title is blank', () => { + expect(textContent(newHire.render({}))).toEqual(textContent(render())); + expect(textContent(render({ title: ' ' }))[2]).toBe('Priya Sharma'); + }); + + describe('initials', () => { + it('are the first letters of the first and the last word', () => { + expect(initialsOf('Priya Sharma')).toBe('PS'); + expect(initialsOf('priya sharma')).toBe('PS'); + expect(initialsOf('Mary Jane Watson')).toBe('MW'); + expect(initialsOf(' Priya Sharma ')).toBe('PS'); + }); + + it('are one letter for a one-word name', () => { + expect(initialsOf('Madonna')).toBe('M'); + }); + + it('agree with getInitials for ordinary names', () => { + for (const name of ['Priya Sharma', 'Madonna', 'Mary Jane Watson', 'élan vital', 'Ada Lovelace']) { + expect(initialsOf(name)).toBe(getInitials(name)); + } + }); + + it('skip leading punctuation, @ and emoji instead of drawing a broken glyph', () => { + expect(initialsOf('@octocat')).toBe('O'); + expect(initialsOf("(Priya) O'Neil")).toBe('PO'); + expect(initialsOf('\u{1F600} Smith')).toBe('S'); + expect(initialsOf('\u{1F600}')).toBe(''); + }); + + it('keep non-Latin and astral letters whole', () => { + expect(initialsOf('李雷 王')).toBe('李王'); + expect(initialsOf('\u{20BB7}野 家')).toBe('\u{20BB7}家'); + }); + + it('never contain half of a surrogate pair', () => { + for (const name of ['\u{1F600} \u{1F601}', '\u{20BB7}野', '\u{1D54F}ander Smith']) { + for (const ch of Array.from(initialsOf(name))) { + const cp = ch.codePointAt(0) as number; + expect(cp >= 0xd800 && cp <= 0xdfff).toBe(false); + } + } + }); + + it('sit in a circle of the same size, centered, for any name', () => { + for (const title of ['Madonna', 'Priya Sharma', 'Alexandria Montgomery-Featherstonehaugh III', '\u{1F600}']) { + const style = circle(render({ title }))?.props.style; + expect(style).toMatchObject({ + width: '128px', + height: '128px', + borderRadius: '64px', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }); + } + }); + }); + + it('shrinks a long name, role and note with autoFontSize and then truncates them', () => { + const sizeOf = (tree: SatoriElement, text: string) => + elements(tree).find((element) => textContent(element)[0] === text && element.props.style?.fontSize)?.props.style?.fontSize; + + const nameTree = (title: string) => elements(render({ title })).find((element) => element.type === 'h1'); + expect(nameTree('Priya Sharma')?.props.style?.fontSize).toBe('64px'); + expect(nameTree('Alexandria Montgomery-Smith')?.props.style?.fontSize).toBe('46px'); + // Each band ends on its maxLen: 18 -> 64, 26 -> 54, 36 -> 46, then 38. + const bands: [number, string][] = [[18, '64px'], [19, '54px'], [26, '54px'], [27, '46px'], [36, '46px'], [37, '38px']]; + for (const [length, size] of bands) { + expect(nameTree('A'.repeat(length))?.props.style?.fontSize).toBe(size); + } + + const longName = 'Alexandria Montgomery-Featherstonehaugh III of the Northern Reaches'; + const heading = nameTree(longName); + expect(textContent(heading)[0].length).toBeLessThanOrEqual(48); + expect(textContent(heading)[0]).toMatch(/…$/); + expect(heading?.props.style?.fontSize).toBe('38px'); + + const longRole = 'Principal Distributed Systems Reliability Engineer, Payments Platform and Fraud'; + const role = span(render({ role: longRole }), `${longRole.slice(0, 59).trimEnd()}…`); + expect(role).toBeDefined(); + expect(role?.props.style?.fontSize).toBe('22px'); + expect(span(render({ role: 'Engineer' }), 'Engineer')?.props.style?.fontSize).toBe('30px'); + + const longNote = 'Excited to help build the payments stack and to work with the fraud, risk, identity and compliance teams on the next generation of the platform'; + const note = elements(render({ description: longNote })).find((element) => element.type === 'span' && + textContent(element)[0].startsWith('Excited')); + expect(textContent(note)[0].length).toBeLessThanOrEqual(120); + expect(textContent(note)[0]).toMatch(/…$/); + expect(note?.props.style?.fontSize).toBe('22px'); + expect(sizeOf(render({ description: 'Welcome aboard' }), 'Welcome aboard')).toBe('28px'); + }); + + it('truncates a long team and start date', () => { + const texts = textContent(render({ + team: 'The Extremely Long Platform Engineering Team', + startDate: 'Starts on the third of November 2026', + })); + const team = texts[4]; + const start = texts[6]; + expect(team.length).toBeLessThanOrEqual(32); + expect(team).toMatch(/…$/); + expect(start.length).toBeLessThanOrEqual(32); + expect(start).toMatch(/…$/); + }); + + it('keeps the label, the circle and the start date readable on a light background', () => { + const dark = render(); + expect(span(dark, 'Welcome to the team')?.props.style?.color).toBe('#E07A5F'); + expect(circle(dark)?.props.style?.backgroundColor).toBe('#E07A5F'); + expect(span(dark, 'Starts Nov 3')?.props.style?.color).toBe('#E07A5F'); + + const light = render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' }); + expect(isLightColor('#ffffff')).toBe(true); + const ink = mixColors('#fde047', '#000000', 0.45); + expect(ink).toBe('#8b7b27'); + expect(span(light, 'Welcome to the team')?.props.style?.color).toBe(ink); + expect(span(light, 'Starts Nov 3')?.props.style?.color).toBe(ink); + expect(circle(light)?.props.style?.backgroundColor).toBe(ink); + + const initials = elements(circle(light)).find((element) => element.type === 'span'); + expect(initials?.props.style?.color).toBe(contrastText(ink)); + const darkInitials = elements(circle(dark)).find((element) => element.type === 'span'); + expect(darkInitials?.props.style?.color).toBe(contrastText('#E07A5F')); + + expect(elements(light).find((element) => element.type === 'h1')?.props.style?.color).toBe('#111111'); + expect(span(light, 'Platform team')?.props.style?.color).toBe('#111111'); + }); +});