+
+
+ pageNumber === 1 ? (
+
+ {viewing ? (
+
+
+
+ Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} ·
+ read-only
+
+
+ ) : previewTemplate ? (
+
+
+ Previewing {templates[previewTemplate].name} · click to apply
+
+ ) : (
+ Page 1 · {formatLabel}
+ )}
+
+ ) : (
+
+ Page {pageNumber}
+
+ )
+ }
+ renderPageOverlay={({ pageIndex, pageMap }) =>
+ viewing ? null : (
+
+ )
+ }
+ />
+
+
+
+
+ );
+}
+
+type LetterOverlayProps = { pageIndex: number; pageMap: PageMap | undefined; itemId: string; washed: boolean };
+
+/**
+ * Pointer layer over the page: the sender's header leads to From, the letter to its body in the panel. A draft
+ * lies on a green wash until it's kept.
+ */
+function LetterOverlay({ pageIndex, pageMap, itemId, washed }: LetterOverlayProps) {
+ const [mode, setMode] = useLetterMode();
+ const page = pageMap?.pages[pageIndex];
+ if (!pageMap || !page || page.width <= 0 || page.height <= 0) return null;
+
+ const nodes = pageMap.nodes.filter(
+ (node) => node.page === pageIndex && (node.kind === "header" || (node.kind === "item" && node.itemId === itemId)),
+ );
+
+ const reveal = (target: "from" | "body") => {
+ if (mode !== "write") setMode("write");
+ useEditorStore.getState().setMobileView("write");
+ useEditorStore.getState().setDrawerOpen(true);
+ window.setTimeout(() => {
+ if (target === "body") {
+ const editor = document.getElementById("letter-body-editor");
+ if (editor) return editor.focus();
+ }
+ document
+ .getElementById(target === "from" ? "letter-from" : "letter-body")
+ ?.scrollIntoView({ behavior: getScrollBehavior(), block: "start" });
+ }, 50);
+ };
+
+ return (
+ // A pointer shortcut, hidden from assistive tech: the panel reaches the same fields by keyboard.
+
+ {nodes.map((node) => (
+ // biome-ignore lint/a11y/noStaticElementInteractions: hidden from assistive tech, see above.
+ // biome-ignore lint/a11y/useKeyWithClickEvents: the panel offers the same by keyboard.
+
reveal(node.kind === "header" ? "from" : "body")}
+ className={cn(
+ "absolute cursor-pointer rounded-[4px] transition-[background-color,outline-color] duration-standard",
+ washed && node.kind === "item"
+ ? "bg-[oklch(0.93_0.05_150/0.55)] mix-blend-multiply outline-[1.5px] outline-[oklch(0.5_0.1_150)] outline-solid"
+ : "hover:bg-[oklch(0.5_0.1_150/0.06)]",
+ )}
+ style={{
+ left: `calc(${(node.x / page.width) * 100}% - 4px)`,
+ top: `calc(${(node.y / page.height) * 100}% - 3px)`,
+ width: `calc(${(node.width / page.width) * 100}% + 8px)`,
+ height: `calc(${(node.height / page.height) * 100}% + 6px)`,
+ }}
+ />
+ ))}
+
+ );
+}
diff --git a/apps/web/src/routes/builder/letter/-components/letter-shell.tsx b/apps/web/src/routes/builder/letter/-components/letter-shell.tsx
new file mode 100644
index 000000000..637c812cd
--- /dev/null
+++ b/apps/web/src/routes/builder/letter/-components/letter-shell.tsx
@@ -0,0 +1,270 @@
+import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
+import type { IconName } from "@reactive-resume/ui/components/icon";
+import type { LetterMode } from "@/features/letters/use-letter-mode";
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import { useHotkey } from "@tanstack/react-hotkeys";
+import { useEffect } from "react";
+import { Icon } from "@reactive-resume/ui/components/icon";
+import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
+import { toast } from "@reactive-resume/ui/components/toast";
+import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
+import { cn } from "@reactive-resume/utils/style";
+import { useLetterEditorStore } from "@/features/letters/store";
+import { useLetterMode, useOpenLetterVersionFromUrl } from "@/features/letters/use-letter-mode";
+import { usePreviewPausedStore } from "@/features/resume/builder/draft";
+import { useIsLandscape } from "@/features/resume/editor/chrome";
+import { useEditorStore } from "@/features/resume/editor/store";
+import { LetterDesignPanel } from "./design-panel";
+import { LetterBar, useDownloadLetter } from "./letter-bar";
+import { LetterPage } from "./letter-page";
+import { LetterShareSheet } from "./share-sheet";
+import { LetterWritePanel } from "./write-panel";
+
+/**
+ * The letter editor: the resume editor's shell with Write and Design (no Check). The panel holds who the letter is
+ * for, to and from, and its body; the page shows the letter as it prints.
+ */
+export function LetterShell() {
+ const [mode, setMode] = useLetterMode();
+ const breakpoint = useBreakpoint();
+ const layout = breakpoint === "mobile" ? "mobile" : breakpoint === "tablet" ? "tablet" : "desktop";
+ const landscape = useIsLandscape();
+ const pinnable = layout === "tablet" && landscape;
+ const pinned = useEditorStore((state) => state.drawerPinned) && pinnable;
+ const resetEditor = useEditorStore((state) => state.reset);
+
+ // Zoom, open sheets and the History version belong to one document.
+ useEffect(() => resetEditor, [resetEditor]);
+ useOpenLetterVersionFromUrl();
+ useLinkedUpdateNotice();
+ useUnsavedGuard();
+
+ return (
+
setMode(value as LetterMode)} className="contents">
+
+
+ Skip to the page
+
+
+
+
+ {(layout === "desktop" || pinned) && (
+
+
+
+
+
+
+
+
+ )}
+ {layout === "tablet" && !pinned &&
}
+ {layout === "mobile" &&
}
+
+
+
+
+
+ );
+}
+
+const panelLabel = (mode: LetterMode) => (mode === "design" ? t`Design` : t`Letter details`);
+
+function ModePanel({ mode }: { mode: LetterMode }) {
+ return mode === "design" ?
:
;
+}
+
+function TabletBody({ mode }: { mode: LetterMode }) {
+ const drawerOpen = useEditorStore((state) => state.drawerOpen);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+type MobileView = "write" | "page" | "design";
+
+const MOBILE_TABS: { view: MobileView; icon: IconName }[] = [
+ { view: "write", icon: "edit" },
+ { view: "page", icon: "description" },
+ { view: "design", icon: "palette" },
+];
+
+type MobileBodyProps = { mode: LetterMode; onModeChange: (mode: LetterMode) => void };
+
+/** Phones: one view at a time, Write · Page · Design. The page stays mounted so zoom survives switching. */
+function MobileBody({ mode, onModeChange }: MobileBodyProps) {
+ const stored = useEditorStore((state) => state.mobileView);
+ const view: MobileView = stored === "page" || stored === "design" || stored === "write" ? stored : mode;
+ const setView = useEditorStore((state) => state.setMobileView);
+ const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
+ const labels: Record
= { write: t`Write`, page: t`Page`, design: t`Design` };
+
+ useEffect(() => {
+ setPreviewPaused(view !== "page");
+ return () => setPreviewPaused(false);
+ }, [view, setPreviewPaused]);
+
+ return (
+
+
+
+
+
+ {view !== "page" && (
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+/** 1 and 2 switch modes outside fields, ⌘P downloads the PDF, ⌘⇧E opens Download. Saving is automatic. */
+function LetterHotkeys({ onModeChange }: { onModeChange: (mode: LetterMode) => void }) {
+ const download = useDownloadLetter();
+ const setShareTab = useEditorStore((state) => state.setShareTab);
+
+ useHotkey("1", () => onModeChange("write"));
+ useHotkey("2", () => onModeChange("design"));
+ useHotkey("Mod+P", () => void download.run());
+ useHotkey("Mod+Shift+E", () => setShareTab("download"));
+ useHotkey("Mod+S", () => {
+ void useLetterEditorStore.getState().flush();
+ toast.add({ type: "info", description: t`Your changes are saved automatically.`, id: "auto-save" });
+ });
+
+ return null;
+}
+
+/** Closing the tab with edits still on their way asks first. */
+function useUnsavedGuard() {
+ useEffect(() => {
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
+ const { status, pending } = useLetterEditorStore.getState();
+ if (status === "saved" && Object.keys(pending).length === 0) return;
+ void useLetterEditorStore.getState().flush();
+ event.preventDefault();
+ };
+ window.addEventListener("beforeunload", onBeforeUnload);
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
+ }, []);
+}
+
+const SENDER_FIELDS = ["name", "headline", "email", "phone", "location"] as const;
+type SenderField = (typeof SENDER_FIELDS)[number];
+type SenderSnapshot = Partial>;
+
+const snapshotOf = (letter: CoverLetter): SenderSnapshot =>
+ Object.fromEntries(SENDER_FIELDS.map((field) => [field, letter.style.basics[field]]));
+
+const readSnapshot = (key: string): SenderSnapshot | null => {
+ try {
+ return JSON.parse(window.localStorage.getItem(key) ?? "null") as SenderSnapshot | null;
+ } catch {
+ return null;
+ }
+};
+
+const changedMessage = (field: SenderField) =>
+ ({
+ name: t`Your name changed on the resume. The letter updated too.`,
+ headline: t`Your headline changed on the resume. The letter updated too.`,
+ email: t`Your email address changed on the resume. The letter updated too.`,
+ phone: t`Your phone number changed on the resume. The letter updated too.`,
+ location: t`Your location changed on the resume. The letter updated too.`,
+ })[field];
+
+/**
+ * C2: a linked letter says so, once, when details it takes from the resume changed since this device last showed
+ * it. The live link means there's nothing to refresh.
+ */
+function useLinkedUpdateNotice() {
+ const letterId = useLetterEditorStore((state) => state.letter?.id);
+ const basics = useLetterEditorStore((state) => state.letter?.style.basics);
+
+ useEffect(() => {
+ const letter = useLetterEditorStore.getState().letter;
+ if (!letterId || !letter?.senderLinked) return;
+ const before = readSnapshot(`letter-sender:${letter.id}`);
+ if (!before) return;
+ const now = snapshotOf(letter);
+ const changed = SENDER_FIELDS.filter((field) => before[field] !== undefined && before[field] !== now[field]);
+ const [only] = changed;
+ if (!only) return;
+ toast.add({
+ description:
+ changed.length === 1 ? changedMessage(only) : t`Your details changed on the resume. The letter updated too.`,
+ });
+ }, [letterId]);
+
+ // What this device has shown, for the next visit.
+ useEffect(() => {
+ const letter = useLetterEditorStore.getState().letter;
+ if (!letter || !basics) return;
+ try {
+ window.localStorage.setItem(`letter-sender:${letter.id}`, JSON.stringify(snapshotOf(letter)));
+ } catch {
+ // Storage can be unavailable; the notice is a nicety.
+ }
+ }, [basics]);
+}
diff --git a/apps/web/src/routes/builder/letter/-components/share-sheet.tsx b/apps/web/src/routes/builder/letter/-components/share-sheet.tsx
new file mode 100644
index 000000000..7739e7dbe
--- /dev/null
+++ b/apps/web/src/routes/builder/letter/-components/share-sheet.tsx
@@ -0,0 +1,214 @@
+import type { ExportFormat } from "@/features/resume/export/use-resume-export";
+import type { DownloadFormat, DownloadState } from "@/features/resume/share/download-tab";
+import type { HistorySource } from "@/features/resume/share/history-tab";
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
+import { toast } from "@reactive-resume/ui/components/toast";
+import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
+import { downloadWithAnchor } from "@reactive-resume/utils/file";
+import { cn } from "@reactive-resume/utils/style";
+import { useLetterWords } from "@/features/letters/compose";
+import { createLetterFile, letterFileName } from "@/features/letters/export";
+import { useLetterEditorStore } from "@/features/letters/store";
+import { useEditorStore } from "@/features/resume/editor/store";
+import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export";
+import {
+ DownloadActions,
+ FileNameField,
+ FormatRadioGroup,
+ getExportFormats,
+} from "@/features/resume/share/download-tab";
+import { HistoryTimeline } from "@/features/resume/share/history-tab";
+import { getOrpcErrorMessage } from "@/libs/error-message";
+import { client, orpc } from "@/libs/orpc/client";
+
+/**
+ * Share & export for letters: Download and History. Letters have no public link, so Share opens Download.
+ * 440px from the right; a full-height bottom sheet on phones.
+ */
+export function LetterShareSheet() {
+ const tab = useEditorStore((state) => state.shareTab);
+ const setTab = useEditorStore((state) => state.setShareTab);
+ const setHistoryVersion = useEditorStore((state) => state.setHistoryVersion);
+ const isPhone = useBreakpoint() === "mobile";
+ const history = useLetterHistory(tab === "history");
+
+ return (
+ !open && setTab(null)}>
+
+
+
+ Share & export
+
+
+
+ {
+ // The page shows a version only while History is open.
+ if (value !== "history") setHistoryVersion(null);
+ setTab(value as "download" | "history");
+ }}
+ className="min-h-0 flex-1 gap-0"
+ >
+
+
+ Download
+
+
+ History
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+type LetterFormat = ExportFormat | "both";
+
+const PAUSE_BETWEEN_FILES_MS = 400;
+
+/**
+ * Download: the letter as PDF (or Word, Markdown, JSON), or, for a letter with a resume, both as two PDFs named to
+ * match: First-Last-Resume.pdf and First-Last-Cover-Letter.pdf.
+ */
+function LetterDownloadTab() {
+ const letter = useLetterEditorStore((state) => state.letter);
+ const words = useLetterWords();
+ const { data: resumes } = useQuery(orpc.resume.list.queryOptions({ input: {} }));
+ const [format, setFormat] = useState("pdf");
+ const [fileName, setFileName] = useState(null);
+ const [state, setState] = useState("idle");
+ if (!letter) return null;
+
+ const resumeId = letter.sourceResumeId;
+ const resumeName = resumes?.find((item) => item.id === resumeId)?.name ?? t`your resume`;
+ const [pdf, ...others] = getExportFormats();
+ const formats: DownloadFormat[] = [
+ ...(pdf ? [pdf] : []),
+ ...(resumeId
+ ? [
+ {
+ id: "both" as const,
+ label: t`Resume + letter`,
+ extension: t`2 files`,
+ icon: "picture_as_pdf" as const,
+ description: t`This letter and “${resumeName}” as two PDFs, named to match.`,
+ },
+ ]
+ : []),
+ ...others.map((option) =>
+ option.id === "json"
+ ? { ...option, description: t`A backup of this letter. Imports back into Reactive Resume as a letter.` }
+ : option,
+ ),
+ ];
+ const selected = formats.find((option) => option.id === format) ?? (formats[0] as DownloadFormat);
+ const name = fileName ?? letterFileName(letter, words);
+
+ const download = async (as: LetterFormat) => {
+ setState("busy");
+ try {
+ if (as === "both" && resumeId) {
+ const resume = await client.resume.getById({ id: resumeId });
+ downloadWithAnchor(await createExportFile(resume, "pdf"), `${getDefaultFileName(resume)}.pdf`);
+ // Two downloads in a row are more reliable a moment apart.
+ await new Promise((resolve) => window.setTimeout(resolve, PAUSE_BETWEEN_FILES_MS));
+ downloadWithAnchor(await createLetterFile(letter, words, "pdf"), `${letterFileName(letter, words)}.pdf`);
+ toast.add({ description: t`Downloaded your resume and this letter` });
+ } else if (as !== "both") {
+ const extension = formats.find((option) => option.id === as)?.extension ?? ".pdf";
+ const file = `${sanitizeFileName(name) || letterFileName(letter, words)}${extension}`;
+ downloadWithAnchor(await createLetterFile(letter, words, as), file);
+ toast.add({ description: t`Downloaded ${file}` });
+ }
+ setState("idle");
+ } catch {
+ setState("error");
+ }
+ };
+
+ return (
+
+ {
+ setFormat(value);
+ setState("idle");
+ }}
+ />
+
+ {selected.id !== "both" && (
+ Recruiters see this name. Your name plus “Cover Letter” works well.}
+ onChange={setFileName}
+ />
+ )}
+
+ void download(selected.id)}
+ onDownloadPdf={selected.id === "pdf" ? undefined : () => void download("pdf")}
+ />
+
+ );
+}
+
+/** History for the letter: sessions, named and sent versions, and restore points. */
+function useLetterHistory(open: boolean): HistorySource {
+ const queryClient = useQueryClient();
+ const letter = useLetterEditorStore((state) => state.letter);
+ const locked = useLetterEditorStore((state) => state.letter?.isLocked ?? false);
+ const id = letter?.id ?? "";
+ const { data: versions, isPending: loading } = useQuery({
+ ...orpc.coverLetters.listVersions.queryOptions({ input: { id } }),
+ enabled: Boolean(id) && open,
+ });
+ const refresh = () =>
+ queryClient.invalidateQueries({ queryKey: orpc.coverLetters.listVersions.queryKey({ input: { id } }) });
+ const { flush, change } = useLetterEditorStore.getState();
+
+ return {
+ versions,
+ loading,
+ locked,
+ nowDetail: t`The letter as it is`,
+ errorMessage: (error) => getOrpcErrorMessage(error, { fallback: t`Something went wrong. Try again.` }),
+ save: async (name) => {
+ await flush();
+ await client.coverLetters.createVersion({ id, name });
+ void refresh();
+ },
+ restore: async (versionId) => {
+ await change(() => client.coverLetters.restoreVersion({ id, versionId }));
+ void refresh();
+ },
+ rename: async (versionId, name) => {
+ await client.coverLetters.renameVersion({ id, versionId, name });
+ void refresh();
+ },
+ remove: async (versionId) => {
+ await client.coverLetters.deleteVersion({ id, versionId });
+ void refresh();
+ },
+ };
+}
diff --git a/apps/web/src/routes/builder/letter/-components/write-panel.tsx b/apps/web/src/routes/builder/letter/-components/write-panel.tsx
new file mode 100644
index 000000000..df16be6ee
--- /dev/null
+++ b/apps/web/src/routes/builder/letter/-components/write-panel.tsx
@@ -0,0 +1,648 @@
+import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
+import type { ReactElement, ReactNode } from "react";
+import type { Application } from "@/features/applications/types";
+import type { LetterDraft } from "@/features/letters/store";
+import { t } from "@lingui/core/macro";
+import { Plural, Trans } from "@lingui/react/macro";
+import { ORPCError } from "@orpc/client";
+import { useQuery } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { useReducedMotion } from "motion/react";
+import { useId, useRef, useState } from "react";
+import { coverLetterTextToHtml, greetingName } from "@reactive-resume/resume/cover-letter";
+import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@reactive-resume/ui/components/dropdown-menu";
+import { Icon } from "@reactive-resume/ui/components/icon";
+import { Input } from "@reactive-resume/ui/components/input";
+import { Label } from "@reactive-resume/ui/components/label";
+import { NativeSelect } from "@reactive-resume/ui/components/native-select";
+import { SwitchRow } from "@reactive-resume/ui/components/switch";
+import { toast } from "@reactive-resume/ui/components/toast";
+import { cn } from "@reactive-resume/utils/style";
+import { templates } from "@/dialogs/resume/template/data";
+import { applicationsListQueryOptions } from "@/features/applications/queries";
+import { getStageColor, getStageLabel } from "@/features/applications/stages";
+import { useLetterWords } from "@/features/letters/compose";
+import { countWords, LETTER_LENGTH, letterLength } from "@/features/letters/length";
+import { discardLetterDraft, startLetterDraft, useLetterEditorStore } from "@/features/letters/store";
+import { useLetterMode } from "@/features/letters/use-letter-mode";
+import { ACCENTS, FONT_PAIRINGS, matchFontPairing, rgbaToHex } from "@/features/resume/editor/design/presets";
+import { RichTextEditor } from "@/features/resume/editor/write/rich-text-editor";
+import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
+import { getOrpcErrorMessage } from "@/libs/error-message";
+import { client, orpc } from "@/libs/orpc/client";
+
+const BODY_ID = "letter-body-editor";
+
+const focusBody = () => window.setTimeout(() => document.getElementById(BODY_ID)?.focus(), 50);
+
+const failed = (error: unknown) =>
+ toast.add({
+ type: "error",
+ description: getOrpcErrorMessage(error, { fallback: t`Couldn't save that change. Try again.` }),
+ });
+
+/** Saves what's typed, then changes the letter on the server (links, the application, the resume). */
+const updateLetter = (changes: Omit[0], "id" | "expectedRevision">) =>
+ useLetterEditorStore.getState().change((letter) =>
+ client.coverLetters.update({
+ id: letter.id,
+ expectedRevision: letter.revision,
+ sessionId: useLetterEditorStore.getState().sessionId,
+ ...changes,
+ }),
+ );
+
+type SectionProps = { id: string; title: ReactNode; aside?: ReactNode; children: ReactNode };
+
+function Section({ id, title, aside, children }: SectionProps) {
+ return (
+
+
+
+ {title}
+
+ {aside}
+
+ {children}
+
+ );
+}
+
+/**
+ * Write: who the letter is for (the application), to (recipient, company, date) and from (the resume, live or
+ * copied), then the body with its length guide and a note on the design. The page beside it updates as you type.
+ */
+export function LetterWritePanel() {
+ const letter = useLetterEditorStore((state) => state.letter);
+ const draft = useLetterEditorStore((state) => state.draft);
+ const { data: applications } = useQuery(applicationsListQueryOptions());
+ if (!letter) return null;
+
+ const application = applications?.find((item) => item.id === letter.sourceApplicationId) ?? null;
+ const disabled = letter.isLocked;
+
+ return (
+
+ {letter.isLocked && (
+
+
+ This letter is locked. Unlock it from its menu to edit.
+
+ )}
+
+
+
+
+ {/* While a draft shows, the length is the draft's. */}
+
+
+
+ );
+}
+
+type ForSectionProps = {
+ letter: CoverLetter;
+ application: Application | null;
+ applications: Application[];
+ disabled: boolean;
+};
+
+/** FOR: the application the letter is for. Linking one fills the recipient; without one, drafting uses the resume. */
+function ForSection({ letter, application, applications, disabled }: ForSectionProps) {
+ const choices = applications.filter((item) => item.status !== "closed" || item.id === application?.id);
+
+ const choose = (next: Application | null) =>
+ void updateLetter({
+ applicationId: next?.id ?? null,
+ // The recipient comes from the application: its company, and its first contact when it has one.
+ ...(next && letter.layout === "structured"
+ ? { recipientCompany: next.company, recipientName: next.contacts[0]?.name ?? "" }
+ : {}),
+ }).catch(failed);
+
+ const menu = (trigger: ReactElement) => (
+
+
+
+ {choices.map((item) => (
+ choose(item)}>
+
+
+ {item.role}
+ {item.company}
+
+
+ ))}
+ {choices.length === 0 && (
+
+ No applications yet. Add one in Applications first.
+
+ )}
+ {application && (
+ <>
+
+ choose(null)}>
+
+ No application
+
+ >
+ )}
+
+
+ );
+
+ return (
+ For}>
+ {application ? (
+
+
+ {application.company.charAt(0).toUpperCase()}
+
+
+ {application.role}
+
+ {application.company}
+ ·
+
+ {getStageLabel(application.status)}
+
+
+ {menu(
+ ,
+ )}
+
+ ) : (
+ <>
+ {menu(
+ ,
+ )}
+
+ Without one, you fill in the recipient yourself and drafting uses only your resume.
+
+ >
+ )}
+
+ );
+}
+
+type LetterSectionProps = { letter: CoverLetter; application: Application | null; disabled: boolean };
+
+/** TO: name or team, company and date for structured letters; older (freeform) letters keep their recipient block. */
+function ToSection({ letter, application, disabled }: LetterSectionProps) {
+ const id = useId();
+ const edit = useLetterEditorStore((state) => state.edit);
+
+ if (letter.layout === "freeform") {
+ return (
+ To}>
+ edit({ recipient })}
+ />
+
+ This letter keeps its recipient as written.
+
+
+ );
+ }
+
+ return (
+ To}>
+
+
+ {application ? (
+ Filled from the application. The greeting follows the name.
+ ) : (
+ The greeting follows the name.
+ )}
+
+
+ );
+}
+
+/** FROM: the resume the sender's details come from, and whether they stay live or are copied into the letter. */
+function FromSection({ letter, disabled }: { letter: CoverLetter; disabled: boolean }) {
+ const id = useId();
+ const { data: resumes } = useQuery(orpc.resume.list.queryOptions({ input: {} }));
+ const resume = resumes?.find((item) => item.id === letter.sourceResumeId);
+
+ const setResume = (resumeId: string | null) =>
+ void updateLetter({
+ resumeId,
+ // A letter taking a resume for the first time takes its details and design with it.
+ ...(resumeId && !letter.sourceResumeId ? { senderLinked: true, designLinked: true } : {}),
+ }).catch(failed);
+
+ const setLinked = (senderLinked: boolean) =>
+ void updateLetter({ senderLinked })
+ .then(() =>
+ toast.add({
+ description: senderLinked ? t`Linked to the resume again` : t`Sender details copied into the letter`,
+ }),
+ )
+ .catch(failed);
+
+ return (
+ From}>
+
+
+ setResume(event.target.value || null)}
+ >
+
+ {resumes?.map((item) => (
+
+ ))}
+
+
+ {letter.sourceResumeId ? (
+
+ ) : (
+
+ Your name and contact details come from a resume.
+
+ )}
+
+ );
+}
+
+/**
+ * The body, edited here while the page updates. Empty, it offers a draft from the posting and the resume. A draft
+ * shows on a green wash until it's kept: it never replaces typed text by itself.
+ */
+function BodySection({ letter, application, disabled }: LetterSectionProps) {
+ const words = useLetterWords();
+ const edit = useLetterEditorStore((state) => state.edit);
+ const draft = useLetterEditorStore((state) => state.draft);
+ const [writing, setWriting] = useState(false);
+ const structured = letter.layout === "structured";
+ const name = greetingName(letter.recipientName);
+ const showEditor = writing || countWords(letter.content) > 0;
+
+ return (
+ Letter}>
+ {structured && {name ? words.greeting(name) : words.teamGreeting}
}
+
+ {draft.phase !== "idle" ? (
+ {
+ edit({ content: coverLetterTextToHtml(text) });
+ discardLetterDraft();
+ setWriting(true);
+ toast.add({ description: t`Draft kept. Edit anything you like.` });
+ focusBody();
+ }}
+ />
+ ) : showEditor ? (
+ edit({ content })}
+ />
+ ) : (
+ {
+ setWriting(true);
+ focusBody();
+ }}
+ />
+ )}
+
+ {structured && (
+
+ {words.signOff}
+ {letter.style.basics.name && (
+ <>
+
+ {letter.style.basics.name}
+ >
+ )}
+
+ )}
+
+ );
+}
+
+type EmptyBodyProps = { letter: CoverLetter; application: Application | null; disabled: boolean; onWrite: () => void };
+
+function EmptyBody({ letter, application, disabled, onWrite }: EmptyBodyProps) {
+ const { hasUsableProvider } = useHasUsableAiProvider();
+ const hasResume = Boolean(letter.sourceResumeId);
+ const company = application?.company;
+ const canDraft = hasResume || Boolean(application);
+
+ return (
+
+
+ {company && hasResume ? (
+ Start typing, or draft from what we know: the {company} posting and your resume.
+ ) : company ? (
+ Start typing, or draft from what we know: the {company} posting.
+ ) : hasResume ? (
+ Start typing, or draft from what we know: your resume.
+ ) : (
+ Start typing. To draft one, link a resume or an application first.
+ )}
+
+
+ {canDraft && hasUsableProvider && (
+
+ )}
+
+
+ {canDraft && !hasUsableProvider && (
+
+ Drafting needs an AI provider.{" "}
+
+ Open AI settings
+
+
+ )}
+
+ );
+}
+
+type DraftBoxProps = {
+ draft: Exclude;
+ letter: CoverLetter;
+ application: Application | null;
+ onKeep: (text: string) => void;
+};
+
+/** The draft on its green wash, then the dark chip: Keep, Shorter, More personal, Discard. */
+function DraftBox({ draft, letter, application, onKeep }: DraftBoxProps) {
+ const reducedMotion = useReducedMotion();
+ // Retrying repeats the last request.
+ const last = useRef<{ variant: "draft" | "shorter" | "personal"; previous?: string }>({ variant: "draft" });
+ const run = (variant: "draft" | "shorter" | "personal", previous?: string) => {
+ last.current = previous ? { variant, previous } : { variant };
+ void startLetterDraft(variant, previous);
+ };
+
+ if (draft.phase === "failed") {
+ const gateway = draft.error instanceof ORPCError && draft.error.code === "BAD_GATEWAY";
+ return (
+
+
+
+ {gateway && draft.provider ? (
+ Drafting stopped: {draft.provider} didn't respond. Nothing on the page changed.
+ ) : (
+ Drafting stopped. Nothing on the page changed.
+ )}
+
+
+
+
+
+
+ );
+ }
+
+ const streaming = draft.phase === "streaming";
+ // Reduced motion shows the whole draft at once.
+ const text = streaming && reducedMotion ? "" : draft.text;
+ const sources =
+ letter.sourceResumeId && application
+ ? t`Draft · uses only your resume and the posting`
+ : application
+ ? t`Draft · uses only the posting`
+ : t`Draft · uses only your resume`;
+
+ return (
+
+ {draft.phase === "ready" && (
+
+
+
+ {sources}
+
+
+ {(
+ [
+ ["shorter", t`Shorter`],
+ ["personal", t`More personal`],
+ ] as const
+ ).map(([variant, label]) => (
+
+ ))}
+
+
+ )}
+
+ {text}
+ {streaming && (
+
+ {reducedMotion ? Drafting… : ▍}
+
+ )}
+
+ {streaming && (
+
+ )}
+
+ );
+}
+
+/** Length: the word count against the 180 to 320 band most recruiters read, as a hint rather than a rule. */
+function LengthSection({ content }: { content: string }) {
+ const words = countWords(content);
+ const length = letterLength(words);
+ const color = length === "empty" ? "text-ink-3" : length === "comfortable" ? "text-accent-text" : "text-warn-text";
+ const fill = length === "empty" ? "bg-line-2" : length === "comfortable" ? "bg-accent" : "bg-warn";
+ const percent = (value: number) => `${(value / LETTER_LENGTH.scale) * 100}%`;
+
+ return (
+ Length}
+ aside={
+
+
+
+ }
+ >
+
+
+
+
+
+ {length === "empty" ? (
+ Most recruiters read 180–320 words. The shaded band shows the range.
+ ) : length === "short" ? (
+ Short and direct. Fine if the posting asks for brevity.
+ ) : length === "comfortable" ? (
+ A comfortable length, and it fits on one page.
+ ) : (
+ Getting long. Try “Shorter”, or cut the least specific paragraph.
+ )}
+
+
+ );
+}
+
+/** DESIGN: what the letter looks like, and where to change it. */
+function DesignNote({ letter }: { letter: CoverLetter }) {
+ const [, setMode] = useLetterMode();
+ const { metadata } = letter.style;
+ const pairing = FONT_PAIRINGS.find((option) => option.id === matchFontPairing(metadata))?.label;
+ const accent = ACCENTS.find((option) => option.hex === rgbaToHex(metadata.design.colors.primary))?.label;
+ const summary = [templates[metadata.template].name, pairing && t`${pairing} type`, accent].filter(Boolean).join(", ");
+
+ return (
+ Design}>
+
+ {letter.designLinked ? (
+ Matches the resume ({summary}).
+ ) : (
+ Its own design ({summary}).
+ )}{" "}
+
+
+
+ );
+}
diff --git a/apps/web/src/routes/dashboard/index.tsx b/apps/web/src/routes/dashboard/index.tsx
index 99af51e7a..7a160669c 100644
--- a/apps/web/src/routes/dashboard/index.tsx
+++ b/apps/web/src/routes/dashboard/index.tsx
@@ -1,5 +1,5 @@
import type { DocumentsSearch } from "@/features/documents/documents-page";
-import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
+import { createFileRoute, redirect, stripSearchParams } from "@tanstack/react-router";
import z from "zod";
import { DocumentsPage } from "@/features/documents/documents-page";
@@ -13,9 +13,15 @@ export const Route = createFileRoute("/dashboard/")({
sort: z.enum(["edited", "name", "created"]).default("edited").catch("edited"),
// Without one, the page uses the last view picked on this device.
view: z.enum(["grid", "list"]).optional().catch(undefined),
+ // Older links opened letters here; they open in the letter editor now.
letter: z.string().optional().catch(undefined),
}),
search: { middlewares: [stripSearchParams(defaults)] },
+ beforeLoad: ({ search }) => {
+ if (search.letter) {
+ throw redirect({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: search.letter }, replace: true });
+ }
+ },
component: RouteComponent,
});