Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 70bace54357d4b57b674108f
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Editor.tsx
// The resume editor: the form on the left, the paper on the right (an Edit / Preview switch on phones).
// With a letter, a Resume / Cover letter switch edits the cover letter in the same template.
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode, type TextareaHTMLAttributes } from "react";
import {
blankWork, blankEducation, blankSkill, blankProject, blankLanguage, blankCertificate, blankVolunteer, blankAward, blankPublication, blankInterest,
isEmptyEntry, sectionTitle, dateRange, fmtDate, degreeOf, colorOf,
COLORS, COLOR_IDS, CORE_SECTIONS, DEFAULT_TITLES, LIMITS, PAPERS, TEMPLATES,
type Basics, type ColorId, type ListKey, type Resume, type SectionKey, type Settings,
} from "../functions/lib/resume";
import { layout, layoutLetter, REFLOW_WIDTH, TEMPLATE_NAMES } from "./layout";
import { Paper } from "./Paper";
import { Menu, toast, type MenuItem } from "./ui";
import type { Letter } from "./store";
export type Mode = "resume" | "letter";
export interface EditorError { text: string; field?: "name" }
const icons = {
up: <path d="m5 12 5-5 5 5" />,
down: <path d="m5 8 5 5 5-5" />,
x: <path d="M5.5 5.5l9 9M14.5 5.5l-9 9" />,
plus: <path d="M10 4v12M4 10h12" />,
dots: <><circle cx="4.5" cy="10" r=".9" fill="currentColor" /><circle cx="10" cy="10" r=".9" fill="currentColor" /><circle cx="15.5" cy="10" r=".9" fill="currentColor" /></>,
};
function Icon({ name, size = 16 }: { name: keyof typeof icons; size?: number }) {
return <svg width={size} height={size} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{icons[name]}</svg>;
}
function IconBtn({ icon, label, onClick, disabled }: { icon: keyof typeof icons; label: string; onClick: () => void; disabled?: boolean }) {
return <button className="btn btn-quiet btn-icon btn-sm icon-tool" type="button" aria-label={label} title={label} onClick={onClick} disabled={disabled}><Icon name={icon} /></button>;
}
function Field({ label, value, onChange, placeholder, type = "text", max = LIMITS.short, autoComplete = "off", error, id }: {
label: string; value: string; onChange: (v: string) => void; placeholder?: string; type?: string; max?: number; autoComplete?: string; error?: string; id?: string;
}) {
return (
<label className="field">
<span>{label}</span>
<input className="input" id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} maxLength={max} autoComplete={autoComplete}
aria-invalid={error ? true : undefined} aria-describedby={error && id ? `${id}-error` : undefined} />
{error && <span className="field-error" id={id ? `${id}-error` : undefined}>{error}</span>}
</label>
);
}
/** A textarea that grows with its text. */
function Grow({ inputRef, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { value: string; inputRef?: (el: HTMLTextAreaElement | null) => void }) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight + 2}px`;
}, [props.value]);
return <textarea rows={1} {...props} ref={(el) => { ref.current = el; inputRef?.(el); }} />;
}
const moved = <T,>(list: T[], i: number, d: number): T[] => {
const j = i + d;
if (j < 0 || j >= list.length) return list;
const next = [...list];
[next[i], next[j]] = [next[j], next[i]];
return next;
};
/** Bullet points: Enter starts a new one, Backspace at the start joins it to the one above, pasted lines become bullets. */
function Bullets({ items, onChange, placeholder, addLabel }: { items: string[]; onChange: (v: string[]) => void; placeholder: string; addLabel: string }) {
const refs = useRef<(HTMLTextAreaElement | null)[]>([]);
const focus = useRef<{ i: number; at: number } | null>(null);
useLayoutEffect(() => {
const f = focus.current;
focus.current = null;
const el = f && refs.current[f.i];
if (!el) return;
el.focus();
const at = f.at < 0 ? el.value.length : f.at;
el.setSelectionRange(at, at);
});
const set = (next: string[], i?: number, at = -1) => {
if (i != null) focus.current = { i, at };
onChange(next);
};
const change = (i: number, value: string) => {
if (!value.includes("\n")) return set(items.map((b, j) => (j === i ? value.slice(0, LIMITS.bullet) : b)));
const parts = value.split(/\r?\n/).map((s) => s.replace(/^\s*(?:[-*•▪◦]|\d+[.)])\s+/, "").slice(0, LIMITS.bullet)).filter((s, k) => k === 0 || s.trim());
const next = [...items.slice(0, i), ...parts, ...items.slice(i + 1)].slice(0, LIMITS.bullets);
set(next, Math.min(i + parts.length - 1, next.length - 1));
};
const key = (i: number, e: KeyboardEvent<HTMLTextAreaElement>) => {
const el = e.currentTarget;
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
if (items.length >= LIMITS.bullets) return;
set([...items.slice(0, i), el.value.slice(0, el.selectionStart), el.value.slice(el.selectionEnd), ...items.slice(i + 1)], i + 1, 0);
} else if (e.key === "Backspace" && i > 0 && el.selectionStart === 0 && el.selectionEnd === 0) {
e.preventDefault();
const prev = items[i - 1];
set([...items.slice(0, i - 1), prev + el.value, ...items.slice(i + 1)], i - 1, prev.length);
} else if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
e.preventDefault();
const d = e.key === "ArrowUp" ? -1 : 1;
if (i + d >= 0 && i + d < items.length) set(moved(items, i, d), i + d);
}
};
return (
<div className="bullets-wrap">
{items.length > 0 && (
<ul className="bullets">
{items.map((b, i) => (
<li className="bullet" key={i}>
<span className="bullet-dot" aria-hidden="true">•</span>
<Grow inputRef={(el) => { refs.current[i] = el; }} className="textarea bullet-input" value={b} placeholder={i === 0 ? placeholder : ""}
onChange={(e) => change(i, e.target.value)} onKeyDown={(e) => key(i, e)} aria-label={`Bullet ${i + 1}`} />
<span className="row-tools wide-only">
<IconBtn icon="up" label="Move bullet up" onClick={() => set(moved(items, i, -1), i - 1)} disabled={i === 0} />
<IconBtn icon="down" label="Move bullet down" onClick={() => set(moved(items, i, 1), i + 1)} disabled={i === items.length - 1} />
<IconBtn icon="x" label="Remove bullet" onClick={() => set(items.filter((_, j) => j !== i), i > 0 ? i - 1 : undefined)} />
</span>
{/* On narrow screens one button holds the same three, so the bullet keeps the width. */}
<span className="row-tools narrow-only">
<Menu className="btn btn-quiet btn-icon btn-sm icon-tool" ariaLabel={`Bullet ${i + 1} options`} end label={<Icon name="dots" size={18} />} items={[
...(i > 0 ? [{ label: "Move up", onSelect: () => set(moved(items, i, -1), i - 1) }] : []),
...(i < items.length - 1 ? [{ label: "Move down", onSelect: () => set(moved(items, i, 1), i + 1) }] : []),
{ label: "Remove bullet", danger: true, onSelect: () => set(items.filter((_, j) => j !== i)) },
]} />
</span>
</li>
))}
</ul>
)}
{items.length < LIMITS.bullets && (
<button className="btn btn-sm btn-quiet add-bullet" type="button" onClick={() => set([...items, ""], items.length)}><Icon name="plus" size={14} />{addLabel}</button>
)}
</div>
);
}
/** A list of entries (jobs, schools...) that can be added, removed (with Undo), reordered and folded. */
function Entries<T extends { id: string }>({ items, onChange, onRemove, make, addLabel, noun, title, sub, flat, children }: {
items: T[]; onChange: (items: T[]) => void; onRemove: (i: number) => void; make: () => T; addLabel: string; noun: string;
title?: (e: T) => string; sub?: (e: T) => string; flat?: boolean;
children: (e: T, set: (patch: Partial<T>) => void) => ReactNode;
}) {
const [open, setOpen] = useState<Set<string>>(() => new Set(items.filter((e) => isEmptyEntry(e)).map((e) => e.id)));
const [leaving, setLeaving] = useState<string | null>(null);
const fresh = useRef(new Set<string>());
const toggle = (id: string) => setOpen((s) => {
const next = new Set(s);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const add = () => {
const e = make();
fresh.current.add(e.id);
setOpen((s) => new Set(s).add(e.id));
onChange([...items, e]);
};
const tools = (i: number, e: T) => (
<span className="row-tools">
<IconBtn icon="up" label={`Move ${noun} up`} onClick={() => onChange(moved(items, i, -1))} disabled={i === 0} />
<IconBtn icon="down" label={`Move ${noun} down`} onClick={() => onChange(moved(items, i, 1))} disabled={i === items.length - 1} />
<IconBtn icon="x" label={`Remove ${noun}`} onClick={() => setLeaving(e.id)} />
</span>
);
const done = (e: { animationName: string }, i: number, id: string) => {
if (leaving === id && e.animationName === "oa-leave") { setLeaving(null); onRemove(i); }
};
const cls = (base: string, id: string) => `${base}${fresh.current.has(id) ? " enter" : ""}${leaving === id ? " leaving" : ""}`;
return (
<>
{items.length > 0 && (
<ol className={flat ? "rows" : "entries"}>
{items.map((e, i) => {
const set = (patch: Partial<T>) => onChange(items.map((x, j) => (j === i ? { ...x, ...patch } : x)));
if (flat) return <li className={cls("row-item", e.id)} key={e.id} onAnimationEnd={(ev) => done(ev, i, e.id)}><div className="row-fields">{children(e, set)}</div>{tools(i, e)}</li>;
const isOpen = open.has(e.id);
const s = sub?.(e);
return (
<li className={cls("entry", e.id)} key={e.id} onAnimationEnd={(ev) => done(ev, i, e.id)}>
<div className="entry-head">
<button className="entry-toggle" type="button" aria-expanded={isOpen} onClick={() => toggle(e.id)}>
<span className="entry-chev" data-open={isOpen}><Icon name="down" size={14} /></span>
<span className="entry-name">
<strong>{title?.(e) || `New ${noun}`}</strong>
{s && <span className="muted">{s}</span>}
</span>
</button>
{tools(i, e)}
</div>
{isOpen && <div className="entry-body">{children(e, set)}</div>}
</li>
);
})}
</ol>
)}
{items.length < LIMITS.entries && (
<button className="btn btn-sm btn-line add-btn" type="button" onClick={add}><Icon name="plus" size={14} />{addLabel}</button>
)}
</>
);
}
const DATE_HINT = "Mar 2021";
const OPTIONAL_NOUN: Record<ListKey, string> = {
work: "job", education: "school", skills: "skill group", projects: "project", volunteer: "role", awards: "award",
certificates: "certification", publications: "publication", languages: "language", interests: "interest",
};
const BLANK: Record<ListKey, () => { id: string }> = {
work: blankWork, education: blankEducation, skills: blankSkill, projects: blankProject, volunteer: blankVolunteer, awards: blankAward,
certificates: blankCertificate, publications: blankPublication, languages: blankLanguage, interests: blankInterest,
};
export function ResumeForm({ resume: r, onChange, error }: { resume: Resume; onChange: (r: Resume) => void; error?: EditorError | null }) {
const latest = useRef(r);
latest.current = r;
const [renaming, setRenaming] = useState<SectionKey | null>(null);
const [leaving, setLeaving] = useState<SectionKey | null>(null);
const [added, setAdded] = useState<SectionKey | null>(null);
const set = (patch: Partial<Resume>) => onChange({ ...r, ...patch });
const setBasics = (patch: Partial<Basics>) => set({ basics: { ...r.basics, ...patch } });
const setSettings = (patch: Partial<Settings>) => set({ settings: { ...r.settings, ...patch } });
const b = r.basics;
const order = r.settings.order;
const links = b.links.length ? b.links : [""];
const shown = (k: SectionKey) => CORE_SECTIONS.includes(k) || (k !== "summary" && r[k].length > 0);
const visible = order.filter(shown);
const hidden = order.filter((k) => !shown(k));
/** Removes an entry, then offers to put it back where it was. */
const removeAt = (key: ListKey, i: number) => {
const list = r[key] as { id: string }[];
const gone = list[i];
onChange({ ...r, [key]: list.filter((_, j) => j !== i) });
if (isEmptyEntry(gone)) return;
toast(`${OPTIONAL_NOUN[key][0].toUpperCase()}${OPTIONAL_NOUN[key].slice(1)} removed.`, {
label: "Undo",
run: () => {
const now = latest.current;
const back = [...(now[key] as { id: string }[])];
back.splice(Math.min(i, back.length), 0, gone);
onChange({ ...now, [key]: back });
},
});
};
const removeSection = (key: ListKey) => {
const kept = r[key];
onChange({ ...r, [key]: [] });
if (!(kept as object[]).some((e) => !isEmptyEntry(e))) return;
toast(`${sectionTitle(r, key)} removed.`, { label: "Undo", run: () => onChange({ ...latest.current, [key]: kept }) });
};
const addSection = (key: ListKey) => {
setAdded(key);
onChange({ ...r, [key]: [BLANK[key]()] });
requestAnimationFrame(() => {
const el = document.getElementById(`section-${key}`);
el?.scrollIntoView({ block: "nearest" });
el?.querySelector<HTMLElement>(".entry-body input, .row-fields input")?.focus({ preventScroll: true });
});
};
const list = <K extends ListKey>(key: K) => ({
items: r[key],
onChange: (items: Resume[K]) => set({ [key]: items } as Partial<Resume>),
onRemove: (i: number) => removeAt(key, i),
make: BLANK[key] as () => Resume[K][number],
noun: OPTIONAL_NOUN[key],
});
const body: Record<SectionKey, () => ReactNode> = {
summary: () => (
<Grow className="textarea summary-input" value={b.summary} onChange={(e) => setBasics({ summary: e.target.value })} maxLength={LIMITS.text} aria-label="Summary"
placeholder="Two or three sentences: what you do, what you are good at, what you want next." />
),
work: () => (
<Entries {...list("work")} addLabel="Add job" title={(w) => [w.position, w.name].filter(Boolean).join(", ")} sub={(w) => dateRange(w.startDate, w.endDate)}>
{(w, setW) => (
<div className="stack fields">
<div className="two">
<Field label="Job title" value={w.position} onChange={(position) => setW({ position })} placeholder="Product designer" />
<Field label="Company" value={w.name} onChange={(name) => setW({ name })} placeholder="Acme Inc." />
</div>
<div className="three">
<Field label="Location" value={w.location} onChange={(location) => setW({ location })} placeholder="Remote" />
<Field label="Start" value={w.startDate} onChange={(startDate) => setW({ startDate })} placeholder={DATE_HINT} max={40} />
<Field label="End" value={w.endDate} onChange={(endDate) => setW({ endDate })} placeholder="Present" max={40} />
</div>
<label className="field"><span>About the role <em>optional</em></span>
<Grow className="textarea" value={w.summary} onChange={(e) => setW({ summary: e.target.value })} maxLength={LIMITS.text} placeholder="One line about the team or the product." />
</label>
<div className="field"><span>What you did</span>
<Bullets items={w.highlights} onChange={(highlights) => setW({ highlights })} addLabel="Add bullet"
placeholder="Led the redesign of checkout, which lifted conversion by 12%" />
</div>
</div>
)}
</Entries>
),
education: () => (
<Entries {...list("education")} addLabel="Add school" title={(e) => e.institution || degreeOf(e)} sub={(e) => dateRange(e.startDate, e.endDate)}>
{(e, setE) => (
<div className="stack fields">
<Field label="School" value={e.institution} onChange={(institution) => setE({ institution })} placeholder="University of Lisbon" />
<div className="two">
<Field label="Degree" value={e.studyType} onChange={(studyType) => setE({ studyType })} placeholder="BSc" />
<Field label="Field of study" value={e.area} onChange={(area) => setE({ area })} placeholder="Computer Science" />
</div>
<div className="three">
<Field label="Location" value={e.location} onChange={(location) => setE({ location })} placeholder="Lisbon" />
<Field label="Start" value={e.startDate} onChange={(startDate) => setE({ startDate })} placeholder="2016" max={40} />
<Field label="End" value={e.endDate} onChange={(endDate) => setE({ endDate })} placeholder="2020" max={40} />
</div>
<Field label="Grade" value={e.score} onChange={(score) => setE({ score })} placeholder="GPA 3.8, First class honours" max={60} />
<div className="field"><span>Details <em>optional</em></span>
<Bullets items={e.courses} onChange={(courses) => setE({ courses })} addLabel="Add detail" placeholder="Honors, thesis or relevant courses" />
</div>
</div>
)}
</Entries>
),
skills: () => (
<Entries {...list("skills")} addLabel="Add skills" flat>
{(s, setS) => (
<div className="pair-row">
<input className="input" value={s.name} onChange={(e) => setS({ name: e.target.value })} placeholder="Group (optional)" aria-label="Skill group" maxLength={LIMITS.short} />
<input className="input" value={s.keywords} onChange={(e) => setS({ keywords: e.target.value })} placeholder="Figma, prototyping, user research" aria-label="Skills, separated by commas" maxLength={LIMITS.bullet} />
</div>
)}
</Entries>
),
projects: () => (
<Entries {...list("projects")} addLabel="Add project" title={(p) => p.name} sub={(p) => dateRange(p.startDate, p.endDate)}>
{(p, setP) => (
<div className="stack fields">
<div className="two">
<Field label="Project" value={p.name} onChange={(name) => setP({ name })} placeholder="Open source design kit" />
<Field label="Link" value={p.url} onChange={(url) => setP({ url })} placeholder="github.com/you/project" max={300} />
</div>
<div className="two dates">
<Field label="Start" value={p.startDate} onChange={(startDate) => setP({ startDate })} placeholder={DATE_HINT} max={40} />
<Field label="End" value={p.endDate} onChange={(endDate) => setP({ endDate })} placeholder="Present" max={40} />
</div>
<label className="field"><span>Description <em>optional</em></span>
<Grow className="textarea" value={p.description} onChange={(e) => setP({ description: e.target.value })} maxLength={LIMITS.text} placeholder="What it is and who uses it." />
</label>
<div className="field"><span>Highlights</span>
<Bullets items={p.highlights} onChange={(highlights) => setP({ highlights })} addLabel="Add bullet" placeholder="Used by 40 teams; 2,000 stars on GitHub" />
</div>
</div>
)}
</Entries>
),
volunteer: () => (
<Entries {...list("volunteer")} addLabel="Add role" title={(v) => [v.position, v.organization].filter(Boolean).join(", ")} sub={(v) => dateRange(v.startDate, v.endDate)}>
{(v, setV) => (
<div className="stack fields">
<div className="two">
<Field label="Role" value={v.position} onChange={(position) => setV({ position })} placeholder="Mentor" />
<Field label="Organization" value={v.organization} onChange={(organization) => setV({ organization })} placeholder="Code for Lisbon" />
</div>
<div className="two dates">
<Field label="Start" value={v.startDate} onChange={(startDate) => setV({ startDate })} placeholder={DATE_HINT} max={40} />
<Field label="End" value={v.endDate} onChange={(endDate) => setV({ endDate })} placeholder="Present" max={40} />
</div>
<label className="field"><span>About it <em>optional</em></span>
<Grow className="textarea" value={v.summary} onChange={(e) => setV({ summary: e.target.value })} maxLength={LIMITS.text} placeholder="What the organization does." />
</label>
<div className="field"><span>What you did</span>
<Bullets items={v.highlights} onChange={(highlights) => setV({ highlights })} addLabel="Add bullet" placeholder="Mentored 12 new developers through their first project" />
</div>
</div>
)}
</Entries>
),
awards: () => (
<Entries {...list("awards")} addLabel="Add award" title={(a) => a.title} sub={(a) => [a.awarder, fmtDate(a.date)].filter(Boolean).join(", ")}>
{(a, setA) => (
<div className="stack fields">
<Field label="Award" value={a.title} onChange={(title) => setA({ title })} placeholder="Employee of the year" />
<div className="two">
<Field label="Given by" value={a.awarder} onChange={(awarder) => setA({ awarder })} placeholder="Acme Inc." />
<Field label="Date" value={a.date} onChange={(date) => setA({ date })} placeholder="2023" max={40} />
</div>
<label className="field"><span>What it was for <em>optional</em></span>
<Grow className="textarea" value={a.summary} onChange={(e) => setA({ summary: e.target.value })} maxLength={LIMITS.text} placeholder="One line about why you got it." />
</label>
</div>
)}
</Entries>
),
certificates: () => (
<Entries {...list("certificates")} addLabel="Add certification" flat>
{(c, setC) => (
<div className="cert-row">
<input className="input cert-name" value={c.name} onChange={(e) => setC({ name: e.target.value })} placeholder="AWS Certified Developer" aria-label="Certification" maxLength={LIMITS.short} />
<input className="input" value={c.issuer} onChange={(e) => setC({ issuer: e.target.value })} placeholder="Issued by" aria-label="Issued by" maxLength={LIMITS.short} />
<input className="input" value={c.date} onChange={(e) => setC({ date: e.target.value })} placeholder="2024" aria-label="Date" maxLength={40} />
<input className="input cert-link" value={c.url} onChange={(e) => setC({ url: e.target.value })} placeholder="Link (optional)" aria-label="Link" maxLength={300} />
</div>
)}
</Entries>
),
publications: () => (
<Entries {...list("publications")} addLabel="Add publication" title={(p) => p.name} sub={(p) => [p.publisher, fmtDate(p.releaseDate)].filter(Boolean).join(", ")}>
{(p, setP) => (
<div className="stack fields">
<Field label="Title" value={p.name} onChange={(name) => setP({ name })} placeholder="Designing for trust" />
<div className="three">
<Field label="Published in" value={p.publisher} onChange={(publisher) => setP({ publisher })} placeholder="UX Journal" />
<Field label="Date" value={p.releaseDate} onChange={(releaseDate) => setP({ releaseDate })} placeholder="2022" max={40} />
<Field label="Link" value={p.url} onChange={(url) => setP({ url })} placeholder="doi.org/..." max={300} />
</div>
<label className="field"><span>Summary <em>optional</em></span>
<Grow className="textarea" value={p.summary} onChange={(e) => setP({ summary: e.target.value })} maxLength={LIMITS.text} placeholder="One line about it." />
</label>
</div>
)}
</Entries>
),
languages: () => (
<Entries {...list("languages")} addLabel="Add language" flat>
{(l, setL) => (
<div className="pair-row pair-even">
<input className="input" value={l.language} onChange={(e) => setL({ language: e.target.value })} placeholder="Spanish" aria-label="Language" maxLength={LIMITS.short} />
<input className="input" value={l.fluency} onChange={(e) => setL({ fluency: e.target.value })} placeholder="Native, fluent, B2..." aria-label="Level" maxLength={LIMITS.short} />
</div>
)}
</Entries>
),
interests: () => (
<Entries {...list("interests")} addLabel="Add interest" flat>
{(s, setS) => (
<div className="pair-row">
<input className="input" value={s.name} onChange={(e) => setS({ name: e.target.value })} placeholder="Group (optional)" aria-label="Interest group" maxLength={LIMITS.short} />
<input className="input" value={s.keywords} onChange={(e) => setS({ keywords: e.target.value })} placeholder="Climbing, film photography" aria-label="Interests, separated by commas" maxLength={LIMITS.bullet} />
</div>
)}
</Entries>
),
};
return (
<div className="form">
<section className="form-section">
<h2 className="form-title">Contact</h2>
<div className="stack fields">
<div className="two">
<Field id="field-name" label="Full name" value={b.name} onChange={(name) => setBasics({ name })} placeholder="Sam Rivera" max={120} autoComplete="name"
error={error?.field === "name" && !b.name.trim() ? error.text : undefined} />
<Field label="Job title" value={b.label} onChange={(label) => setBasics({ label })} placeholder="Product designer" autoComplete="organization-title" />
</div>
<div className="two">
<Field label="Email" type="email" value={b.email} onChange={(email) => setBasics({ email })} placeholder="sam@example.com" autoComplete="email" />
<Field label="Phone" type="tel" value={b.phone} onChange={(phone) => setBasics({ phone })} placeholder="+1 555 010 0199" max={60} autoComplete="tel" />
</div>
<Field label="Location" value={b.location} onChange={(location) => setBasics({ location })} placeholder="Portland, OR" autoComplete="address-level2" />
<div className="field">
<span>Links</span>
<ul className="links">
{links.map((l, i) => (
<li key={i}>
<input className="input" value={l} onChange={(e) => setBasics({ links: links.map((x, j) => (j === i ? e.target.value : x)) })} maxLength={300}
placeholder={i === 0 ? "linkedin.com/in/samrivera" : "Portfolio, GitHub..."} aria-label={`Link ${i + 1}`} autoComplete="url" />
{(links.length > 1 || l) && <IconBtn icon="x" label="Remove link" onClick={() => setBasics({ links: links.filter((_, j) => j !== i) })} />}
</li>
))}
</ul>
{links.length < LIMITS.links && links[links.length - 1].trim() !== "" && (
<button className="btn btn-sm btn-quiet add-bullet" type="button" onClick={() => setBasics({ links: [...links, ""] })}><Icon name="plus" size={14} />Add link</button>
)}
</div>
</div>
</section>
{visible.map((key, i) => {
const title = sectionTitle(r, key);
// Moving skips sections that are not on the form, so one press always moves it past a visible neighbour.
const move = (d: number) => {
const target = visible[i + d];
if (!target) return;
const next = order.filter((k) => k !== key);
next.splice(next.indexOf(target) + (d > 0 ? 1 : 0), 0, key);
setSettings({ order: next });
};
const options: MenuItem[] = [];
if (i > 0) options.push({ label: "Move up", onSelect: () => move(-1) });
if (i < visible.length - 1) options.push({ label: "Move down", onSelect: () => move(1) });
options.push({ label: "Rename heading", onSelect: () => setRenaming(key) });
if (!CORE_SECTIONS.includes(key)) options.push("-", { label: "Remove section", danger: true, onSelect: () => setLeaving(key) });
return (
<section className={`form-section${added === key ? " enter" : ""}${leaving === key ? " leaving" : ""}`} key={key} id={`section-${key}`}
onAnimationEnd={(e) => {
if (e.target !== e.currentTarget) return;
if (leaving === key && e.animationName === "oa-leave") { setLeaving(null); removeSection(key as ListKey); }
if (added === key) setAdded(null);
}}>
<div className="form-head">
{renaming === key ? (
<input className="input heading-input" autoFocus defaultValue={r.settings.titles[key] ?? ""} placeholder={DEFAULT_TITLES[key]} maxLength={LIMITS.title} aria-label={`Heading for ${DEFAULT_TITLES[key]}`}
onBlur={(e) => { setSettings({ titles: { ...r.settings.titles, [key]: e.target.value.trim() } }); setRenaming(null); }}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") { e.currentTarget.value = r.settings.titles[key] ?? ""; e.currentTarget.blur(); }
}} />
) : (
<h2 className="form-title">{title}</h2>
)}
<Menu className="btn btn-quiet btn-icon btn-sm icon-tool" ariaLabel={`${title} options`} end label={<Icon name="dots" size={18} />} items={options} />
</div>
{body[key]()}
</section>
);
})}
{hidden.length > 0 && (
<section className="form-section add-sections">
<h2 className="form-title">Add a section</h2>
<div className="row add-row">
{hidden.map((k) => (
<button key={k} className="btn btn-sm btn-line" type="button" onClick={() => addSection(k as ListKey)}>
<Icon name="plus" size={14} />{DEFAULT_TITLES[k]}
</button>
))}
</div>
</section>
)}
</div>
);
}
function LetterForm({ letter, onChange }: { letter: Letter; onChange: (l: Letter) => void }) {
return (
<div className="form">
<section className="form-section">
<h2 className="form-title">Cover letter</h2>
<div className="stack fields">
<label className="field"><span>To <em>optional</em></span>
<Grow className="textarea" value={letter.to} onChange={(e) => onChange({ ...letter, to: e.target.value })} maxLength={600}
placeholder={"Alex Lee, Hiring manager\nAcme Inc.\nPortland, OR"} />
</label>
<label className="field"><span>Letter</span>
<Grow className="textarea letter-input" value={letter.body} onChange={(e) => onChange({ ...letter, body: e.target.value })} maxLength={LIMITS.text * 2}
placeholder={"Dear Ms. Lee,\n\nWhy you want this job, in two or three sentences.\n\nOne or two results from your experience that matter for this role.\n\nKind regards,\nSam Rivera"} />
</label>
<p className="hint">Your name and contact details print at the top, from your resume, with today's date. A blank line starts a new paragraph.</p>
</div>
</section>
</div>
);
}
function DesignBar({ resume, onChange, pages }: { resume: Resume; onChange: (r: Resume) => void; pages: number }) {
const s = resume.settings;
const setS = (patch: Partial<Settings>) => onChange({ ...resume, settings: { ...s, ...patch } });
const color = colorOf(s);
return (
<div className="design-bar">
<div className="segmented segmented-3" role="radiogroup" aria-label="Template">
{TEMPLATES.map((t) => (
<button key={t} type="button" role="radio" aria-checked={s.template === t} onClick={() => setS({ template: t })}>{TEMPLATE_NAMES[t]}</button>
))}
</div>
<div className="swatches" role="radiogroup" aria-label="Color">
{COLOR_IDS.map((c: ColorId) => (
<button key={c} type="button" role="radio" aria-checked={color === c} aria-label={c[0].toUpperCase() + c.slice(1)} title={c[0].toUpperCase() + c.slice(1)}
className="swatch" style={{ "--c": COLORS[c] } as CSSProperties} onClick={() => setS({ color: c })} />
))}
</div>
<div className="design-side">
<select className="select select-sm" aria-label="Paper size" value={s.paper} onChange={(e) => setS({ paper: e.target.value as Settings["paper"] })}>
{PAPERS.map((p) => <option key={p} value={p}>{p === "Letter" ? "US Letter" : "A4"}</option>)}
</select>
<span className="pages wide-only" aria-live="polite">{pageCount(pages)}</span>
</div>
</div>
);
}
const pageCount = (n: number) => (n === 1 ? "1 page" : `${n} pages`);
/** Below this width the editor shows the form or the paper, not both; the paper can show reflowed to read. */
const PHONE = "(max-width: 900px)";
export function useMedia(query: string): boolean {
const [on, setOn] = useState(() => matchMedia(query).matches);
useEffect(() => {
const m = matchMedia(query);
const change = () => setOn(m.matches);
m.addEventListener("change", change);
return () => m.removeEventListener("change", change);
}, [query]);
return on;
}
/** The paper, with a Pages / Readable switch on phones: readable lays the same resume out at phone width. */
export function PaperView({ resume, letter, placeholders, label }: { resume: Resume; letter?: Letter; placeholders: boolean; label: string }) {
const narrow = useMedia("(max-width: 640px)");
const [view, setView] = useState<"read" | "pages">("read");
const reflow = narrow && view === "read";
const make = (extra: { reflow?: number }) => (letter ? layoutLetter(resume, letter, { placeholders, ...extra }) : layout(resume, { placeholders, ...extra }));
const printed = useMemo(() => make({}), [resume, letter, placeholders]);
const readable = useMemo(() => (reflow ? make({ reflow: REFLOW_WIDTH }) : null), [resume, letter, placeholders, reflow]);
return (
<div className="paper-view">
{narrow && (
<div className="segmented segmented-2 view-switch" role="radiogroup" aria-label="How to show it">
<button type="button" role="radio" aria-checked={view === "read"} onClick={() => setView("read")}>Readable</button>
<button type="button" role="radio" aria-checked={view === "pages"} onClick={() => setView("pages")}>As printed, {pageCount(printed.pages.length)}</button>
</div>
)}
<Paper layout={readable ?? printed} label={label} reflowed={reflow} />
</div>
);
}
/** Form and paper side by side, with the actions above the paper; on phones one or the other, with a bar at the bottom
* that switches between them and holds the actions. */
export function Editor({ resume, onChange, actions, error, top, letter }: {
resume: Resume; onChange: (r: Resume) => void; actions: (mode: Mode, phone: boolean) => ReactNode; error?: EditorError | null; top?: ReactNode;
letter?: { value: Letter; onChange: (l: Letter) => void };
}) {
const [view, setView] = useState<"edit" | "preview">("edit");
const [mode, setMode] = useState<Mode>("resume");
const root = useRef<HTMLDivElement>(null);
const phone = useMedia(PHONE);
const isLetter = mode === "letter" && letter;
const lay = useMemo(() => (isLetter ? layoutLetter(resume, letter.value, { placeholders: true }) : layout(resume, { placeholders: true })), [resume, isLetter && letter.value]);
const pages = lay.pages.length;
const errorNote = error && !error.field ? <p className="notice notice-error enter" role="alert">{error.text}</p> : null;
// A missing name sends the person to the field.
useEffect(() => {
if (error?.field !== "name") return;
setMode("resume");
setView("edit");
requestAnimationFrame(() => {
const el = document.getElementById("field-name");
el?.scrollIntoView({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
el?.focus({ preventScroll: true });
});
}, [error]);
// Switching starts at the top of the editor, so the paper (or the form) is in view right away.
const show = (v: "edit" | "preview") => {
setView(v);
root.current?.scrollIntoView();
};
return (
<div className="editor" data-view={view} ref={root}>
{(top || letter) && (
<div className="editor-top">
{letter && (
<div className="doc-tabs" role="tablist" aria-label="Document" onKeyDown={(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
const next = mode === "resume" ? "letter" : "resume";
setMode(next);
e.currentTarget.querySelector<HTMLElement>(`[data-mode="${next}"]`)?.focus();
}}>
<button type="button" role="tab" data-mode="resume" aria-selected={mode === "resume"} tabIndex={mode === "resume" ? 0 : -1} onClick={() => setMode("resume")}>Resume</button>
<button type="button" role="tab" data-mode="letter" aria-selected={mode === "letter"} tabIndex={mode === "letter" ? 0 : -1} onClick={() => setMode("letter")}>Cover letter</button>
</div>
)}
{top}
</div>
)}
<div className="editor-grid">
<div className="editor-form card">
{isLetter ? <LetterForm letter={letter.value} onChange={letter.onChange} /> : <ResumeForm resume={resume} onChange={onChange} error={error} />}
</div>
<div className="editor-preview">
<div className="preview-sticky">
{!phone && (
<div className="editor-actions">
{errorNote}
<div className="row">{actions(mode, false)}</div>
</div>
)}
<DesignBar resume={resume} onChange={onChange} pages={pages} />
{lay.missing.length > 0 && (
<p className="notice small">These characters are not in the resume fonts, so they print without accents or as "?": {lay.missing.slice(0, 16).join(" ")}</p>
)}
<div className="preview-scroll">
{phone
? <PaperView resume={resume} letter={isLetter ? letter.value : undefined} placeholders label={isLetter ? "Cover letter preview" : "Resume preview"} />
: <Paper layout={lay} label={isLetter ? "Cover letter preview" : "Resume preview"} />}
</div>
</div>
</div>
</div>
{phone && (
<div className="editor-bar">
{errorNote}
<div className="editor-bar-row">
<button className="btn btn-line bar-toggle" type="button" onClick={() => show(view === "edit" ? "preview" : "edit")}>
{view === "edit" ? "Preview" : "Edit"}
</button>
{actions(mode, true)}
</div>
</div>
)}
</div>
);
}
Change history
Quality pass: several resumes, cover letters, phone-readable shared pages
Bounded source sync
Bounded source sync
Bounded source sync