Part of #765. Depends on the letter scope keys issue — this one consumes the lattice that issue stores.
Read #765 first for the standard-is-the-source framing and the never-inferred rule.
Problem
With scope keys stored, a job can be reached by up to three letters — its own, its company's, and the standard one. Nothing decides which the user sees, and nothing lets them create anything but a job letter.
Today the whole surface is JobLetterIndicator (src/components/features/JobLetterIndicator.tsx), one per Saved-jobs row, driving LetterRevealDialog and LetterEditorDialog. It reads letters for one jobId and nothing else.
Design
The resolution chain
For a given job, the letter that applies resolves job → company → standard, first hit wins. The chain is a pure function over the letter set, so it belongs in src/lib/ and is unit-testable without a DOM:
// src/lib/letters/resolve-letter.ts
export function resolveLetterForJob(
job: Pick<JobRecord, "id" | "company">,
letters: readonly LetterRecord[],
): { letter: LetterRecord; scope: "job" | "company" | "standard" } | undefined
The returned scope is not decoration — every surface below needs it to tell the user why they are looking at this text.
The row glyph keeps meaning "this job's own letter"
JobLetterIndicator renders two glyphs today: an envelope (has letters) and an envelope-plus (write one). Neither changes meaning here. A standard letter existing must NOT flip every row to "has letter" — that would claim a letter the user never wrote for that job, and the reveal would then show text they did not intend for that employer.
Instead, an inherited letter surfaces inside the dialogs, labelled with its scope, where there is room to say what it is.
Customize-from is a COPY, not a link
Starting a job letter from the company or standard letter writes a new record with a new id and the job's jobId. It does not link to its source.
This is deliberate and is the same reasoning that makes the résumé half hard (#765): if job B's letter were a live reference to the standard letter, editing it would rewrite the letter already submitted for job A. For prose there is no merge that could make that safe, so the copy is the honest model — and it is why letters ship before résumés.
The dialog must say so plainly, once, at the moment of copying. "Starting from your Northwind letter" with no further explanation reads as a link.
Steps
-
src/lib/letters/resolve-letter.ts (new) — the chain above. Pure, no React, no storage import. A job with no company (the field may be empty, src/lib/storage/types.ts:198) skips the company rung rather than matching a blank key.
-
src/hooks/useJobLetters.ts — expose company and standard letters alongside byJobId so a row can resolve without a second store read. The hook already reads the whole store once and groups in memory for exactly this reason (see its docblock).
-
src/components/features/LetterEditorDialog.tsx — jobId becomes optional (absent = editing a company or standard letter, per the props the scope-keys issue lands). Add a "Start from…" picker: shown only when composing (never when revising), only when there is something to start from, and never seeding automatically. Selecting seeds body and writes a new record on save — no id carried over, which is what makes it a copy.
-
src/components/features/LetterRevealDialog.tsx — when the letter shown is inherited, label the scope ("Your standard letter" / "Your Northwind letter") and offer Customize for this job, which opens the editor pre-seeded per step 3.
-
src/components/features/StandardLetterButton.tsx (new, ~40 LOC) — one panel-level affordance on /jobs/ → Saved jobs that opens LetterEditorDialog with no jobId. This is where a standard letter is created and edited. Company letters are created via "Customize for this company" from a job row, so they need no separate entry point.
-
src/components/features/JobTracker.tsx — mount the button, thread the resolved letter and scope down to each row.
Reuse analysis
Capability: author, view, and pick among cover letters at three scopes.
Existing surfaces found:
src/components/features/LetterEditorDialog.tsx — already owns authoring and revising a LetterRecord. Extend (optional jobId + the picker); do not add a second editor for company/standard letters. They are the same act on the same record type.
src/components/features/LetterRevealDialog.tsx — already owns reading and copying. Extend with the scope label.
src/components/features/JobLetterIndicator.tsx — already owns the row's click → acknowledge → reveal/edit state machine. Extend; its two glyphs are unchanged.
@design-system — Dialog, Button, TextAreaField already cover every control needed. No new primitive.
Decision: extend all three, plus one new file — StandardLetterButton — because no existing surface owns a panel-level (not per-row) letter affordance, and folding it into JobTracker would push that file further past the ~200 LOC guideline it already sits near.
Acceptance criteria
Out of scope
Part of #765. Depends on the letter scope keys issue — this one consumes the lattice that issue stores.
Read #765 first for the standard-is-the-source framing and the never-inferred rule.
Problem
With scope keys stored, a job can be reached by up to three letters — its own, its company's, and the standard one. Nothing decides which the user sees, and nothing lets them create anything but a job letter.
Today the whole surface is
JobLetterIndicator(src/components/features/JobLetterIndicator.tsx), one per Saved-jobs row, drivingLetterRevealDialogandLetterEditorDialog. It readslettersfor onejobIdand nothing else.Design
The resolution chain
For a given job, the letter that applies resolves job → company → standard, first hit wins. The chain is a pure function over the letter set, so it belongs in
src/lib/and is unit-testable without a DOM:The returned
scopeis not decoration — every surface below needs it to tell the user why they are looking at this text.The row glyph keeps meaning "this job's own letter"
JobLetterIndicatorrenders two glyphs today: an envelope (has letters) and an envelope-plus (write one). Neither changes meaning here. A standard letter existing must NOT flip every row to "has letter" — that would claim a letter the user never wrote for that job, and the reveal would then show text they did not intend for that employer.Instead, an inherited letter surfaces inside the dialogs, labelled with its scope, where there is room to say what it is.
Customize-from is a COPY, not a link
Starting a job letter from the company or standard letter writes a new record with a new id and the job's
jobId. It does not link to its source.This is deliberate and is the same reasoning that makes the résumé half hard (#765): if job B's letter were a live reference to the standard letter, editing it would rewrite the letter already submitted for job A. For prose there is no merge that could make that safe, so the copy is the honest model — and it is why letters ship before résumés.
The dialog must say so plainly, once, at the moment of copying. "Starting from your Northwind letter" with no further explanation reads as a link.
Steps
src/lib/letters/resolve-letter.ts(new) — the chain above. Pure, no React, no storage import. A job with nocompany(the field may be empty,src/lib/storage/types.ts:198) skips the company rung rather than matching a blank key.src/hooks/useJobLetters.ts— expose company and standard letters alongsidebyJobIdso a row can resolve without a second store read. The hook already reads the whole store once and groups in memory for exactly this reason (see its docblock).src/components/features/LetterEditorDialog.tsx—jobIdbecomes optional (absent = editing a company or standard letter, per the props the scope-keys issue lands). Add a "Start from…" picker: shown only when composing (never when revising), only when there is something to start from, and never seeding automatically. Selecting seedsbodyand writes a new record on save — noidcarried over, which is what makes it a copy.src/components/features/LetterRevealDialog.tsx— when the letter shown is inherited, label the scope ("Your standard letter" / "Your Northwind letter") and offer Customize for this job, which opens the editor pre-seeded per step 3.src/components/features/StandardLetterButton.tsx(new, ~40 LOC) — one panel-level affordance on/jobs/→ Saved jobs that opensLetterEditorDialogwith nojobId. This is where a standard letter is created and edited. Company letters are created via "Customize for this company" from a job row, so they need no separate entry point.src/components/features/JobTracker.tsx— mount the button, thread the resolved letter and scope down to each row.Reuse analysis
Capability: author, view, and pick among cover letters at three scopes.
Existing surfaces found:
src/components/features/LetterEditorDialog.tsx— already owns authoring and revising aLetterRecord. Extend (optionaljobId+ the picker); do not add a second editor for company/standard letters. They are the same act on the same record type.src/components/features/LetterRevealDialog.tsx— already owns reading and copying. Extend with the scope label.src/components/features/JobLetterIndicator.tsx— already owns the row's click → acknowledge → reveal/edit state machine. Extend; its two glyphs are unchanged.@design-system—Dialog,Button,TextAreaFieldalready cover every control needed. No new primitive.Decision: extend all three, plus one new file —
StandardLetterButton— because no existing surface owns a panel-level (not per-row) letter affordance, and folding it intoJobTrackerwould push that file further past the ~200 LOC guideline it already sits near.Acceptance criteria
job, even when a company and standard letter also existcompanystandardcompanyis empty never matches a company letterjobId; the source letter is byte-identical afterwardsjobIdand saves a letter with neither keyJobLetterIndicatorbehaviour is unchanged for a store containing only job letters — the whole pre-existing test file still passes untouchednpm run verifygreenOut of scope