feat(web): check mode with pinned issues, job match, a writing review and proposals

- The score ring (live checks only, easing to each new score), the verdict,
  and Issues · Job match · Writing tabs.
- Issues: numbered cards with category, explanation and fix, pinned to their
  lines on the page with warn pins and wavy underlines. One-step fixes apply
  with undo; the rest open the field in Write. Show on page, and Ignore (Keep
  for the two-column issue), stored with the resume. Category rows below, open
  when they need attention. "Also check the exported PDF" reports in a toast.
- Job match reads the linked application's posting, or a pasted one that can
  be saved as an application. Missing terms ask where they belong (Add to
  Skills) or can be hidden; covered terms light up their entries on the page.
- Writing: an opt-in AI review that says what it sends. Rewrites of bullets
  and paragraphs arrive as proposals: struck-through old text and highlighted
  new text on the page, numbered markers, Accept, Reject, Accept all, A/R and
  arrow keys, one undo step. Out-of-date proposals can't be applied.
- "What a person sees / What a parser reads": the parser view extracts the
  text of the PDF on the page, in reading order, flagging issue lines.
- Phones step through issues on the page; tablet pins open the drawer.
- The builder's ATS section and its deep-check UI are gone.
This commit is contained in:
Amruth Pillai
2026-09-28 21:33:41 +02:00
parent 1608d56903
commit 565424631a
38 changed files with 3310 additions and 986 deletions
@@ -24,6 +24,8 @@ export type Resume = {
hasPassword?: boolean;
isPublic?: boolean;
showDownloadButtons?: boolean;
/** The job application this resume was made for; Check's Job match reads its posting. */
applicationId?: string | null;
};
// Mirrors the server-side ResumeUpdatedEvent discriminator (packages/api resume/events.ts).
@@ -0,0 +1,80 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CheckIssue } from "./issues";
import { t } from "@lingui/core/macro";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "../store";
import { useEditorMode } from "../use-editor-mode";
import { getScrollBehavior, revealSelectionInPanel } from "../write/reveal";
/** The author's Check choices on a draft, created on first use. */
export function checkStateOf(draft: ResumeData) {
draft.metadata.check ??= { ignored: [], hiddenTerms: [] };
return draft.metadata.check;
}
/** One undo step, confirmed in a toast whose Undo takes it back. */
export function editWithUndo(edit: (draft: ResumeData) => void, message: string) {
const store = useResumeStore.getState();
store.updateResumeData(edit, { newStep: true });
toast.add({
description: message,
actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() },
});
}
/** Scrolls the page to an issue's pin, or the panel to its card. */
export function scrollToIssue(key: string, where: "page" | "panel") {
requestAnimationFrame(() => {
const selector =
where === "page" ? `[data-issue-pin="${CSS.escape(key)}"]` : `[data-issue-card="${CSS.escape(key)}"]`;
document.querySelector(selector)?.scrollIntoView({ block: "center", behavior: getScrollBehavior() });
});
}
/** What a Check card can do: fix, open the field in Write, show its line on the page, or set it aside. */
export function useCheckActions() {
const [, setMode] = useEditorMode();
const breakpoint = useBreakpoint();
const showOnPage = (issue: CheckIssue) => {
const editor = useEditorStore.getState();
editor.setCheckIssue(issue.key);
editor.setPageView("page");
// The page is behind the drawer on tablets and in its own view on phones.
if (breakpoint === "tablet") editor.setDrawerOpen(false);
if (breakpoint === "mobile") editor.setMobileView("page");
scrollToIssue(issue.key, "page");
};
const fix = (issue: CheckIssue) => {
if (issue.fix.kind === "apply") {
editWithUndo(issue.fix.apply, t`${issue.fix.done}. Issue resolved.`);
useEditorStore.getState().setCheckIssue(null);
return;
}
setMode("write");
if (breakpoint === "mobile") useEditorStore.getState().setMobileView("write");
revealSelectionInPanel(issue.fix.target);
};
const ignore = (issue: CheckIssue) => {
editWithUndo(
(draft) => {
const state = checkStateOf(draft);
if (!state.ignored.includes(issue.key)) state.ignored.push(issue.key);
},
issue.keepLabel ? t`Kept as it is` : t`Issue ignored`,
);
useEditorStore.getState().setCheckIssue(null);
};
const restoreIgnored = () =>
editWithUndo((draft) => {
checkStateOf(draft).ignored = [];
}, t`Ignored issues are back`);
return { showOnPage, fix, ignore, restoreIgnored };
}
@@ -0,0 +1,379 @@
import type { AtsCategory, AtsReport } from "@reactive-resume/resume/ats";
import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
import type { CheckTab } from "../store";
import type { CheckIssue } from "./issues";
import type { CheckResult } from "./use-check";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useState } from "react";
import { ATS_CATEGORIES } from "@reactive-resume/resume/ats";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { AtsPdfReportView } from "@/features/ats-checker/report/report-view";
import { blobToPdfFile, runAtsCheck } from "@/features/ats-checker/run-ats-check";
import { useIsResumeLocked } from "@/features/resume/builder/draft";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { useEditorStore } from "../store";
import { useCheckActions } from "./actions";
import { getCategoryDescription, getCategoryName } from "./issues";
import { JobMatchTab, useJobMatch } from "./job-match";
import { useCheck } from "./use-check";
import { WritingTab } from "./writing-tab";
/**
* Check: the score over the live checks, then Issues (numbered cards pinned to their lines), Job match (terms from
* the linked application's posting, not scored) and Writing (an opt-in AI review, not scored).
*/
export function CheckPanel() {
const check = useCheck();
const tab = useEditorStore((state) => state.checkTab);
const setTab = useEditorStore((state) => state.setCheckTab);
const match = useJobMatch(check?.data);
if (!check) return null;
return (
<div className="grid content-start gap-3.5 p-4 pb-12">
<ScoreSummary report={check.report} />
<Tabs value={tab} onValueChange={(value) => setTab(value as CheckTab)} className="gap-3.5">
<TabsList aria-label={t`Check views`} className="h-[38px] w-full">
<TabsTrigger value="issues" className="gap-1.5">
<Trans>Issues</Trans>
<span className="font-mono text-ink-3 text-xs">{check.issues.length}</span>
</TabsTrigger>
<TabsTrigger value="match" className="gap-1.5">
<Trans>Job match</Trans>
{match.result && (
<span className="font-mono text-ink-3 text-xs">
{match.result.found.length}/{match.result.total}
</span>
)}
</TabsTrigger>
<TabsTrigger value="writing">
<Trans>Writing</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="issues">
<IssuesTab check={check} />
</TabsContent>
<TabsContent value="match">
<JobMatchTab match={match} data={check.data} />
</TabsContent>
<TabsContent value="writing">
<WritingTab data={check.data} issues={check.issues} />
</TabsContent>
</Tabs>
</div>
);
}
const getVerdict = (score: number) =>
score >= 80 ? t`Reads cleanly` : score >= 50 ? t`Mostly readable` : t`Hard for software to read`;
/** The 84px ring (accent, or warn below 80), the verdict and what the score counts. */
function ScoreSummary({ report }: { report: AtsReport }) {
const { score, passedRules, totalRules } = report;
const open = report.findings.length;
return (
<div className="flex items-center gap-4 rounded-xl border border-line p-4">
<div
role="img"
aria-label={t`Readability score: ${score} out of 100`}
className="score-ring grid size-[84px] shrink-0 place-items-center rounded-full"
style={
{
"--ring-value": `${score}%`,
"--ring-color": score >= 80 ? "var(--accent)" : "var(--warn)",
} as CSSProperties
}
>
<span className="grid size-[70px] place-items-center rounded-full bg-surface font-display font-medium text-[26px]">
{score}
</span>
</div>
<div className="grid min-w-0 gap-1">
<strong className="font-semibold text-[15px]">{getVerdict(score)}</strong>
<span className="text-[13px] text-ink-2 leading-[19px]">
<Trans>
{passedRules} of {totalRules} checks pass.
</Trans>{" "}
{open > 0 ? (
<Plural value={open} one="# thing to review." other="# things to review." />
) : (
<Trans>Nothing to review.</Trans>
)}
</span>
<span className="flex items-center gap-1 text-ink-3 text-xs">
<Icon name="bolt" size={14} />
<Trans>Live · updates as you edit</Trans>
</span>
</div>
</div>
);
}
function IssuesTab({ check }: { check: CheckResult }) {
const { report, issues, data } = check;
const selected = useEditorStore((state) => state.checkIssue);
const { restoreIgnored } = useCheckActions();
const locked = useIsResumeLocked();
return (
<div className="grid gap-2.5">
{issues.length === 0 ? (
<div className="flex gap-2.5 rounded-xl bg-accent-soft p-3 text-[13px] text-accent-text leading-[19px]">
<Icon name="check_circle" />
<p>
<Trans>
<strong>Nothing to fix.</strong> Every check passes.
</Trans>
</p>
</div>
) : (
<ol aria-label={t`Issues`} className="grid gap-2.5">
{issues.map((issue) => (
<IssueCard key={issue.key} issue={issue} selected={selected === issue.key} locked={locked} />
))}
</ol>
)}
<CategoryRows report={report} />
<p className="px-1 text-ink-3 text-xs leading-[17px]">
<Trans>
The score counts how reliably software reads your resume. It doesn't predict whether you'll be shortlisted.
</Trans>{" "}
{report.ignored.length > 0 && (
<>
<Plural value={report.ignored.length} one="# issue is ignored." other="# issues are ignored." />{" "}
<button
type="button"
disabled={locked}
onClick={restoreIgnored}
className="font-medium text-ink-2 underline underline-offset-2"
>
<Trans>Show them again</Trans>
</button>
</>
)}
</p>
<DeepCheck data={data} />
</div>
);
}
type IssueCardProps = { issue: CheckIssue; selected: boolean; locked: boolean };
/** A numbered card: category, title, a plain explanation, the fix, Show on page and Ignore (or Keep). */
function IssueCard({ issue, selected, locked }: IssueCardProps) {
const { fix, ignore, showOnPage } = useCheckActions();
const setCheckIssue = useEditorStore((state) => state.setCheckIssue);
const titleId = `issue-${issue.number}-title`;
const applies = issue.fix.kind === "apply";
return (
// Clicking a card picks it, outlining its line on the page; Show on page does the same from the keyboard.
// biome-ignore lint/a11y/useKeyWithClickEvents: the buttons inside cover the keyboard.
<li
data-issue-card={issue.key}
aria-labelledby={titleId}
onClick={() => setCheckIssue(issue.key)}
className={cn(
"grid cursor-pointer gap-2.5 rounded-xl border bg-surface p-3.5 transition-[border-color,box-shadow] duration-standard",
selected ? "border-warn shadow-e2" : "border-line",
)}
>
<div className="flex items-start gap-2.5">
<span
aria-hidden="true"
className="grid size-[22px] shrink-0 place-items-center rounded-full bg-warn font-bold text-[12px] text-[oklch(0.22_0.03_80)]"
>
{issue.number}
</span>
<div className="grid min-w-0 gap-[3px]">
<span className="font-mono text-[11px] text-ink-3 uppercase">{getCategoryName(issue.category)}</span>
<h3 id={titleId} className="font-semibold text-sm leading-5">
<span className="sr-only">
<Trans>Issue {issue.number}:</Trans>{" "}
</span>
{issue.title}
</h3>
<p className="text-[13px] text-ink-2 leading-[19px]">{issue.body}</p>
</div>
</div>
{/* Touch size on phones (B1). */}
<div className="ms-8 flex flex-wrap gap-1.5 [&_button]:max-sm:h-10 [&_button]:max-sm:px-3.5 [&_button]:max-sm:text-sm">
<Button
size="sm"
variant={applies ? "primary" : "secondary"}
disabled={locked && applies}
onClick={(event) => {
event.stopPropagation();
fix(issue);
}}
>
{issue.fix.kind === "apply" ? <Icon name={issue.fix.icon} size={16} /> : <Icon name="edit" size={16} />}
{issue.fix.label}
</Button>
{issue.target && (
<Button
size="sm"
variant="ghost"
onClick={(event) => {
event.stopPropagation();
showOnPage(issue);
}}
>
<Trans>Show on page</Trans>
</Button>
)}
<Button
size="sm"
variant="ghost"
disabled={locked}
onClick={(event) => {
event.stopPropagation();
ignore(issue);
}}
>
{issue.keepLabel ? <Trans>Keep</Trans> : <Trans>Ignore</Trans>}
</Button>
</div>
</li>
);
}
/** Contact details, Dates, Layout, Section headings, Writing: "n of m", open when something needs attention. */
function CategoryRows({ report }: { report: AtsReport }) {
const [toggled, setToggled] = useState<Partial<Record<AtsCategory, boolean>>>({});
const categories = ATS_CATEGORIES.filter((category) => report.categories[category].total > 0);
return (
<section aria-labelledby="check-categories" className="grid gap-2 pt-2">
<h3 id="check-categories" className="px-1 font-semibold text-ink-3 text-xs">
<Trans>Checks by category</Trans>
</h3>
<div className="overflow-hidden rounded-xl border border-line">
{categories.map((category, index) => {
const { total, passed } = report.categories[category];
const failing = total - passed;
const open = toggled[category] ?? failing > 0;
const descriptionId = `check-category-${category}`;
return (
<div key={category} className={cn(index > 0 && "border-line border-t")}>
<button
type="button"
aria-expanded={open}
aria-controls={descriptionId}
onClick={() => setToggled((current) => ({ ...current, [category]: !open }))}
className="flex h-12 w-full items-center gap-2.5 px-3 text-start font-medium text-sm transition-colors duration-quick hover:bg-hover"
>
<Icon
name={failing > 0 ? "error" : "check_circle"}
className={failing > 0 ? "text-warn-text" : "text-accent-text"}
/>
{getCategoryName(category)}
<span className={cn("font-normal text-xs", failing > 0 ? "text-warn-text" : "text-ink-3")}>
{failing > 0 ? (
<Plural value={failing} one="# to review" other="# to review" />
) : (
<Trans>
{passed} of {total}
</Trans>
)}
</span>
<Icon
name="expand_more"
className={cn("ms-auto text-ink-3 transition-transform duration-standard", open && "rotate-180")}
/>
</button>
<p
id={descriptionId}
hidden={!open}
className="px-3 ps-[42px] pb-3 text-[13px] text-ink-2 leading-[19px]"
>
{getCategoryDescription(category)}
</p>
</div>
);
})}
</div>
</section>
);
}
/**
* Also check the exported PDF: renders it, runs the file-level engine on it in this tab and reports in a toast.
* Its full report opens from the toast.
*/
function DeepCheck({ data }: { data: ResumeData }) {
const [running, setRunning] = useState(false);
const [report, setReport] = useState<PdfAtsReport | null>(null);
const run = async () => {
setRunning(true);
try {
const blob = await createResumePdfBlob(data);
const result = await runAtsCheck(blobToPdfFile(blob, "resume.pdf"));
const problems = result.report.findings.length;
toast.add(
problems === 0
? { description: t`Exported PDF checked: it reads cleanly too.` }
: {
description: t`Exported PDF checked: ${problems} more to look at.`,
actionProps: { children: t`Show`, onClick: () => setReport(result.report) },
},
);
} catch {
toast.add({ type: "error", description: t`The exported PDF couldn't be checked. Try again.` });
} finally {
setRunning(false);
}
};
return (
<>
<Button variant="ghost" size="sm" className="w-fit text-ink-2" disabled={running} onClick={() => void run()}>
{running ? <Spinner /> : <Icon name="picture_as_pdf" size={18} />}
{running ? <Trans>Checking the exported PDF…</Trans> : <Trans>Also check the exported PDF</Trans>}
</Button>
<Dialog open={report !== null} onOpenChange={(open) => !open && setReport(null)}>
<DialogContent className="max-h-[85svh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
<Trans>The exported PDF</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
What the file-level check found in the PDF you'd send. It ran in this tab; nothing was uploaded.
</Trans>
</DialogDescription>
</DialogHeader>
{report && <AtsPdfReportView report={report} />}
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,126 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CheckIssue } from "./issues";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { produce } from "immer";
import { lintResumeForAts } from "@reactive-resume/resume/ats";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { buildIssues } from "./issues";
const NOW = new Date("2026-09-28T00:00:00Z");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en-US", messages: {} });
});
function makeResume(mutate: (data: ResumeData) => void = () => undefined): ResumeData {
return produce(defaultResumeData, (data) => {
data.basics.name = "Jordan Reyes";
data.basics.email = "jordan@reyes.design";
data.basics.phone = "+49 151 2345 6789";
data.basics.location = "Berlin";
data.sections.experience.items = [
{
id: "kettle",
hidden: false,
company: "Studio Kettle",
position: "Junior Designer",
location: "Lisbon",
period: "2016 - 2019",
website: { url: "", label: "", inlineLink: false },
description: "<p>Designed identities for small businesses.</p>",
roles: [],
} as ResumeData["sections"]["experience"]["items"][number],
];
data.metadata.template = "onyx";
data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: [] }];
mutate(data);
});
}
const issuesOf = (data: ResumeData) => buildIssues(lintResumeForAts(data, { now: NOW }), data);
const only = (data: ResumeData, code: string): CheckIssue => {
const issue = issuesOf(data).find((entry) => entry.finding.code === code);
if (!issue) throw new Error(`No ${code} issue`);
return issue;
};
/** Applies an issue's one-step fix to the data and returns the result. */
function applyFix(data: ResumeData, issue: CheckIssue) {
if (issue.fix.kind !== "apply") throw new Error(`${issue.finding.code} has no one-step fix`);
const { apply } = issue.fix;
return produce(data, (draft) => {
apply(draft);
});
}
describe("buildIssues", () => {
it("numbers open issues in the report's order and places each on its block", () => {
const data = makeResume((resume) => {
resume.basics.email = "";
const entry = resume.sections.experience.items[0];
if (entry) entry.period = "a while back";
});
const issues = issuesOf(data);
expect(issues.map((issue) => [issue.number, issue.finding.code])).toEqual([
[1, "MISSING_EMAIL"],
[2, "UNPARSEABLE_PERIOD"],
]);
expect(issues[0]?.target).toEqual({ kind: "header" });
expect(issues[1]?.target).toEqual({ kind: "item", sectionId: "experience", itemId: "kettle" });
expect(issues[1]?.category).toBe("dates");
expect(issues[1]?.fix).toMatchObject({ kind: "write", target: issues[1]?.target });
});
it("adds https:// to a link in one step, and sends links with another scheme to Write", () => {
const bare = makeResume((resume) => (resume.basics.website.url = "reyes.design"));
const fixed = applyFix(bare, only(bare, "MALFORMED_URL"));
expect(fixed.basics.website.url).toBe("https://reyes.design");
expect(issuesOf(fixed).map((issue) => issue.finding.code)).not.toContain("MALFORMED_URL");
const odd = makeResume((resume) => (resume.basics.website.url = "ftp:/reyes.design"));
expect(only(odd, "MALFORMED_URL").fix.kind).toBe("write");
});
it("switches a two-column page to one column without losing its sidebar sections", () => {
const data = makeResume((resume) => {
resume.metadata.template = "azurill";
resume.sections.skills.items = [
{ id: "s1", hidden: false, icon: "", iconColor: "", name: "Figma", proficiency: "", level: 0, keywords: [] },
];
resume.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }];
});
const issue = only(data, "TWO_COLUMN_LAYOUT");
expect(issue.keepLabel).toBe(true);
expect(issue.target).toBeNull();
const fixed = applyFix(data, issue);
expect(fixed.metadata.layout.pages).toEqual([{ fullWidth: true, main: ["experience", "skills"], sidebar: [] }]);
expect(issuesOf(fixed)).toEqual([]);
});
it("moves a prose section out of the sidebar, and resets an unusual heading", () => {
const data = makeResume((resume) => {
resume.sections.experience.title = "Where I've Been";
resume.metadata.layout.pages = [{ fullWidth: false, main: [], sidebar: ["experience"] }];
});
const moved = applyFix(data, only(data, "PROSE_SECTION_IN_SIDEBAR"));
expect(moved.metadata.layout.pages[0]).toMatchObject({ main: ["experience"], sidebar: [] });
const renamed = applyFix(data, only(data, "NON_STANDARD_SECTION_TITLE"));
expect(renamed.sections.experience.title).toBe("");
});
it("sets the minimum for type, spacing and margins", () => {
const data = makeResume((resume) => {
resume.metadata.typography.body.fontSize = 8;
resume.metadata.page.marginY = 4;
});
expect(applyFix(data, only(data, "SMALL_BODY_FONT")).metadata.typography.body.fontSize).toBe(9);
expect(applyFix(data, only(data, "TIGHT_PAGE_MARGINS")).metadata.page.marginY).toBe(8);
});
});
@@ -0,0 +1,377 @@
import type { PageMapTarget } from "@reactive-resume/pdf/page-map";
import type { AtsCategory, AtsFinding, AtsReport } from "@reactive-resume/resume/ats";
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
import type { IconName } from "@reactive-resume/ui/components/icon";
import { t } from "@lingui/core/macro";
import { atsRuleCategory } from "@reactive-resume/resume/ats";
import { getSectionTitle } from "@/libs/resume/section";
import { describeEntry, findEntry, getSectionObject, resolveSection } from "../write/model";
/** How a card fixes its issue: in one step on the resume (with undo), or by opening the entry in Write. */
type IssueFix =
| { kind: "apply"; label: string; icon: IconName; done: string; apply: (draft: ResumeData) => void }
| { kind: "write"; label: string; target: PageMapTarget };
export type CheckIssue = {
key: string;
/** 1-based, in the order the cards list them; the pin on the page carries the same number. */
number: number;
finding: AtsFinding;
category: AtsCategory;
title: string;
body: string;
fix: IssueFix;
/** "Keep" instead of "Ignore": the issue follows from a design choice that is the author's to make. */
keepLabel: boolean;
/** The block on the page the issue is about, for its pin; null for page-wide settings. */
target: PageMapTarget | null;
};
export const getCategoryName = (category: AtsCategory) =>
({
contact: t`Contact details`,
dates: t`Dates`,
layout: t`Layout`,
headings: t`Section headings`,
writing: t`Writing`,
})[category];
export const getCategoryDescription = (category: AtsCategory) =>
({
contact: t`Name, email, phone and location are found, and links are full addresses.`,
dates: t`Every entry has dates in a form systems read, in order, and none in the future.`,
layout: t`Text comes out in the order a person reads it, at a size and spacing that survive.`,
headings: t`Sections use headings systems recognise, and work experience shows.`,
writing: t`Every role says what you did. Opinions on wording live in the Writing tab.`,
})[category];
const decodeToken = (token: string) => token.replace(/~1/g, "/").replace(/~0/g, "~");
const tokensOf = (pointer: string) => pointer.split("/").slice(1).map(decodeToken);
/** The section a pointer is in: the summary, a built-in section's key or a custom section's id. */
function sectionIdOf(tokens: readonly string[], data: ResumeData): string | undefined {
const [head, next] = tokens;
if (head === "summary") return "summary";
if (head === "sections") return next;
if (head === "customSections") return data.customSections[Number(next)]?.id;
return undefined;
}
function itemIdOf(tokens: readonly string[], data: ResumeData): string | undefined {
const itemsIndex = tokens.indexOf("items");
if (itemsIndex === -1) return undefined;
let node: unknown = data;
for (const token of tokens.slice(0, itemsIndex + 2)) node = (node as Record<string, unknown> | undefined)?.[token];
const id = (node as { id?: unknown } | undefined)?.id;
return typeof id === "string" ? id : undefined;
}
/** The block a finding is about: the header for contact details, else its section or entry. */
function getFindingTarget(finding: AtsFinding, data: ResumeData): PageMapTarget | null {
const tokens = tokensOf(finding.pointer);
if (tokens[0] === "basics" || tokens[0] === "picture") return { kind: "header" };
const sectionId = sectionIdOf(tokens, data);
if (!sectionId) return null;
const itemId = itemIdOf(tokens, data);
return itemId ? { kind: "item", sectionId, itemId } : { kind: "section", sectionId };
}
/** The printed name of a section: the title the author gave it, else its type's name. */
export function getSectionName(data: ResumeData, sectionId: string): string {
const section = resolveSection(data, sectionId);
if (!section) return sectionId;
const title = getSectionObject(data, section)?.title.trim();
return title || getSectionTitle(section.kind === "custom" ? section.type : (section.id as SectionType | "summary"));
}
function getEntryName(data: ResumeData, target: PageMapTarget | null): string {
if (target?.kind !== "item") return t`This entry`;
const section = resolveSection(data, target.sectionId);
const entry = findEntry(data, target.sectionId, target.itemId);
const title = section && entry ? describeEntry(section.type, entry).title : "";
return title ? `“${title}”` : t`This entry`;
}
/** Sets the value a finding's pointer names (the pointer still has array indexes, which match `draft`). */
function setAt(draft: ResumeData, pointer: string, value: unknown) {
const tokens = tokensOf(pointer);
const last = tokens.pop();
let node: unknown = draft;
for (const token of tokens) node = (node as Record<string, unknown> | undefined)?.[token];
if (node && last !== undefined) (node as Record<string, unknown>)[last] = value;
}
const HAS_SCHEME = /^[a-z][a-z\d+.-]*:/i;
type Message = { title: string; body: string; fix: IssueFix; keepLabel?: boolean };
function describeFinding(finding: AtsFinding, data: ResumeData, target: PageMapTarget | null): Message {
const value = String(finding.params?.value ?? "");
const section = String(finding.params?.section ?? "");
const minimum = Number(finding.params?.minimum ?? 0);
const write = (label: string): IssueFix => ({ kind: "write", label, target: target ?? { kind: "header" } });
const entry = getEntryName(data, target);
switch (finding.code) {
case "MISSING_NAME":
return {
title: t`Your name is missing`,
body: t`Systems file you under the name at the top of the page.`,
fix: write(t`Add your name`),
};
case "MISSING_EMAIL":
return {
title: t`No email address`,
body: t`Most systems file candidates by email address.`,
fix: write(t`Add an email`),
};
case "MALFORMED_EMAIL":
return {
title: t`Email address won't be recognised`,
body: t`“${value}” isn't a plain address like name@example.com.`,
fix: write(t`Edit the email`),
};
case "MISSING_PHONE":
return {
title: t`No phone number`,
body: t`Some application systems ask for one before you can apply.`,
fix: write(t`Add a phone number`),
};
case "MISSING_LOCATION":
return {
title: t`No location`,
body: t`A city and country let roles be matched to where you are.`,
fix: write(t`Add a location`),
};
case "MALFORMED_URL": {
const url = `https://${value}`;
return {
title: t`A link isn't a full address`,
body: t`Some systems only recognise links that start with https://. This one reads as plain text: “${value}”.`,
fix: HAS_SCHEME.test(value)
? write(t`Edit the link`)
: {
kind: "apply",
label: t`Use ${url}`,
icon: "link",
done: t`Link updated`,
apply: (draft) => setAt(draft, finding.pointer, url),
},
};
}
case "PICTURE_PRESENT":
return {
title: t`Your resume has a photo`,
body: t`Some systems mishandle images, and photos are discouraged in some countries.`,
fix: {
kind: "apply",
label: t`Hide the photo`,
icon: "visibility_off",
done: t`Photo hidden`,
apply: (draft) => {
draft.picture.hidden = true;
},
},
};
case "EMPTY_PERIOD":
return {
title: t`An entry has no dates`,
body: t`${entry} doesn't say when. Dates place it on your timeline.`,
fix: write(t`Add dates`),
};
case "UNPARSEABLE_PERIOD":
return {
title: t`Dates won't be read`,
body: t`“${value}” isn't a form systems recognise, such as Mar 2020 – Present.`,
fix: write(t`Fix the dates`),
};
case "UNPARSEABLE_DATE":
return {
title: t`A date won't be read`,
body: t`“${value}” isn't a form systems recognise, such as March 2022.`,
fix: write(t`Fix the date`),
};
case "REVERSED_PERIOD":
return {
title: t`Dates run backwards`,
body: t`“${value}” ends before it starts.`,
fix: write(t`Fix the dates`),
};
case "FUTURE_DATED_PERIOD":
return {
title: t`Dates start in the future`,
body: t`“${value}” starts after today. Check the year, or mark it as current.`,
fix: write(t`Fix the dates`),
};
case "SECTION_MISSING_FROM_LAYOUT": {
const name = getSectionName(data, section);
return {
title: t`${name} never prints`,
body: t`It has entries but isn't placed on a page.`,
fix: {
kind: "apply",
label: t`Add it to page 1`,
icon: "add",
done: t`${name} added to page 1`,
apply: (draft) => {
const [page] = draft.metadata.layout.pages;
if (page) page.main.push(section);
else draft.metadata.layout.pages.push({ fullWidth: false, main: [section], sidebar: [] });
},
},
};
}
case "NO_VISIBLE_EXPERIENCE":
return {
title: t`No work experience shows`,
body: t`Systems look for an experience section. Projects or volunteering can show the same history.`,
fix: { kind: "write", label: t`Add experience`, target: { kind: "section", sectionId: "experience" } },
};
case "MISSING_EXPERIENCE_DESCRIPTION":
return {
title: t`A role has no description`,
body: t`${entry} names the role but not what you did, so it adds no keywords.`,
fix: write(t`Describe the role`),
};
case "NON_STANDARD_SECTION_TITLE": {
const title = String(finding.params?.title ?? "");
return {
title: t`“${title}” isn't a heading systems look for`,
body: t`They look for headings such as Experience, Education and Skills.`,
fix: {
kind: "apply",
label: t`Use the standard heading`,
icon: "title",
done: t`Heading changed`,
// An empty title prints the section type's name in the resume's language.
apply: (draft) => setAt(draft, finding.pointer, ""),
},
};
}
case "MULTI_COLUMN_PROSE_SECTION": {
const name = getSectionName(data, section);
const columns = Number(finding.params?.columns ?? 2);
return {
title: t`${name} is split into ${columns} columns`,
body: t`Columns can scramble the order text is read in.`,
fix: {
kind: "apply",
label: t`Use one column`,
icon: "view_list",
done: t`${name} uses one column`,
apply: (draft) => setAt(draft, finding.pointer, 1),
},
};
}
case "PROSE_SECTION_IN_SIDEBAR": {
const name = getSectionName(data, section);
return {
title: t`${name} sits in the sidebar`,
body: t`Sidebars are often read last, or mixed into the main column.`,
fix: {
kind: "apply",
label: t`Move it to the main column`,
icon: "arrow_forward",
done: t`${name} moved to the main column`,
apply: (draft) => {
for (const page of draft.metadata.layout.pages) {
if (!page.sidebar.includes(section)) continue;
page.sidebar = page.sidebar.filter((id) => id !== section);
page.main.push(section);
}
},
},
};
}
case "TWO_COLUMN_LAYOUT": {
const names = String(finding.params?.sections ?? "")
.split(",")
.filter(Boolean)
.map((id) => getSectionName(data, id))
.join(", ");
return {
title: t`Sidebar is read after the main column`,
body: t`${names} come last in the text. Most systems still find them; some don't.`,
keepLabel: true,
fix: {
kind: "apply",
label: t`Switch to one column`,
icon: "view_list",
done: t`Switched to one column`,
// Full-width pages print no sidebar, so its sections move into the main column first.
apply: (draft) => {
for (const page of draft.metadata.layout.pages) {
if (page.fullWidth || page.sidebar.length === 0) continue;
page.main.push(...page.sidebar);
page.sidebar = [];
page.fullWidth = true;
}
},
},
};
}
case "SMALL_BODY_FONT":
return {
title: t`Body text is very small`,
body: t`Text under ${minimum} pt can be misread when a system re-renders it.`,
fix: {
kind: "apply",
label: t`Use ${minimum} pt`,
icon: "format_size",
done: t`Body text set to ${minimum} pt`,
apply: (draft) => setAt(draft, finding.pointer, minimum),
},
};
case "TIGHT_LINE_HEIGHT":
return {
title: t`Lines are packed tightly`,
body: t`Lines this close can run together when read. Use at least ${minimum}.`,
fix: {
kind: "apply",
label: t`Use ${minimum}`,
icon: "tune",
done: t`Line height set to ${minimum}`,
apply: (draft) => setAt(draft, finding.pointer, minimum),
},
};
case "TIGHT_PAGE_MARGINS":
return {
title: t`Margins are very narrow`,
body: t`Text this close to the edge can be cut off. Use at least ${minimum} pt.`,
fix: {
kind: "apply",
label: t`Use ${minimum} pt margins`,
icon: "tune",
done: t`Margins set to ${minimum} pt`,
apply: (draft) => setAt(draft, finding.pointer, minimum),
},
};
}
}
/** The open findings as numbered cards, in the report's order (most severe first). */
export function buildIssues(report: AtsReport, data: ResumeData): CheckIssue[] {
return report.findings.map((finding, index) => {
const target = getFindingTarget(finding, data);
const message = describeFinding(finding, data, target);
return {
key: finding.key,
number: index + 1,
finding,
category: atsRuleCategory(finding.code),
title: message.title,
body: message.body,
fix: message.fix,
keepLabel: message.keepLabel ?? false,
target,
};
});
}
@@ -0,0 +1,554 @@
import type { JdTermMatch } from "@reactive-resume/resume/ats-pdf";
import type { ResumeData, SkillItem } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useMemo, useState } from "react";
import { buildMarkdown } from "@reactive-resume/resume/markdown";
import { Button } from "@reactive-resume/ui/components/button";
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 { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { generateId } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { applicationsListQueryKey, applicationsListQueryOptions } from "@/features/applications/queries";
import { useCurrentBuilderResumeSelector, useIsResumeLocked, usePatchResume } from "@/features/resume/builder/draft";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useEditorStore } from "../store";
import { checkStateOf, editWithUndo } from "./actions";
/** Matches the applications feature's cap on a saved posting. */
const MAX_POSTING_CHARS = 20_000;
/** A posting term, with `label` as the posting writes it ("C#" rather than the matcher's "csharp"). */
type MatchedTerm = JdTermMatch & { label: string };
type JobMatchResult = { found: MatchedTerm[]; missing: MatchedTerm[]; total: number };
export type JobMatch = {
/** The application the resume was made for, once the list has loaded. */
application: { id: string; company: string; role: string; jobDescription: string | null } | null;
/** The linked application's posting, or the one pasted this visit when none is linked. */
posting: string;
result: JobMatchResult | null;
hiddenTerms: readonly string[];
};
/**
* Job match reads the posting of the application the resume is linked to, or one pasted for this visit, and sorts
* its terms into found and missing. Terms hidden as "not true for me" are left out. It isn't part of the score.
*/
export function useJobMatch(data: ResumeData | undefined): JobMatch {
const applicationId = useCurrentBuilderResumeSelector((resume) => resume.applicationId ?? null);
const pasted = useEditorStore((state) => state.pastedPosting);
const { data: applications } = useQuery(applicationsListQueryOptions());
const application = applications?.find((entry) => entry.id === applicationId) ?? null;
const posting = application ? (application.jobDescription ?? "").trim() : pasted.trim();
// The matcher (stemmer and skill aliases) loads only once there's a posting to match.
const { data: engine } = useQuery({
queryKey: ["ats-pdf-engine"],
queryFn: () => import("@reactive-resume/resume/ats-pdf"),
staleTime: Number.POSITIVE_INFINITY,
enabled: posting.length > 0,
});
const hiddenTerms = data?.metadata.check?.hiddenTerms;
const resumeText = useMemo(() => (data ? buildMarkdown(data) : ""), [data]);
const result = useMemo(() => {
if (!engine || !posting) return null;
const hidden = new Set(hiddenTerms ?? []);
const terms = engine
.matchJobDescription({ jobDescription: posting, resumeText })
.terms.filter((term) => !hidden.has(term.term))
.map((term) => ({ ...term, label: termAsWritten(engine.surfaceFormsOf(term.term), posting) ?? term.term }));
return {
found: terms.filter((term) => term.resumeCount > 0),
missing: terms.filter((term) => term.resumeCount === 0),
total: terms.length,
};
}, [engine, posting, resumeText, hiddenTerms]);
return { application, posting, result, hiddenTerms: hiddenTerms ?? [] };
}
/** The first spelling of a term the posting uses, as it writes it ("Figma", not "figma"). */
function termAsWritten(forms: readonly string[], posting: string): string | undefined {
for (const form of forms) {
const escaped = form.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const found = posting.match(new RegExp(escaped, "i"))?.[0];
if (found) return found;
}
}
type JobMatchTabProps = { match: JobMatch; data: ResumeData };
export function JobMatchTab({ match, data }: JobMatchTabProps) {
const { application, posting, result, hiddenTerms } = match;
const [openTerm, setOpenTerm] = useState<string | null>(null);
const highlightTerm = useEditorStore((state) => state.highlightTerm);
const setHighlightTerm = useEditorStore((state) => state.setHighlightTerm);
const locked = useIsResumeLocked();
if (!posting) return <NoPosting application={application} />;
const open = result?.missing.find((term) => term.term === openTerm);
const skills = data.sections.skills.items.find((item) => !item.hidden);
const addToSkills = (term: MatchedTerm) => {
const written = term.label;
editWithUndo((draft) => {
const target = draft.sections.skills.items.find((item) => item.id === skills?.id);
if (target) target.keywords.push(written);
else draft.sections.skills.items.push(newSkill(written));
}, t`“${written}” added to Skills`);
setOpenTerm(null);
setHighlightTerm(term.term);
};
const hideTerm = (term: MatchedTerm) => {
editWithUndo((draft) => {
const state = checkStateOf(draft);
if (!state.hiddenTerms.includes(term.term)) state.hiddenTerms.push(term.term);
}, t`“${term.label}” hidden`);
setOpenTerm(null);
};
return (
<div className="grid gap-3.5">
<PostingSource application={application} />
{!result ? (
<p className="flex items-center gap-2 text-ink-2 text-sm">
<Trans>Reading the posting…</Trans>
</p>
) : result.total === 0 ? (
<p className="text-ink-2 text-sm">
<Trans>No terms stood out in this posting.</Trans>
</p>
) : (
<>
<div className="grid gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<strong className="font-semibold text-sm">
<Trans>
{result.found.length} of {result.total} posting terms appear
</Trans>
</strong>
<span className="text-ink-3 text-xs">
<Trans>not scored</Trans>
</span>
</div>
<div
role="progressbar"
aria-label={t`Posting terms in your resume`}
aria-valuemin={0}
aria-valuemax={result.total}
aria-valuenow={result.found.length}
className="h-1.5 overflow-hidden rounded-full bg-sunken"
>
<div
className="h-full rounded-full bg-accent transition-[width] duration-emphasized ease-enter"
style={{ width: `${(result.found.length / result.total) * 100}%` }}
/>
</div>
</div>
{result.missing.length > 0 && (
<section aria-labelledby="match-missing" className="grid gap-2">
<h3 id="match-missing" className="font-semibold text-ink-3 text-xs uppercase">
<Trans>Not in your resume · add only if true</Trans>
</h3>
<div className="flex flex-wrap gap-1.5">
{result.missing.map((term) => (
<button
key={term.term}
type="button"
aria-expanded={openTerm === term.term}
onClick={() => setOpenTerm(openTerm === term.term ? null : term.term)}
className={cn(
"flex h-[30px] items-center gap-1 rounded-[7px] border border-dashed px-2.5 font-medium text-[13px] transition-colors duration-quick",
openTerm === term.term ? "border-accent bg-accent-soft" : "border-line-2 hover:bg-hover",
)}
>
<Icon name="add" size={15} className="text-ink-3" />
{term.label}
</button>
))}
</div>
{open && (
<section
aria-label={t`Add “${open.label}”`}
className="grid gap-2 rounded-xl border border-line bg-raised p-3 shadow-e2"
>
<p className="text-[13px] text-ink-2 leading-[19px]">
<Plural
value={open.jdCount}
one={`“${open.label}” appears once in the posting.`}
other={`“${open.label}” appears #× in the posting.`}
/>{" "}
<Trans>If it's part of your experience, add it where it belongs.</Trans>
</p>
<div className="grid justify-items-start gap-1">
<Button size="sm" disabled={locked} onClick={() => addToSkills(open)}>
<Icon name="add" size={16} />
{skills?.name ? <Trans>Add to Skills · {skills.name}</Trans> : <Trans>Add to Skills</Trans>}
</Button>
<Button size="sm" variant="ghost" disabled={locked} onClick={() => hideTerm(open)}>
<Trans>Not true for me, hide it</Trans>
</Button>
</div>
</section>
)}
</section>
)}
{result.found.length > 0 && (
<section aria-labelledby="match-found" className="grid gap-2">
<h3 id="match-found" className="font-semibold text-ink-3 text-xs uppercase">
<Trans>Already covered · pick one to find it on the page</Trans>
</h3>
<div className="flex flex-wrap gap-1.5">
{result.found.map((term) => {
const on = highlightTerm === term.term;
return (
<button
key={term.term}
type="button"
aria-pressed={on}
onClick={() => setHighlightTerm(on ? null : term.term)}
className={cn(
"flex h-7 items-center gap-1 rounded-md px-2 font-medium text-xs transition-colors duration-quick",
on ? "bg-[#F2DE8C] text-[oklch(0.3_0.05_80)]" : "bg-accent-soft text-accent-text",
)}
>
<Icon name="check" size={14} />
{term.label}
</button>
);
})}
</div>
</section>
)}
</>
)}
{hiddenTerms.length > 0 && (
<p className="text-ink-3 text-xs">
<Trans>Hidden: {hiddenTerms.join(", ")}</Trans> ·{" "}
<button
type="button"
disabled={locked}
className="font-medium text-ink-2 underline underline-offset-2"
onClick={() =>
editWithUndo((draft) => {
checkStateOf(draft).hiddenTerms = [];
}, t`Hidden terms are back`)
}
>
<Trans>Show again</Trans>
</button>
</p>
)}
</div>
);
}
const newSkill = (name: string): SkillItem => ({
id: generateId(),
hidden: false,
icon: "",
iconColor: "",
name,
proficiency: "",
level: 0,
keywords: [],
});
function useLinkApplication() {
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const patchResume = usePatchResume();
const { mutateAsync, isPending } = useMutation(orpc.documents.linkApplication.mutationOptions());
const link = async (applicationId: string | null) => {
try {
await mutateAsync({ type: "resume", id: resumeId, applicationId });
patchResume((resume) => {
resume.applicationId = applicationId;
});
} catch (error) {
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't link the application.` }),
});
}
};
return { link, isPending };
}
/** Where the posting comes from, with Change (link another application or unlink) or Save as application. */
function PostingSource({ application }: { application: JobMatch["application"] }) {
const [changing, setChanging] = useState(false);
const setPastedPosting = useEditorStore((state) => state.setPastedPosting);
const { link } = useLinkApplication();
if (!application) {
return (
<div className="grid gap-2 rounded-[10px] bg-bg p-3">
<div className="flex items-center gap-2.5">
<span className="grid size-7 place-items-center rounded-[7px] bg-sunken text-ink-2">
<Icon name="content_copy" size={16} />
</span>
<span className="grid min-w-0 flex-1">
<b className="font-semibold text-[13px]">
<Trans>Pasted posting</Trans>
</b>
<span className="text-ink-3 text-xs">
<Trans>For this visit only</Trans>
</span>
</span>
<Button size="sm" variant="secondary" onClick={() => setPastedPosting("")}>
<Trans>Clear</Trans>
</Button>
</div>
<SaveAsApplication />
</div>
);
}
return (
<div className="grid gap-2 rounded-[10px] bg-bg p-3">
<div className="flex items-center gap-2.5">
<span className="grid size-7 place-items-center rounded-[7px] bg-sunken font-semibold text-[13px] text-ink-2">
{application.company.slice(0, 1).toUpperCase()}
</span>
<span className="grid min-w-0 flex-1">
<b className="truncate font-semibold text-[13px]">
{application.role} · {application.company}
</b>
<span className="text-ink-3 text-xs">
<Trans>Posting from the linked application</Trans>
</span>
</span>
<Button size="sm" variant="secondary" aria-expanded={changing} onClick={() => setChanging(!changing)}>
<Trans>Change</Trans>
</Button>
</div>
{changing && (
<div className="grid gap-2">
<ApplicationPicker
value={application.id}
onChange={(id) => {
setChanging(false);
void link(id);
}}
/>
<Button
size="sm"
variant="ghost"
className="w-fit"
onClick={() => {
setChanging(false);
void link(null);
}}
>
<Trans>Unlink this application</Trans>
</Button>
</div>
)}
</div>
);
}
function ApplicationPicker({ value, onChange }: { value: string | null; onChange: (id: string) => void }) {
const { data: applications } = useQuery(applicationsListQueryOptions());
const choices = (applications ?? []).filter((application) => !application.archived);
// Linking changes the document's details, which a locked document keeps as they are.
const locked = useIsResumeLocked();
return (
<NativeSelect
aria-label={t`Link an application`}
disabled={locked}
value={value ?? ""}
onChange={(event) => event.target.value && onChange(event.target.value)}
>
<option value="" disabled>
{choices.length > 0 ? t`Link an application…` : t`No applications yet`}
</option>
{choices.map((application) => (
<option key={application.id} value={application.id}>
{application.role} · {application.company}
</option>
))}
</NativeSelect>
);
}
/**
* C2: nothing to match against. Link an existing application first; pasting a posting is the fallback. A linked
* application without a saved posting takes one here, saved to the application.
*/
function NoPosting({ application }: { application: JobMatch["application"] }) {
const pasted = useEditorStore((state) => state.pastedPosting);
const setPastedPosting = useEditorStore((state) => state.setPastedPosting);
const [draft, setDraft] = useState(pasted);
const { link, isPending } = useLinkApplication();
const queryClient = useQueryClient();
const { mutate: savePosting, isPending: saving } = useMutation({
...orpc.applications.update.mutationOptions(),
onSuccess: () => queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() }),
onError: (error) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't save the posting.` }),
}),
});
if (application) {
return (
<div className="grid gap-2.5 rounded-xl border border-line p-4">
<strong className="font-semibold text-sm">
{application.role} · {application.company}
</strong>
<p className="text-[13px] text-ink-2 leading-[19px]">
<Trans>This application has no posting saved yet. Paste it to match against it.</Trans>
</p>
<Textarea
aria-label={t`Job posting`}
rows={5}
maxLength={MAX_POSTING_CHARS}
value={draft}
placeholder={t`Paste a job posting…`}
onChange={(event) => setDraft(event.target.value)}
/>
<Button
size="sm"
className="w-fit"
disabled={!draft.trim() || saving}
onClick={() => savePosting({ id: application.id, jobDescription: draft.trim() })}
>
<Trans>Save to the application</Trans>
</Button>
</div>
);
}
return (
<div className="grid gap-2.5 rounded-xl border border-line p-4">
<strong className="font-semibold text-sm">
<Trans>Match against a job</Trans>
</strong>
<p className="text-[13px] text-ink-2 leading-[19px]">
<Trans>This resume isn't linked to an application yet.</Trans>
</p>
<ApplicationPicker value={null} onChange={(id) => void link(id)} />
{isPending && (
<span className="text-ink-3 text-xs">
<Trans>Linking…</Trans>
</span>
)}
<span className="text-center text-ink-3 text-xs">
<Trans>or</Trans>
</span>
<Textarea
aria-label={t`Job posting`}
rows={4}
maxLength={MAX_POSTING_CHARS}
value={draft}
placeholder={t`Paste a job posting…`}
onChange={(event) => setDraft(event.target.value)}
/>
<Button size="sm" className="w-fit" disabled={!draft.trim()} onClick={() => setPastedPosting(draft)}>
<Trans>Match this posting</Trans>
</Button>
<span className="text-ink-3 text-xs leading-[17px]">
<Trans>A pasted posting can be saved as an application afterwards.</Trans>
</span>
</div>
);
}
/** Saves the pasted posting as a new application (company and role are required) and links the resume to it. */
function SaveAsApplication() {
const [open, setOpen] = useState(false);
const [company, setCompany] = useState("");
const [role, setRole] = useState("");
const posting = useEditorStore((state) => state.pastedPosting);
const setPastedPosting = useEditorStore((state) => state.setPastedPosting);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const queryClient = useQueryClient();
const { link } = useLinkApplication();
const { mutateAsync: create, isPending } = useMutation(orpc.applications.create.mutationOptions());
const companyId = useId();
if (!open) {
return (
<Button size="sm" variant="ghost" className="w-fit" onClick={() => setOpen(true)}>
<Icon name="work" size={16} />
<Trans>Save as application…</Trans>
</Button>
);
}
const save = async () => {
try {
const id = await create({
company: company.trim(),
role: role.trim(),
jobDescription: posting.trim().slice(0, MAX_POSTING_CHARS),
resumeId,
});
await queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
await link(id);
setPastedPosting("");
toast.add({ description: t`Saved as an application and linked` });
} catch (error) {
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't save the application.` }),
});
}
};
return (
<form
className="grid gap-2"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-1">
<Label htmlFor={companyId}>
<Trans>Company</Trans>
</Label>
<Input id={companyId} required value={company} onChange={(event) => setCompany(event.target.value)} />
</div>
<div className="grid gap-1">
<Label htmlFor={`${companyId}-role`}>
<Trans>Role</Trans>
</Label>
<Input id={`${companyId}-role`} required value={role} onChange={(event) => setRole(event.target.value)} />
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" type="submit" disabled={!company.trim() || !role.trim() || isPending}>
<Trans>Save and link</Trans>
</Button>
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
</div>
</form>
);
}
@@ -0,0 +1,295 @@
import type { PageMap, PageMapNode, PageMapTarget } from "@reactive-resume/pdf/page-map";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
import type { CheckIssue } from "./issues";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMemo } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { getProposalState } from "../proposals/proposals";
import { isSameSelection, useEditorStore } from "../store";
import { findEntry } from "../write/model";
import { scrollToIssue, useCheckActions } from "./actions";
import { useCheck } from "./use-check";
const toTarget = (node: PageMapNode): PageMapTarget => {
if (node.kind === "item") return { kind: "item", sectionId: node.sectionId, itemId: node.itemId };
if (node.kind === "section") return { kind: "section", sectionId: node.sectionId };
return { kind: "header" };
};
/** The first block drawn for a target, on whichever page it starts. */
const findNode = (pageMap: PageMap, target: PageMapTarget) =>
pageMap.nodes.find((node) => isSameSelection(toTarget(node), target));
/**
* A marker in the page margin, level with the top of a block: 30px left of it, but never off the page, whose
* margins can be narrower than the marker. `stack` moves further markers on the same block down.
*/
const marginStyle = (node: PageMapNode, page: { width: number; height: number }, stack = 0): CSSProperties => ({
left: `max(4px, calc(${(node.x / page.width) * 100}% - 30px))`,
top: `calc(${(node.y / page.height) * 100}% - 3px + ${stack * 26}px)`,
});
/** Page-relative placement of a block, as percentages of its page. */
function boxStyle(node: PageMapNode, page: { width: number; height: number }): CSSProperties {
return {
left: `${(node.x / page.width) * 100}%`,
top: `${(node.y / page.height) * 100}%`,
width: `${(node.width / page.width) * 100}%`,
height: `${(node.height / page.height) * 100}%`,
};
}
const text = (value: unknown): string =>
typeof value === "string"
? value
: typeof value === "object" && value
? Object.values(value).map(text).join(" ")
: "";
/** The blocks whose text mentions a term: the header, the summary or entries. */
function targetsMentioning(data: ResumeData, term: string): PageMapTarget[] {
const needle = term.toLowerCase();
const mentions = (value: unknown) => text(value).toLowerCase().includes(needle);
const targets: PageMapTarget[] = [];
if (mentions(data.basics)) targets.push({ kind: "header" });
if (mentions(data.summary.content)) targets.push({ kind: "section", sectionId: "summary" });
const sections = [
...Object.entries(data.sections).map(([id, section]) => ({ id, items: section.items as { id: string }[] })),
...data.customSections.map((section) => ({ id: section.id, items: section.items as { id: string }[] })),
];
for (const section of sections) {
for (const item of section.items) {
if (mentions(findEntry(data, section.id, item.id))) {
targets.push({ kind: "item", sectionId: section.id, itemId: item.id });
}
}
}
return targets;
}
type CheckPageLayerProps = { pageIndex: number; pageMap: PageMap | undefined };
/**
* Check's marks on one page: numbered warn pins with a wavy underline for open issues, numbered accent markers for
* proposed edits (while Writing is open), and a highlight on the entries that mention the picked job-match term.
*/
export function CheckPageLayer({ pageIndex, pageMap }: CheckPageLayerProps) {
const check = useCheck();
const tab = useEditorStore((state) => state.checkTab);
const selected = useEditorStore((state) => state.checkIssue);
const highlightTerm = useEditorStore((state) => state.highlightTerm);
const proposals = useEditorStore((state) => state.proposals);
const breakpoint = useBreakpoint();
const page = pageMap?.pages[pageIndex];
const highlighted = useMemo(
() => (check && highlightTerm && tab === "match" ? targetsMentioning(check.data, highlightTerm) : []),
[check, highlightTerm, tab],
);
if (!check || !pageMap || !page || page.width <= 0 || page.height <= 0) return null;
const pickIssue = (issue: CheckIssue) => {
const editor = useEditorStore.getState();
editor.setCheckIssue(issue.key);
editor.setCheckTab("issues");
// Tablets: the drawer opens on the issue. Phones stay on the page, where the issue bar steps through them.
if (breakpoint === "tablet") editor.setDrawerOpen(true);
if (breakpoint !== "mobile") scrollToIssue(issue.key, "panel");
};
const perBlock = new Map<string, number>();
const pins =
tab === "writing"
? []
: check.issues.flatMap((issue) => {
const node = issue.target ? findNode(pageMap, issue.target) : undefined;
if (!node || node.page !== pageIndex) return [];
const stack = perBlock.get(node.key) ?? 0;
perBlock.set(node.key, stack + 1);
return [{ issue, node, stack }];
});
const markers =
tab === "writing"
? proposals.flatMap((proposal, index) => {
if (getProposalState(check.data, proposal) !== "pending") return [];
const target: PageMapTarget = proposal.target.itemId
? { kind: "item", sectionId: proposal.target.sectionId, itemId: proposal.target.itemId }
: { kind: "section", sectionId: proposal.target.sectionId };
const node = findNode(pageMap, target);
return node && node.page === pageIndex ? [{ number: index + 1, node }] : [];
})
: [];
const tinted = highlighted.flatMap((target) =>
pageMap.nodes.filter((node) => node.page === pageIndex && isSameSelection(toTarget(node), target)),
);
return (
<div data-slot="check-page-layer" className="pointer-events-none absolute inset-0">
{tinted.map((node) => (
<div
key={`tint:${node.key}:${node.y}`}
aria-hidden="true"
className="absolute -m-[3px] rounded-[3px] bg-[#F2DE8C]/45 p-[3px] mix-blend-multiply"
style={boxStyle(node, page)}
/>
))}
{pins.map(({ issue, node, stack }) => {
const isSelected = selected === issue.key;
return (
<div key={issue.key}>
<div aria-hidden="true" className="absolute" style={boxStyle(node, page)}>
<div
className={cn(
"absolute -inset-x-1.5 -inset-y-[3px] rounded-[3px] outline-[1.5px] outline-solid transition-[outline-color] duration-quick",
isSelected ? "outline-warn" : "outline-transparent",
)}
/>
{stack === 0 && <div className="wavy-underline absolute inset-x-0 -bottom-1 h-1" />}
</div>
<button
type="button"
data-issue-pin={issue.key}
aria-label={t`Issue ${issue.number}: ${issue.title}`}
aria-pressed={isSelected}
onClick={() => pickIssue(issue)}
style={marginStyle(node, page, stack)}
className={cn(
"pointer-events-auto absolute grid size-[22px] place-items-center rounded-full border-2 border-white bg-warn font-bold text-[11px] text-[oklch(0.22_0.03_80)] shadow-[0_1px_3px_oklch(0_0_0/0.25)]",
isSelected && "outline-2 outline-warn outline-offset-1",
)}
>
{issue.number}
</button>
</div>
);
})}
{markers.map(({ number, node }) => (
<span
key={`marker:${number}`}
aria-hidden="true"
className="absolute grid size-5 place-items-center rounded-full bg-accent font-bold text-[11px] text-on-accent"
style={marginStyle(node, page)}
>
{number}
</span>
))}
</div>
);
}
/** "What a person sees / What a parser reads", floating over the canvas in Check. Switching is instant. */
export function PageViewToggle() {
const view = useEditorStore((state) => state.pageView);
const setView = useEditorStore((state) => state.setPageView);
const options = [
{ value: "page", label: t`What a person sees`, icon: "description" },
{ value: "parser", label: t`What a parser reads`, icon: "data_object" },
] as const;
return (
<fieldset className="absolute top-3.5 left-1/2 z-10 flex -translate-x-1/2 gap-0.5 rounded-[10px] border border-line bg-raised p-[3px] shadow-e1">
<legend className="sr-only">
<Trans>Page view</Trans>
</legend>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={view === option.value}
onClick={() => setView(option.value)}
className={cn(
"flex h-[30px] items-center gap-1.5 whitespace-nowrap rounded-[7px] px-3 font-medium text-[13px] max-sm:px-2",
view === option.value ? "bg-ink text-bg" : "text-ink-2 hover:bg-hover",
)}
>
<Icon name={option.icon} size={18} />
<span className="max-sm:sr-only">{option.label}</span>
</button>
))}
</fieldset>
);
}
/**
* Phones (B2): with an issue picked, the page shows "Issue n of m" with ‹ › to step through them, and the card's
* fix below, so fixing never needs the panel.
*/
export function IssueStepper() {
const check = useCheck();
const selected = useEditorStore((state) => state.checkIssue);
const setCheckIssue = useEditorStore((state) => state.setCheckIssue);
const { fix } = useCheckActions();
const issues = check?.issues ?? [];
const index = issues.findIndex((issue) => issue.key === selected);
const issue = issues[index];
if (!issue) return null;
const go = (step: number) => {
const next = issues[(index + step + issues.length) % issues.length];
if (!next) return;
setCheckIssue(next.key);
scrollToIssue(next.key, "page");
};
return (
<>
<div className="absolute inset-x-3 top-3 z-10 flex h-11 items-center gap-1 rounded-xl bg-ink px-1 text-bg shadow-e3">
<button
type="button"
aria-label={t`Close`}
onClick={() => setCheckIssue(null)}
className="grid size-9 place-items-center rounded-lg"
>
<Icon name="close" size={20} />
</button>
<span className="flex-1 text-center font-semibold text-sm">
<Trans>
Issue {issue.number} of {issues.length}
</Trans>
</span>
<button
type="button"
aria-label={t`Previous issue`}
onClick={() => go(-1)}
className="grid size-9 place-items-center rounded-lg"
>
<Icon name="chevron_left" size={20} />
</button>
<button
type="button"
aria-label={t`Next issue`}
onClick={() => go(1)}
className="grid size-9 place-items-center rounded-lg"
>
<Icon name="chevron_right" size={20} />
</button>
</div>
<section
aria-label={t`Issue ${issue.number}`}
className="absolute inset-x-3 bottom-3 z-10 grid gap-2 rounded-xl border border-line bg-surface p-3.5 shadow-e3"
>
<strong className="font-semibold text-[15px]">{issue.title}</strong>
<p className="text-[13px] text-ink-2 leading-[19px]">{issue.body}</p>
<Button size="sm" className="h-10 w-fit px-3.5 text-sm" onClick={() => fix(issue)}>
{issue.fix.kind === "apply" ? <Icon name={issue.fix.icon} size={18} /> : <Icon name="edit" size={18} />}
{issue.fix.label}
</Button>
</section>
</>
);
}
@@ -0,0 +1,183 @@
import type { AtsRuleCode } from "@reactive-resume/resume/ats";
import type { ExtractedDocument, ResumeSemantics } from "@reactive-resume/resume/ats-pdf";
import type { CheckIssue } from "./issues";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { extractPdf } from "@/features/ats-checker/extract-client";
import { blobToPdfFile } from "@/features/ats-checker/run-ats-check";
import { useEditorStore } from "../store";
import { useCheck } from "./use-check";
type Parsed = { doc: ExtractedDocument; semantics: ResumeSemantics };
/** Reads the PDF on the page the way a parser does: its text layer in reading order, and what it recognises. */
async function parseRenderedPdf(): Promise<Parsed> {
const file = useEditorStore.getState().rendered.file;
if (!file) throw new Error("The page hasn't rendered yet.");
// The operator pass (hidden text, images of text) feeds the full check, not this view, so it's skipped.
const raw = await extractPdf(blobToPdfFile(file, "resume.pdf"), { operatorBudgetMs: 0 });
const { buildExtractedDocument, buildResumeSemantics } = await import("@reactive-resume/resume/ats-pdf");
const doc = buildExtractedDocument(raw);
return { doc, semantics: buildResumeSemantics(raw, doc, { now: new Date() }) };
}
/** Which live checks each field of the parser view reflects. */
const FIELD_CODES: Record<string, readonly AtsRuleCode[]> = {
name: ["MISSING_NAME"],
email: ["MISSING_EMAIL", "MALFORMED_EMAIL"],
phone: ["MISSING_PHONE"],
location: ["MISSING_LOCATION"],
links: ["MALFORMED_URL"],
sections: ["NON_STANDARD_SECTION_TITLE", "NO_VISIBLE_EXPERIENCE", "SECTION_MISSING_FROM_LAYOUT"],
dates: ["EMPTY_PERIOD", "UNPARSEABLE_PERIOD", "UNPARSEABLE_DATE", "REVERSED_PERIOD", "FUTURE_DATED_PERIOD"],
};
const issueFor = (issues: readonly CheckIssue[], codes: readonly AtsRuleCode[]) =>
issues.find((issue) => codes.includes(issue.finding.code));
/** The issue whose text shows on a line, such as a link written without https:// or an unreadable date. */
const issueOnLine = (issues: readonly CheckIssue[], line: string) =>
issues.find((issue) => {
const value = issue.finding.params?.value;
return typeof value === "string" && value.length > 2 && line.includes(value);
});
function IssueChip({ issue }: { issue: CheckIssue }) {
return (
<span className="h-[18px] shrink-0 whitespace-nowrap rounded-full bg-warn-soft px-1.5 font-sans font-semibold text-[10px] text-warn-text leading-[18px]">
<Trans>issue {issue.number}</Trans>
</span>
);
}
/**
* "What a parser reads": the text extracted from the PDF on the page, in reading order, with the fields a parser
* recognises and the lines behind open issues highlighted.
*/
export function ParserView() {
const version = useEditorStore((state) => state.rendered.version);
const issues = useCheck()?.issues ?? [];
const { data, isError } = useQuery({
queryKey: ["check-parser-view", version],
queryFn: parseRenderedPdf,
placeholderData: keepPreviousData,
staleTime: Number.POSITIVE_INFINITY,
gcTime: 60_000,
retry: false,
});
return (
<div className="mx-auto grid min-h-[792px] w-[612px] max-w-full content-start gap-[18px] rounded-lg border border-line bg-surface px-8 py-7 font-mono text-[12px] text-ink leading-[19px] max-sm:px-4">
{isError ? (
<p className="font-sans text-danger-text text-sm">
<Trans>The page's PDF couldn't be read. Edit anything to render it again.</Trans>
</p>
) : !data ? (
<p className="flex items-center gap-2 font-sans text-ink-2 text-sm">
<Spinner />
<Trans>Reading the PDF…</Trans>
</p>
) : (
<ParsedText parsed={data} issues={issues} />
)}
</div>
);
}
function ParsedText({ parsed, issues }: { parsed: Parsed; issues: readonly CheckIssue[] }) {
const { doc, semantics } = parsed;
const columns = doc.pages.some((page) => page.gutter) ? 2 : 1;
const notFound = t`not found`;
if (doc.charCount === 0) {
return (
<p className="font-sans text-sm text-warn-text">
<Trans>This PDF has no text layer, so a parser reads nothing from it.</Trans>
</p>
);
}
const { contact } = semantics;
const links = [...new Set([...contact.textUrls, ...contact.annotationUrls])];
const unreadable = semantics.dates.filter((date) => !date.parsed).length;
const fields = [
{ id: "name", label: t`Name`, value: contact.nameLine },
{ id: "email", label: t`Email`, value: contact.emails.join(", ") },
{ id: "phone", label: t`Phone`, value: contact.phones.join(", ") },
{ id: "location", label: t`Location`, value: contact.locationLine },
{ id: "links", label: t`Links`, value: links.join(", ") },
{ id: "sections", label: t`Sections`, value: semantics.headings.map((heading) => heading.text).join(", ") },
{
id: "dates",
label: t`Dates`,
value:
semantics.dates.length === 0
? ""
: unreadable > 0
? t`${semantics.dates.length} found, ${unreadable} unreadable`
: t`${semantics.dates.length} found`,
},
];
// Lines grouped under the headings a parser recognised, in the order it reads them.
const headingAt = new Map(semantics.headings.map((heading) => [heading.lineIndex, heading.text]));
const groups: { heading: string | null; lines: string[] }[] = [{ heading: null, lines: [] }];
doc.lines.forEach((line, index) => {
const heading = headingAt.get(index);
if (heading !== undefined) groups.push({ heading, lines: [] });
else groups.at(-1)?.lines.push(line.text);
});
return (
<>
<div className="flex flex-wrap justify-between gap-2 font-medium text-[11px] text-ink-3 uppercase">
<span>
<Trans>Extracted from the PDF on the page · reading order</Trans>
</span>
<span>{columns === 1 ? <Trans>text layer ✓ · 1 column</Trans> : <Trans>text layer ✓ · 2 columns</Trans>}</span>
</div>
<dl className="grid grid-cols-[110px_minmax(0,1fr)] gap-x-4 gap-y-1.5 max-sm:grid-cols-[84px_minmax(0,1fr)]">
{fields.map((field) => {
const issue = issueFor(issues, FIELD_CODES[field.id] ?? []);
return (
<div key={field.id} className="contents">
<dt className="text-ink-3 uppercase">{field.label}</dt>
<dd className={cn("flex min-w-0 items-center gap-2", (issue || !field.value) && "text-warn-text")}>
<span className="min-w-0 break-words">{field.value || notFound}</span>
{issue && <IssueChip issue={issue} />}
</dd>
</div>
);
})}
</dl>
<hr className="border-line" />
{groups.map((group, index) =>
group.heading === null && group.lines.length === 0 ? null : (
<section key={`${index}:${group.heading ?? ""}`} className="grid gap-1">
{group.heading !== null && <h3 className="text-accent-text">▸ {group.heading.toUpperCase()}</h3>}
{group.lines.map((line, lineIndex) => {
const issue = issueOnLine(issues, line);
return (
<p
key={`${lineIndex}:${line}`}
className={cn("flex items-start gap-2 ps-3.5", issue ? "text-warn-text" : "text-ink-2")}
>
<span className="min-w-0 break-words">{line}</span>
{issue && <IssueChip issue={issue} />}
</p>
);
})}
</section>
),
)}
</>
);
}
@@ -0,0 +1,58 @@
import type { Passage } from "../proposals/passages";
import { describe, expect, it } from "vitest";
import { mapWritingReview } from "./review";
const passage: Passage = {
id: "p1",
target: { sectionId: "experience", itemId: "kettle", field: "description" },
location: "Experience · Studio Kettle · bullet 1",
html: "<p>Responsible for various design tasks</p>",
text: "Responsible for various design tasks",
};
const suggestion = (patch: Partial<Parameters<typeof mapWritingReview>[0][number]>) => ({
section: "Experience",
passageId: "p1",
issue: "Names a duty, not an outcome.",
rewrite: "Produced packaging and print work for local retail clients",
impact: "high" as const,
...patch,
});
describe("mapWritingReview", () => {
it("turns a rewrite of a passage it was sent into a proposal for that passage", () => {
const { proposals, notes } = mapWritingReview([suggestion({})], [passage]);
expect(notes).toEqual([]);
expect(proposals).toEqual([
{
id: "w1",
target: passage.target,
location: passage.location,
before: "<p>Responsible for various design tasks</p>",
after: "<p>Produced packaging and print work for local retail clients</p>",
why: "Names a duty, not an outcome.",
status: "pending",
source: "check",
},
]);
});
it("keeps advice, unchanged rewrites and unknown passages as notes", () => {
const { proposals, notes } = mapWritingReview(
[
suggestion({ rewrite: null }),
suggestion({ rewrite: " Responsible for various design tasks " }),
suggestion({ passageId: "p9", section: "Summary", impact: "low" }),
],
[passage],
);
expect(proposals).toEqual([]);
expect(notes.map((note) => [note.location, note.quote, note.target])).toEqual([
[passage.location, passage.text, { kind: "item", sectionId: "experience", itemId: "kettle" }],
[passage.location, passage.text, { kind: "item", sectionId: "experience", itemId: "kettle" }],
["Summary", "", null],
]);
});
});
@@ -0,0 +1,57 @@
import type { PageMapTarget } from "@reactive-resume/pdf/page-map";
import type { Passage } from "../proposals/passages";
import type { Proposal } from "../proposals/proposals";
import type { WritingNote } from "../store";
import { replaceBlockText } from "../proposals/proposals";
type ReviewSuggestion = {
section: string | null;
passageId: string | null;
issue: string;
rewrite: string | null;
impact: WritingNote["impact"];
};
const toPageTarget = (target: Proposal["target"]): PageMapTarget =>
target.itemId
? { kind: "item", sectionId: target.sectionId, itemId: target.itemId }
: { kind: "section", sectionId: target.sectionId };
/**
* Sorts a writing review's suggestions: a rewrite of a passage that was sent becomes a proposal to accept or
* reject; anything else (advice, or a rewrite of text it wasn't given) stays a note.
*/
export function mapWritingReview(suggestions: readonly ReviewSuggestion[], passages: readonly Passage[]) {
const byId = new Map(passages.map((passage) => [passage.id, passage]));
const proposals: Proposal[] = [];
const notes: WritingNote[] = [];
for (const suggestion of suggestions) {
const passage = suggestion.passageId ? byId.get(suggestion.passageId) : undefined;
const rewrite = suggestion.rewrite?.trim();
if (passage && rewrite && rewrite !== passage.text) {
proposals.push({
id: `w${proposals.length + 1}`,
target: passage.target,
location: passage.location,
before: passage.html,
after: replaceBlockText(passage.html, rewrite),
why: suggestion.issue,
status: "pending",
source: "check",
});
continue;
}
notes.push({
location: passage?.location ?? suggestion.section ?? "",
impact: suggestion.impact,
quote: passage?.text ?? "",
note: suggestion.issue,
target: passage ? toPageTarget(passage.target) : null,
});
}
return { proposals, notes };
}
@@ -0,0 +1,26 @@
import type { AtsReport } from "@reactive-resume/resume/ats";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CheckIssue } from "./issues";
import { useDeferredValue, useMemo } from "react";
import { lintResumeForAts } from "@reactive-resume/resume/ats";
import { useResumeData } from "@/features/resume/builder/draft";
import { buildIssues } from "./issues";
export type CheckResult = { data: ResumeData; report: AtsReport; issues: CheckIssue[] };
/** The live checks on the resume as it is now (deferred, so typing stays smooth), with numbered issues. */
export function useCheck(): CheckResult | null {
const data = useDeferredValue(useResumeData());
return useMemo(() => {
if (!data) return null;
const report = lintResumeForAts(data);
return { data, report, issues: buildIssues(report, data) };
}, [data]);
}
/** Open issues, for the badge on the Check tab. Ignored ones don't count. */
export function useOpenIssueCount(): number {
const data = useDeferredValue(useResumeData());
return useMemo(() => (data ? lintResumeForAts(data).findings.length : 0), [data]);
}
@@ -0,0 +1,277 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { WritingNote } from "../store";
import type { CheckIssue } from "./issues";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { buildMarkdown } from "@reactive-resume/resume/markdown";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { AiProviderPicker } from "@/features/settings/integrations/components/ai-provider-picker";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { collectPassages } from "../proposals/passages";
import { ProposalList } from "../proposals/proposal-list";
import { useEditorStore } from "../store";
import { getSectionName } from "./issues";
import { mapWritingReview } from "./review";
/** The endpoint's caps; the text and lists are trimmed here so a long resume is never refused. */
const MAX_TEXT_CHARS = 50_000;
const MAX_FINDINGS = 120;
const MAX_PASSAGES = 120;
const MAX_PASSAGE_CHARS = 1_000;
type WritingTabProps = { data: ResumeData; issues: readonly CheckIssue[] };
/**
* Writing: an opt-in AI review of the wording. It says what it sends before it runs, returns rewrites of bullets
* and paragraphs as proposals to accept or reject, and is never part of the score. Its errors stay in this tab.
*/
export function WritingTab({ data, issues }: WritingTabProps) {
const { usableProviders, hasUsableProvider, isLoading } = useHasUsableAiProvider();
const [providerOverride, setProviderOverride] = useState<string | null>(null);
const [choosing, setChoosing] = useState(false);
const review = useEditorStore((state) => state.writingReview);
const proposals = useEditorStore((state) => state.proposals);
const setWritingReview = useEditorStore((state) => state.setWritingReview);
const setProposals = useEditorStore((state) => state.setProposals);
const provider = usableProviders.find((entry) => entry.id === providerOverride) ?? usableProviders[0];
const passages = useMemo(
() =>
collectPassages(data, {
summary: getSectionName(data, "summary"),
sectionTitle: (sectionId) => getSectionName(data, sectionId),
bullet: (n) => t`bullet ${n}`,
paragraph: (n) => t`paragraph ${n}`,
}).slice(0, MAX_PASSAGES),
[data],
);
const { mutate, isPending, error, reset } = useMutation({
...orpc.ai.atsReview.mutationOptions(),
onSuccess: (result) => {
const { proposals: next, notes } = mapWritingReview(result.suggestions, passages);
setProposals(next);
setWritingReview({ summary: result.summary, strengths: result.strengths, notes });
},
});
const run = () => {
reset();
mutate({
...(provider ? { aiProviderId: provider.id } : {}),
extractedText: buildMarkdown(data).slice(0, MAX_TEXT_CHARS),
findings: issues.slice(0, MAX_FINDINGS).map((issue) => ({
code: issue.finding.code,
severity: issue.finding.severity,
message: issue.title.slice(0, 300),
})),
passages: passages.map((passage) => ({
id: passage.id,
where: passage.location.slice(0, 200),
text: passage.text.slice(0, MAX_PASSAGE_CHARS),
})),
});
};
if (isLoading) return null;
if (!hasUsableProvider) {
return (
<div className="grid gap-3 rounded-xl border border-line p-4">
<strong className="font-semibold text-sm">
<Trans>A second opinion on your wording</Trans>
</strong>
<p className="text-[13px] text-ink-2 leading-[19px]">
<Trans>
Connect your own AI provider to have a language model read your bullets and suggest rewrites. Issues and Job
match need no provider.
</Trans>
</p>
<Link
to="/dashboard/settings/integrations"
className={buttonVariants({ size: "sm", variant: "secondary", className: "w-fit" })}
>
<Trans>Open AI settings</Trans>
</Link>
</div>
);
}
if (isPending) {
return (
<div aria-busy="true" className="flex items-center gap-2.5 rounded-xl border border-line p-4 text-ink-2 text-sm">
<Spinner />
<Plural
value={passages.length}
one="Reading # passage of your resume…"
other="Reading # bullets and paragraphs of your resume…"
/>
</div>
);
}
if (error) {
return (
<div className="grid gap-3">
<div
role="alert"
className="flex gap-2.5 rounded-xl bg-danger-soft p-3 text-[13px] text-danger-text leading-[19px]"
>
<Icon name="error" />
<span>
{getOrpcErrorMessage(error, {
byCode: {
BAD_GATEWAY: t`Couldn't reach your AI provider. Its key may have expired.`,
BAD_REQUEST: t`The provider returned a review that couldn't be read. Try again.`,
PRECONDITION_FAILED: t`AI providers are unavailable until ENCRYPTION_SECRET is configured.`,
},
fallback: t`The review couldn't finish. Try again.`,
})}
</span>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={run}>
<Trans>Retry</Trans>
</Button>
<Link to="/dashboard/settings/integrations" className={buttonVariants({ size: "sm", variant: "secondary" })}>
<Trans>Open AI settings</Trans>
</Link>
</div>
<p className="text-ink-3 text-xs">
<Trans>Issues and Job match still work without AI.</Trans>
</p>
</div>
);
}
if (!review) {
return (
<div className="grid gap-3 rounded-xl border border-line p-4">
<strong className="font-semibold text-sm">
<Trans>A second opinion on your wording</Trans>
</strong>
<p className="text-[13px] text-ink-2 leading-[19px]">
<Trans>
The checks are mechanical. This asks a language model how a reader might react to your bullets and suggests
rewrites. It isn't part of the score.
</Trans>
</p>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-ink-3 text-xs">
<Icon name="lock" size={16} />
<span>
<Trans>
Sends your resume's text only to {provider?.label} · {provider?.model}, with your key.
</Trans>
</span>
<button
type="button"
aria-expanded={choosing}
className="font-medium text-ink-2 underline underline-offset-2"
onClick={() => setChoosing(!choosing)}
>
<Trans>Change</Trans>
</button>
</div>
{choosing && (
<AiProviderPicker
value={provider?.id ?? null}
providers={usableProviders}
onValueChange={(value) => {
setProviderOverride(value);
setChoosing(false);
}}
/>
)}
<Button size="sm" variant="secondary" className="w-fit" disabled={passages.length === 0} onClick={run}>
<Icon name="auto_awesome" size={16} />
<Trans>Review writing</Trans>
</Button>
{passages.length === 0 && (
<p className="text-ink-3 text-xs">
<Trans>Add a summary or describe a role first; there's nothing to review yet.</Trans>
</p>
)}
</div>
);
}
return (
<div className="grid gap-3">
{review.summary && <p className="text-[13px] text-ink-2 leading-[19px]">{review.summary}</p>}
{proposals.length > 0 && <ProposalList proposals={proposals} data={data} onSuggestAgain={run} />}
{review.notes.map((note) => (
<WritingNoteCard key={`${note.location}:${note.note}`} note={note} />
))}
{review.strengths.length > 0 && (
<section aria-labelledby="writing-strengths" className="grid gap-1.5">
<h3 id="writing-strengths" className="pt-1.5 font-semibold text-ink-3 text-xs uppercase">
<Trans>What's working</Trans>
</h3>
<ul className="grid list-disc gap-1 ps-[18px] text-[13px] text-ink-2 leading-[19px]">
{review.strengths.map((strength) => (
<li key={strength}>{strength}</li>
))}
</ul>
</section>
)}
<p className="text-ink-3 text-xs leading-[17px]">
<Trans>A model's opinion, not a verdict. It can be wrong.</Trans>{" "}
<button type="button" className="font-medium text-ink-2 underline underline-offset-2" onClick={run}>
<Trans>Run again</Trans>
</button>
</p>
</div>
);
}
const impactLabel = (impact: WritingNote["impact"]) => ({ high: t`High`, medium: t`Medium`, low: t`Low` })[impact];
function WritingNoteCard({ note }: { note: WritingNote }) {
const breakpoint = useBreakpoint();
const showOnPage = () => {
if (!note.target) return;
const editor = useEditorStore.getState();
editor.select(note.target);
editor.setPageView("page");
if (breakpoint === "tablet") editor.setDrawerOpen(false);
if (breakpoint === "mobile") editor.setMobileView("page");
};
return (
<article className="grid gap-1.5 rounded-[10px] border border-line p-3">
<div className="flex justify-between gap-2">
<span className="font-mono text-[11px] text-ink-3 uppercase">{note.location}</span>
<span
className={cn(
"h-[18px] rounded px-1.5 font-semibold text-[11px] leading-[18px]",
note.impact === "high" ? "bg-warn-soft text-warn-text" : "bg-sunken text-ink-2",
)}
>
{impactLabel(note.impact)}
</span>
</div>
<p className="text-[13px] leading-[19px]">
{note.quote && <i className="text-ink-2">“{note.quote}”</i>} {note.note}
</p>
{note.target && (
<Button size="sm" variant="secondary" className="w-fit" onClick={showOnPage}>
<Trans>Show on page</Trans>
</Button>
)}
</article>
);
}
@@ -0,0 +1,109 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ProposalTarget } from "./proposals";
import { stripHtml } from "@reactive-resume/utils/string";
import { describeEntry, getEntries, resolveSection } from "../write/model";
/** A bullet or paragraph a reviewer can suggest rewriting, with where it sits. */
export type Passage = {
id: string;
target: ProposalTarget;
/** "Experience · Studio Kettle · bullet 1". */
location: string;
/** The whole block (`<p>…</p>` or `<li>…</li>`) exactly as stored, so a proposal can find it again. */
html: string;
text: string;
};
// A paragraph or list item with no paragraph or list item inside it: `<li><p>…</p></li>` yields the `<p>`.
const LEAF_BLOCK = /<(p|li)(?:\s[^>]*)?>((?:(?!<\/?(?:p|li)[\s>])[\s\S])*?)<\/\1>/gi;
const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
const decodeEntities = (text: string) =>
text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity, name: string) => {
if (name.startsWith("#x") || name.startsWith("#X")) return String.fromCodePoint(Number.parseInt(name.slice(2), 16));
if (name.startsWith("#")) return String.fromCodePoint(Number.parseInt(name.slice(1), 10));
return ENTITIES[name.toLowerCase()] ?? entity;
});
const toText = (html: string) => decodeEntities(stripHtml(html)).replace(/\s+/g, " ").trim();
type Block = { html: string; text: string; bullet: boolean };
/** The leaf paragraphs and list items of rich text, in order, skipping empty ones. */
export function splitBlocks(html: string): Block[] {
const blocks: Block[] = [];
for (const match of html.matchAll(LEAF_BLOCK)) {
const text = toText(match[2] ?? "");
if (!text) continue;
// Inside a list when more <li> have opened than closed before this block.
const preceding = html.slice(0, match.index);
const opened = preceding.match(/<li[\s>]/gi)?.length ?? 0;
const closed = preceding.match(/<\/li>/gi)?.length ?? 0;
blocks.push({ html: match[0], text, bullet: match[1]?.toLowerCase() === "li" || opened > closed });
}
return blocks;
}
type Labels = {
summary: string;
sectionTitle: (sectionId: string) => string;
bullet: (n: number) => string;
paragraph: (n: number) => string;
};
/**
* Every passage a writing review can target, in page order: the summary's paragraphs, then each visible
* entry's description, section by section. Hidden sections and entries don't print, so they're left out.
*/
export function collectPassages(data: ResumeData, labels: Labels): Passage[] {
const passages: Passage[] = [];
const add = (target: ProposalTarget, where: string[], html: string) => {
let bullets = 0;
let paragraphs = 0;
for (const block of splitBlocks(html)) {
const place = block.bullet ? labels.bullet(++bullets) : labels.paragraph(++paragraphs);
passages.push({
id: `p${passages.length + 1}`,
target,
location: [...where, place].join(" · "),
html: block.html,
text: block.text,
});
}
};
if (!data.summary.hidden) add({ sectionId: "summary", field: "content" }, [labels.summary], data.summary.content);
const sectionIds = [...Object.keys(data.sections), ...data.customSections.map((section) => section.id)];
for (const sectionId of sectionIds) {
const section = resolveSection(data, sectionId);
if (!section || section.type === "cover-letter") continue;
const hidden =
section.kind === "custom"
? data.customSections.find((custom) => custom.id === sectionId)?.hidden
: data.sections[sectionId as keyof ResumeData["sections"]].hidden;
if (hidden) continue;
for (const entry of getEntries(data, section)) {
const { hidden: entryHidden, description } = entry as { hidden?: boolean; description?: unknown };
if (entryHidden || typeof description !== "string") continue;
const title = describeEntry(section.type, entry).title;
add(
{ sectionId, itemId: entry.id, field: "description" },
[labels.sectionTitle(sectionId), ...(title ? [title] : [])],
description,
);
}
}
return passages;
}
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Resume } from "@/features/resume/builder/draft";
import type { Proposal } from "./proposals";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "../store";
import { ProposalList } from "./proposal-list";
import { readTarget } from "./proposals";
const routerParams = vi.hoisted(() => ({ resumeId: "proposals" }));
const toastState = vi.hoisted(() => ({ add: vi.fn() }));
vi.mock("@tanstack/react-router", () => ({ useParams: () => routerParams }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {},
streamClient: { resume: { updates: { subscribe: vi.fn() } } },
}));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: toastState.add, close: vi.fn() } }));
beforeAll(() => {
i18n.loadAndActivate({ locale: "en-US", messages: {} });
});
afterEach(() => {
cleanup();
useEditorStore.getState().reset();
toastState.add.mockClear();
});
const TARGET = { sectionId: "experience", itemId: "kettle", field: "description" };
const proposals: Proposal[] = [
{
id: "w1",
target: TARGET,
location: "Experience · Studio Kettle · bullet 1",
before: "<p>Responsible for design tasks</p>",
after: "<p>Produced packaging for retail clients</p>",
why: "Names an outcome.",
status: "pending",
source: "check",
},
{
id: "w2",
target: TARGET,
location: "Experience · Studio Kettle · bullet 2",
before: "<p>Worked on websites</p>",
after: "<p>Designed websites for 20+ businesses</p>",
why: "Adds scale.",
status: "pending",
source: "check",
},
];
function setup() {
const data = structuredClone(defaultResumeData) as ResumeData;
data.sections.experience.items = [
{
id: "kettle",
hidden: false,
company: "Studio Kettle",
position: "Junior Designer",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "<ul><li><p>Responsible for design tasks</p></li><li><p>Worked on websites</p></li></ul>",
roles: [],
} as ResumeData["sections"]["experience"]["items"][number],
];
const resume: Resume = {
id: routerParams.resumeId,
name: "Proposals",
slug: "proposals",
tags: [],
data,
isLocked: false,
updatedAt: new Date("2026-09-28T00:00:00.000Z"),
};
useResumeStore.getState().initialize(resume);
useEditorStore.getState().setProposals(proposals);
const Harness = () => {
const current = useResumeStore((state) => state.resume?.data);
const list = useEditorStore((state) => state.proposals);
return current ? <ProposalList proposals={list} data={current} onSuggestAgain={vi.fn()} /> : null;
};
render(
<I18nProvider i18n={i18n}>
<Harness />
</I18nProvider>,
);
}
const description = () => readTarget(useResumeStore.getState().resume?.data as ResumeData, TARGET);
describe("ProposalList", () => {
it("accepts with A and rejects with R on the focused edit, moving with the arrow keys", () => {
setup();
const [first] = screen.getAllByRole("listitem");
first?.focus();
fireEvent.keyDown(first as HTMLElement, { key: "a" });
expect(description()).toContain("<p>Produced packaging for retail clients</p>");
expect(screen.getByText("Applied")).toBeTruthy();
fireEvent.keyDown(first as HTMLElement, { key: "ArrowDown" });
const second = screen.getAllByRole("listitem")[1] as HTMLElement;
expect(document.activeElement).toBe(second);
fireEvent.keyDown(second, { key: "r" });
expect(screen.getByText("Rejected")).toBeTruthy();
expect(description()).toContain("<p>Worked on websites</p>");
});
it("accepts all pending edits as one undo step, and undoing shows them as pending again", () => {
setup();
fireEvent.click(screen.getByRole("button", { name: "Accept all" }));
expect(description()).toBe(
"<ul><li><p>Produced packaging for retail clients</p></li><li><p>Designed websites for 20+ businesses</p></li></ul>",
);
expect(screen.getAllByText("Applied")).toHaveLength(2);
act(() => useResumeStore.getState().undo());
expect(description()).toContain("<p>Responsible for design tasks</p>");
expect(screen.getAllByRole("button", { name: "Accept" })).toHaveLength(2);
});
it("marks an edit out of date once its text changes", () => {
setup();
act(() =>
useResumeStore.getState().updateResumeData((draft) => {
const entry = draft.sections.experience.items[0];
if (entry) entry.description = "<p>Rewritten by hand</p>";
}),
);
expect(screen.getAllByText(/Out of date/)).toHaveLength(2);
expect(screen.queryByRole("button", { name: "Accept all" })).toBeNull();
});
});
@@ -0,0 +1,205 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { KeyboardEvent } from "react";
import type { Proposal, ProposalState } from "./proposals";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { useIsResumeLocked, useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "../store";
import { applyProposal, getProposalState } from "./proposals";
/** The visible text of a passage's HTML, for the card. */
const passageText = (html: string) =>
html
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&")
.trim();
/** Applies proposals as one undo step; the toast's Undo takes them back, and they show as pending again. */
function acceptProposals(proposals: readonly Proposal[]) {
useResumeStore.getState().updateResumeData(
(draft) => {
for (const proposal of proposals) applyProposal(draft, proposal);
},
{ newStep: true },
);
useEditorStore.getState().setProposalStatus(
proposals.map((proposal) => proposal.id),
"accepted",
);
toast.add({
description: proposals.length === 1 ? t`Edit applied` : t`${proposals.length} edits applied`,
actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() },
});
}
type ProposalListProps = {
proposals: readonly Proposal[];
data: ResumeData;
/** "Suggest again" on an out-of-date proposal. */
onSuggestAgain: () => void;
};
/**
* A change set: "n proposed edits" with Accept all, then each edit numbered like its marker on the page, with
* where it lands, the old text struck through, the new text and why. A accepts and R rejects the focused edit;
* ↑ and ↓ move between edits.
*/
export function ProposalList({ proposals, data, onSuggestAgain }: ProposalListProps) {
const setProposalStatus = useEditorStore((state) => state.setProposalStatus);
const locked = useIsResumeLocked();
const [focused, setFocused] = useState(0);
const states = proposals.map((proposal) => getProposalState(data, proposal));
const pending = proposals.filter((_, index) => states[index] === "pending");
const onKeyDown = (event: KeyboardEvent<HTMLOListElement>) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const item = (event.target as HTMLElement).closest<HTMLElement>("[data-proposal-index]");
const index = Number(item?.dataset.proposalIndex ?? focused);
const proposal = proposals[index];
const move = (next: number) => {
event.preventDefault();
const target = Math.min(proposals.length - 1, Math.max(0, next));
setFocused(target);
event.currentTarget.querySelector<HTMLElement>(`[data-proposal-index="${target}"]`)?.focus();
};
if (event.key === "ArrowDown") return move(index + 1);
if (event.key === "ArrowUp") return move(index - 1);
if (!proposal || states[index] !== "pending" || locked) return;
if (event.key === "a" || event.key === "A") {
event.preventDefault();
acceptProposals([proposal]);
}
if (event.key === "r" || event.key === "R") {
event.preventDefault();
setProposalStatus([proposal.id], "rejected");
}
};
return (
<section aria-labelledby="proposals-heading" className="overflow-hidden rounded-xl border border-line">
<header className="flex min-h-11 items-center justify-between gap-2 border-line border-b bg-bg px-3 py-2">
<h3 id="proposals-heading" className="font-semibold text-sm">
<Plural value={proposals.length} one="# proposed edit" other="# proposed edits" />
</h3>
{pending.length > 1 && (
<Button size="sm" disabled={locked} onClick={() => acceptProposals(pending)}>
<Trans>Accept all</Trans>
</Button>
)}
</header>
<ol aria-label={t`Proposed edits`} onKeyDown={onKeyDown} className="divide-y divide-line">
{proposals.map((proposal, index) => (
<ProposalItem
key={proposal.id}
proposal={proposal}
number={index + 1}
state={states[index] ?? "pending"}
index={index}
focusable={index === Math.min(focused, proposals.length - 1)}
locked={locked}
onFocus={() => setFocused(index)}
onAccept={() => acceptProposals([proposal])}
onReject={() => setProposalStatus([proposal.id], "rejected")}
onSuggestAgain={onSuggestAgain}
/>
))}
</ol>
<p className="border-line border-t px-3 py-2 text-ink-3 text-xs">
<Trans>A accepts and R rejects the focused edit. ↑ and ↓ move between edits.</Trans>
</p>
</section>
);
}
type ProposalItemProps = {
proposal: Proposal;
number: number;
state: ProposalState;
index: number;
focusable: boolean;
locked: boolean;
onFocus: () => void;
onAccept: () => void;
onReject: () => void;
onSuggestAgain: () => void;
};
function ProposalItem(props: ProposalItemProps) {
const { proposal, number, state, index, focusable, locked } = props;
const labelId = `proposal-${proposal.id}-location`;
return (
<li
data-proposal-index={index}
// Roving focus: one edit in the tab order, ↑ and ↓ move between them.
tabIndex={focusable ? 0 : -1}
aria-labelledby={labelId}
onFocus={props.onFocus}
className="grid gap-2 p-3 outline-none focus-visible:bg-hover"
>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="grid size-5 shrink-0 place-items-center rounded-full bg-accent font-bold text-[11px] text-on-accent"
>
{number}
</span>
<span id={labelId} className="truncate font-mono text-[11px] text-ink-3 uppercase">
<span className="sr-only">
<Trans>Edit {number}:</Trans>{" "}
</span>
{proposal.location}
</span>
</div>
<div className="grid gap-1.5 text-[13px] leading-[19px]">
<del className="text-ink-3">{passageText(proposal.before)}</del>
<ins className="rounded-[3px] bg-accent-soft px-1 py-0.5 no-underline">{passageText(proposal.after)}</ins>
{proposal.why && <span className="text-ink-2 text-xs">{proposal.why}</span>}
</div>
{state === "pending" ? (
<div className="flex gap-1.5">
<Button size="sm" disabled={locked} onClick={props.onAccept}>
<Trans>Accept</Trans>
</Button>
<Button size="sm" variant="secondary" onClick={props.onReject}>
<Trans>Reject</Trans>
</Button>
</div>
) : (
<p
className={cn(
"flex items-center gap-2 font-medium text-xs",
state === "accepted" && "text-accent-text",
state === "rejected" && "text-ink-3",
state === "stale" && "text-warn-text",
)}
>
{state === "accepted" && <Trans>Applied</Trans>}
{state === "rejected" && <Trans>Rejected</Trans>}
{state === "stale" && (
<>
<Trans>Out of date: the text has changed since.</Trans>
<button type="button" className="text-ink-2 underline underline-offset-2" onClick={props.onSuggestAgain}>
<Trans>Suggest again</Trans>
</button>
</>
)}
</p>
)}
</li>
);
}
@@ -0,0 +1,147 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Proposal } from "./proposals";
import { describe, expect, it } from "vitest";
import { produce } from "immer";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { collectPassages, splitBlocks } from "./passages";
import { applyProposal, canApply, getProposalState, markProposals, readTarget, replaceBlockText } from "./proposals";
const OLD_BULLET = "<li><p>Responsible for various design tasks</p></li>";
function makeData(): ResumeData {
const data = structuredClone(defaultResumeData);
data.summary.content = "<p>Product designer with 8 years in health tools.</p>";
data.sections.experience.items = [
{
id: "kettle",
hidden: false,
company: "Studio Kettle",
position: "Junior Designer",
location: "Lisbon",
period: "2016 - 2019",
website: { url: "", label: "", inlineLink: false },
description: `<ul>${OLD_BULLET}<li><p>Designed websites &amp; identities for 20+ businesses</p></li></ul>`,
roles: [],
} as ResumeData["sections"]["experience"]["items"][number],
];
return data;
}
const proposal = (patch: Partial<Proposal> = {}): Proposal => ({
id: "1",
target: { sectionId: "experience", itemId: "kettle", field: "description" },
location: "Experience · Studio Kettle · bullet 1",
before: "<p>Responsible for various design tasks</p>",
after: "<p>Produced packaging and print work for local retail clients</p>",
why: "Names an outcome.",
status: "pending",
source: "check",
...patch,
});
describe("splitBlocks", () => {
it("reads leaf paragraphs and list items, marking bullets and decoding entities", () => {
expect(splitBlocks(`<p>Intro</p><ul>${OLD_BULLET}<li>Plain &amp; bare</li></ul><p> </p>`)).toEqual([
{ html: "<p>Intro</p>", text: "Intro", bullet: false },
{
html: "<p>Responsible for various design tasks</p>",
text: "Responsible for various design tasks",
bullet: true,
},
{ html: "<li>Plain &amp; bare</li>", text: "Plain & bare", bullet: true },
]);
});
});
describe("collectPassages", () => {
const labels = {
summary: "Summary",
sectionTitle: () => "Experience",
bullet: (n: number) => `bullet ${n}`,
paragraph: (n: number) => `paragraph ${n}`,
};
it("lists the summary and each visible entry's bullets with where they sit", () => {
const passages = collectPassages(makeData(), labels);
expect(passages.map((passage) => [passage.id, passage.location, passage.text])).toEqual([
["p1", "Summary · paragraph 1", "Product designer with 8 years in health tools."],
["p2", "Experience · Junior Designer · bullet 1", "Responsible for various design tasks"],
["p3", "Experience · Junior Designer · bullet 2", "Designed websites & identities for 20+ businesses"],
]);
expect(passages[1]?.target).toEqual({ sectionId: "experience", itemId: "kettle", field: "description" });
});
it("leaves hidden entries and sections out", () => {
const data = produce(makeData(), (draft) => {
draft.summary.hidden = true;
const entry = draft.sections.experience.items[0];
if (entry) entry.hidden = true;
});
expect(collectPassages(data, labels)).toEqual([]);
});
});
describe("proposals", () => {
it("applies while the passage is still in the field, and only then", () => {
const data = makeData();
expect(canApply(data, proposal())).toBe(true);
const next = produce(data, (draft) => {
expect(applyProposal(draft, proposal())).toBe(true);
});
expect(readTarget(next, proposal().target)).toContain("<li><p>Produced packaging and print work");
expect(getProposalState(next, proposal())).toBe("stale");
produce(next, (draft) => {
expect(applyProposal(draft, proposal())).toBe(false);
});
});
it("shows an accepted proposal as pending again once its edit is undone", () => {
const data = makeData();
const accepted = proposal({ status: "accepted" });
expect(getProposalState(data, accepted)).toBe("pending");
const applied = produce(data, (draft) => {
applyProposal(draft, accepted);
});
expect(getProposalState(applied, accepted)).toBe("accepted");
expect(getProposalState(applied, proposal({ status: "rejected" }))).toBe("rejected");
});
it("keeps dollar signs in the new text literal", () => {
const next = produce(makeData(), (draft) => {
applyProposal(draft, proposal({ after: "<p>Cut costs by $& and $1</p>" }));
});
expect(readTarget(next, proposal().target)).toContain("<p>Cut costs by $& and $1</p>");
});
it("targets the summary", () => {
const summary = proposal({
target: { sectionId: "summary", field: "content" },
before: "<p>Product designer with 8 years in health tools.</p>",
after: "<p>Product designer for clinicians.</p>",
});
const next = produce(makeData(), (draft) => {
applyProposal(draft, summary);
});
expect(next.summary.content).toBe("<p>Product designer for clinicians.</p>");
});
it("marks pending proposals on a copy: old text struck through, new text highlighted", () => {
const data = makeData();
const marked = markProposals(data, [proposal(), proposal({ id: "2", status: "rejected" })]);
expect(readTarget(marked, proposal().target)).toContain(
'<p><s style="color: #7d7b73">Responsible for various design tasks</s> <mark data-color="#d4efd9">Produced packaging and print work for local retail clients</mark></p>',
);
expect(readTarget(data, proposal().target)).not.toContain("<s");
expect(markProposals(data, [proposal({ status: "rejected" })])).toBe(data);
});
it("replaces a block's text and escapes it, keeping the tag", () => {
expect(replaceBlockText('<p class="x">Old</p>', " New <b>& bold ")).toBe(
'<p class="x">New &lt;b&gt;&amp; bold</p>',
);
});
});
@@ -0,0 +1,137 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { produce } from "immer";
import { findEntry } from "../write/model";
/** A field that holds rich text: the summary's content, or an entry's description. */
export type ProposalTarget = { sectionId: string; itemId?: string; field: string };
type ProposalSource = "check" | "assistant" | "improve";
/**
* A suggested edit: replace one passage of a field (a bullet or a paragraph) with another. It is never applied
* until accepted. Out of date is not stored: it's whenever the passage is no longer in the field.
*/
export type Proposal = {
id: string;
target: ProposalTarget;
/** Where it lands, for people: "Experience · Studio Kettle · bullet 1". */
location: string;
/** The passage as it is now: a whole `<p>` or `<li>` block, exactly as stored in the field. */
before: string;
/** The block to put in its place. */
after: string;
/** One line on why. */
why: string;
status: "pending" | "accepted" | "rejected";
source: ProposalSource;
};
export type ProposalState = Proposal["status"] | "stale";
export function readTarget(data: ResumeData, target: ProposalTarget): string | undefined {
if (!target.itemId)
return target.sectionId === "summary" && target.field === "content" ? data.summary.content : undefined;
const value = (findEntry(data, target.sectionId, target.itemId) as Record<string, unknown> | undefined)?.[
target.field
];
return typeof value === "string" ? value : undefined;
}
function writeTarget(draft: ResumeData, target: ProposalTarget, value: string) {
if (!target.itemId) {
draft.summary.content = value;
return;
}
const entry = findEntry(draft, target.sectionId, target.itemId) as Record<string, unknown> | undefined;
if (entry) entry[target.field] = value;
}
/** A pending proposal can be applied only while its passage is still in the field, word for word. */
export const canApply = (data: ResumeData, proposal: Proposal) =>
readTarget(data, proposal.target)?.includes(proposal.before) ?? false;
/**
* What a proposal shows as. Pending ones whose passage has changed are out of date; accepted ones whose edit was
* undone (the passage is back, the new text gone) are pending again.
*/
export function getProposalState(data: ResumeData, proposal: Proposal): ProposalState {
if (proposal.status === "rejected") return "rejected";
const value = readTarget(data, proposal.target) ?? "";
if (proposal.status === "accepted")
return value.includes(proposal.before) && !value.includes(proposal.after) ? "pending" : "accepted";
return value.includes(proposal.before) ? "pending" : "stale";
}
/** Puts `after` in place of `before`. Returns false (changing nothing) when the proposal is out of date. */
export function applyProposal(draft: ResumeData, proposal: Proposal): boolean {
const value = readTarget(draft, proposal.target);
if (value === undefined || !value.includes(proposal.before)) return false;
// A replacer function, so "$&" or "$1" in the new text stay literal.
writeTarget(
draft,
proposal.target,
value.replace(proposal.before, () => proposal.after),
);
return true;
}
// Colours of the page marks (README §5.9), as the PDF needs them: old text struck through in grey, new text on
// a pale accent highlight.
const OLD_TEXT_COLOR = "#7d7b73";
const NEW_TEXT_BACKGROUND = "#d4efd9";
const BLOCK = /^(<(p|li)(?:\s[^>]*)?>)([\s\S]*)(<\/\2>)$/i;
/** A block's opening tag, contents and closing tag; `undefined` for anything else. */
function splitBlock(html: string) {
const match = BLOCK.exec(html);
return match ? { open: match[1] ?? "", inner: match[3] ?? "", close: match[4] ?? "" } : undefined;
}
/** The old text struck through, then the new text highlighted, inside the passage's own block. */
function markChange(before: string, after: string) {
const old = splitBlock(before);
const next = splitBlock(after);
const struck = (html: string) => `<s style="color: ${OLD_TEXT_COLOR}">${html}</s>`;
const highlighted = (html: string) => `<mark data-color="${NEW_TEXT_BACKGROUND}">${html}</mark>`;
if (!old || !next) return `${struck(before)} ${highlighted(after)}`;
return `${old.open}${struck(old.inner)} ${highlighted(next.inner)}${old.close}`;
}
/**
* The page as it would read with the proposals: each passage struck through, followed by its replacement,
* highlighted. For the preview only; it's never saved.
*/
export function markProposals(data: ResumeData, proposals: readonly Proposal[]): ResumeData {
const pending = proposals.filter((proposal) => getProposalState(data, proposal) === "pending");
if (pending.length === 0) return data;
return produce(data, (draft) => {
for (const proposal of pending) {
const value = readTarget(draft, proposal.target);
if (value === undefined || !value.includes(proposal.before)) continue;
const marked = markChange(proposal.before, proposal.after);
writeTarget(
draft,
proposal.target,
value.replace(proposal.before, () => marked),
);
}
});
}
const escapeHtml = (text: string) =>
text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** The block with its text replaced by plain text (a model's rewrite), keeping the tag and its attributes. */
export function replaceBlockText(block: string, text: string): string {
const html = escapeHtml(text.trim().replace(/\s+/g, " "));
const parts = splitBlock(block);
return parts ? `${parts.open}${html}${parts.close}` : html;
}
+68 -4
View File
@@ -1,5 +1,6 @@
import type { PageMap, PageMapTarget } from "@reactive-resume/pdf/page-map";
import type { Template } from "@reactive-resume/schema/templates";
import type { Proposal } from "./proposals/proposals";
import { create } from "zustand/react";
export const EDITOR_MODES = ["write", "design", "check"] as const;
@@ -8,6 +9,23 @@ export type EditorMode = (typeof EDITOR_MODES)[number];
/** The Share & export sheet's tabs; each entry point opens its own. */
export type ShareTab = "link" | "download" | "history";
export type CheckTab = "issues" | "match" | "writing";
/** Phones show one view at a time: the page, or one mode's panel. */
export type MobileView = "page" | EditorMode;
/** A writing note that isn't a rewrite: where it is, how much it matters, the words and what to consider. */
export type WritingNote = {
location: string;
impact: "high" | "medium" | "low";
quote: string;
note: string;
target: PageMapTarget | null;
};
/** Check → Writing: the model's review. Its rewrites are the proposals. */
type WritingReview = { summary: string; strengths: readonly string[]; notes: readonly WritingNote[] };
/** What's selected in the editor: shared by the panel and the page, so each can outline the other. */
export type EditorSelection = PageMapTarget;
@@ -40,8 +58,25 @@ type EditorStore = {
basicsOpen: boolean;
/** Design: a template shown on the page while its thumbnail is hovered or focused, not yet applied. */
previewTemplate: Template | null;
/** The render on screen: physical pages and the page map. `version` counts renders, so Fit can wait for one. */
rendered: { pageCount: number; pageMap: PageMap | undefined; version: number };
/**
* The render on screen: physical pages, the page map and the PDF (which Check's parser view reads). `version`
* counts renders, so Fit can wait for one.
*/
rendered: { pageCount: number; pageMap: PageMap | undefined; file: Blob | undefined; version: number };
/** Phones: the view on screen; null until one is picked, which shows the current mode. */
mobileView: MobileView | null;
checkTab: CheckTab;
/** Check: the issue picked on its card or pin (its key). Both are outlined. */
checkIssue: string | null;
/** Check: the page as a person sees it, or the text a parser reads from it. */
pageView: "page" | "parser";
/** Check → Job match: a term whose entries are tinted on the page. */
highlightTerm: string | null;
/** Check → Job match: a posting pasted for this visit, for a resume with no application linked. */
pastedPosting: string;
/** Edits suggested for the page (Check → Writing), shown on it until accepted or rejected. */
proposals: readonly Proposal[];
writingReview: WritingReview | null;
select: (selection: EditorSelection | null) => void;
setZoom: (zoom: number | "fit") => void;
setDrawerOpen: (open: boolean) => void;
@@ -55,7 +90,16 @@ type EditorStore = {
setFocusEntry: (entryId: string | null) => void;
setBasicsOpen: (open: boolean) => void;
setPreviewTemplate: (template: Template | null) => void;
setRendered: (render: { pageCount: number; pageMap: PageMap | undefined }) => void;
setRendered: (render: { pageCount: number; pageMap: PageMap | undefined; file: Blob }) => void;
setMobileView: (view: MobileView) => void;
setCheckTab: (tab: CheckTab) => void;
setCheckIssue: (key: string | null) => void;
setPageView: (view: "page" | "parser") => void;
setHighlightTerm: (term: string | null) => void;
setPastedPosting: (posting: string) => void;
setProposals: (proposals: readonly Proposal[]) => void;
setProposalStatus: (ids: readonly string[], status: Proposal["status"]) => void;
setWritingReview: (review: WritingReview | null) => void;
reset: () => void;
};
@@ -84,7 +128,15 @@ const initialState = {
focusEntryId: null,
basicsOpen: true,
previewTemplate: null,
rendered: { pageCount: 0, pageMap: undefined, version: 0 },
rendered: { pageCount: 0, pageMap: undefined, file: undefined, version: 0 },
mobileView: null,
checkTab: "issues",
checkIssue: null,
pageView: "page",
highlightTerm: null,
pastedPosting: "",
proposals: [],
writingReview: null,
} as const;
export const useEditorStore = create<EditorStore>()((set) => ({
@@ -112,5 +164,17 @@ export const useEditorStore = create<EditorStore>()((set) => ({
setBasicsOpen: (basicsOpen) => set({ basicsOpen }),
setPreviewTemplate: (previewTemplate) => set({ previewTemplate }),
setRendered: (render) => set((state) => ({ rendered: { ...render, version: state.rendered.version + 1 } })),
setMobileView: (mobileView) => set({ mobileView }),
setCheckTab: (checkTab) => set({ checkTab }),
setCheckIssue: (checkIssue) => set({ checkIssue }),
setPageView: (pageView) => set({ pageView }),
setHighlightTerm: (highlightTerm) => set({ highlightTerm }),
setPastedPosting: (pastedPosting) => set({ pastedPosting }),
setProposals: (proposals) => set({ proposals }),
setProposalStatus: (ids, status) =>
set((state) => ({
proposals: state.proposals.map((proposal) => (ids.includes(proposal.id) ? { ...proposal, status } : proposal)),
})),
setWritingReview: (writingReview) => set({ writingReview }),
reset: () => set(initialState),
}));
@@ -1,14 +0,0 @@
import { useDeferredValue, useMemo } from "react";
import { lintResumeForAts } from "@reactive-resume/resume/ats";
import { useResumeData } from "@/features/resume/builder/draft";
/** Open Check issues (errors and warnings from the live checks) for the badge on the Check tab. */
export function useOpenIssueCount(): number {
const data = useDeferredValue(useResumeData());
return useMemo(() => {
if (!data) return 0;
const { counts } = lintResumeForAts(data);
return counts.error + counts.warning;
}, [data]);
}
@@ -27,12 +27,12 @@ import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { orpc } from "@/libs/orpc/client";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
import { useEditorStore } from "../store";
import { EntryFields } from "./entries";
import { useEntry, useEntryWriter } from "./fields";
import { createEntry, describeEntry, getEntries, getPrimaryField, isDraftEntry } from "./model";
import { entryElementId } from "./reveal";
import { useSectionTitle } from "./section-row";
const DRAFT_HINTS: Record<string, MessageDescriptor> = {
@@ -99,7 +99,7 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove
return (
<div
ref={sortable.setNodeRef}
id={atsFindingItemElementId(entryId)}
id={entryElementId(entryId)}
data-entry-id={entryId}
style={{ transform: CSS.Translate.toString(sortable.transform), transition: sortable.transition }}
className={cn(
@@ -1,13 +1,15 @@
import type { EditorSelection } from "../store";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import { useEditorStore } from "../store";
/** The id of an entry's card in the Write panel, which the page and Check scroll to. */
export const entryElementId = (entryId: string) => `resume-item-${entryId}`;
/** Smooth scrolling unless the user asked for reduced motion. */
export const getScrollBehavior = (): ScrollBehavior =>
window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth";
const SECTION_ANCHOR = "sidebar-";
const ITEM_ANCHOR = atsFindingItemElementId("");
const ITEM_ANCHOR = entryElementId("");
/**
* The block an element in the Write panel edits, read from the section and entry anchors around it, so focusing
@@ -39,7 +41,7 @@ export function revealSelectionInPanel(selection: EditorSelection) {
const section = document.getElementById(
selection.kind === "header" ? "sidebar-basics" : `sidebar-${selection.sectionId}`,
);
const entry = selection.kind === "item" ? document.getElementById(atsFindingItemElementId(selection.itemId)) : null;
const entry = selection.kind === "item" ? document.getElementById(entryElementId(selection.itemId)) : null;
(entry ?? section)?.scrollIntoView({ block: "start", behavior: getScrollBehavior() });
});
}
@@ -182,9 +182,11 @@ export function ResumePreviewClient({
const activeLayer = getActivePreviewLayer(previewLayers);
const activePageCount = activeLayer?.numPages ?? 0;
const activePageMap = activeLayer?.pageMap;
const activeFile = activeLayer?.file;
useEffect(() => {
if (activePageCount > 0) onRender?.({ pageCount: activePageCount, pageMap: activePageMap });
}, [activePageCount, activePageMap, onRender]);
if (activePageCount > 0 && activeFile)
onRender?.({ pageCount: activePageCount, pageMap: activePageMap, file: activeFile });
}, [activePageCount, activePageMap, activeFile, onRender]);
if (!resumeData) return null;
@@ -18,7 +18,8 @@ export type ResumePreviewProps = {
/** Drawn over each page, in page-relative coordinates; receives the page map of the render on screen. */
renderPageOverlay?: (page: { pageIndex: number; pageMap: PageMap | undefined }) => ReactNode;
/** Called whenever the render on screen changes, with its physical page count and page map. */
onRender?: (render: { pageCount: number; pageMap: PageMap | undefined }) => void;
/** Each render on screen: its page count, page map and the PDF itself. */
onRender?: (render: { pageCount: number; pageMap: PageMap | undefined; file: Blob }) => void;
};
export type ResolvedResumePreviewProps = ResumePreviewProps & {
@@ -16,7 +16,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume } from "@/features/resume/builder/draft";
import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count";
import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export";
type Format = { id: ExportFormat; label: string; extension: string; icon: IconName; description: string };
@@ -66,7 +66,10 @@ export function HistoryTab() {
await savePendingChanges(resume.id);
const restored = await restoreVersion.mutateAsync({ resumeId: resume.id, versionId: version.id });
useResumeStore.getState().replaceResumeFromServer(restored as Resume);
queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), restored);
queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), {
...restored,
applicationId: resume.applicationId ?? null,
});
setVersion(null);
void queryClient.invalidateQueries({ queryKey: listKey });
toast.add({
+20
View File
@@ -11,3 +11,23 @@
transform: translateX(-33.333%);
}
}
/* Check's score ring: the arc eases to a new score, so a fix visibly counts. */
@property --ring-value {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@utility score-ring {
background: conic-gradient(var(--ring-color) 0 var(--ring-value), var(--sunken) 0);
transition: --ring-value var(--d3) var(--ease);
}
/* A wavy warn underline under a line of the page that has a Check issue. */
@utility wavy-underline {
background-color: var(--warn);
mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='4' viewBox='0 0 6 4'%3E%3Cpath d='M0 3 Q1.5 0 3 3 T6 3' fill='none' stroke='black' stroke-width='1.2'/%3E%3C/svg%3E")
repeat-x left center / 6px 4px;
}
-152
View File
@@ -1,152 +0,0 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { ATS_RULE_CODES } from "@reactive-resume/resume/ats";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { atsFindingItemElementId, getAtsFindingLocation, getAtsFindingMessage, getAtsFindingTarget } from "./ats";
const experienceItem = (id: string) => ({
id,
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [],
});
function makeResume(): ResumeData {
const data = structuredClone(defaultResumeData);
data.sections.experience.items = [experienceItem("exp-a"), experienceItem("exp-b")] as never;
data.customSections = [
{
id: "custom-1",
type: "experience",
title: "Consulting",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [experienceItem("custom-item-a")],
},
] as never;
return data;
}
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("getAtsFindingMessage", () => {
it("covers every rule in the catalog", () => {
for (const code of ATS_RULE_CODES) {
const message = getAtsFindingMessage(code);
expect(message.title.length, code).toBeGreaterThan(0);
expect(message.action.length, code).toBeGreaterThan(0);
}
});
it("gives each rule a distinct title", () => {
const titles = ATS_RULE_CODES.map((code) => getAtsFindingMessage(code).title);
expect(new Set(titles).size).toBe(titles.length);
});
});
describe("getAtsFindingTarget", () => {
it.each([
["/basics/email", { side: "left", section: "basics" }],
["/basics/customFields/0/link", { side: "left", section: "basics" }],
["/picture", { side: "left", section: "picture" }],
["/summary/content", { side: "left", section: "summary" }],
["/sections/experience/items/0/period", { side: "left", section: "experience" }],
["/customSections/2/items/1/period", { side: "left", section: "custom" }],
["/metadata/typography/body/fontSize", { side: "right", section: "typography" }],
["/metadata/page/marginX", { side: "right", section: "page" }],
["/metadata/layout/pages", { side: "right", section: "layout" }],
])("resolves %s", (pointer, expected) => {
expect(getAtsFindingTarget(pointer)).toEqual(expected);
});
it("returns null for a pointer with no sidebar home", () => {
expect(getAtsFindingTarget("/metadata/styleRules/0")).toBeNull();
});
it("decodes escaped pointer tokens", () => {
expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({
side: "left",
section: "experience",
});
});
});
describe("getAtsFindingTarget item resolution", () => {
it("resolves the item a finding belongs to", () => {
expect(getAtsFindingTarget("/sections/experience/items/0/period", makeResume())).toEqual({
side: "left",
section: "experience",
itemId: "exp-a",
});
});
it("sends two findings in the same section to different items", () => {
const data = makeResume();
const first = getAtsFindingTarget("/sections/experience/items/0/period", data);
const second = getAtsFindingTarget("/sections/experience/items/1/period", data);
expect(first?.itemId).toBe("exp-a");
expect(second?.itemId).toBe("exp-b");
expect(first?.section).toBe(second?.section);
});
it("resolves items inside custom sections", () => {
expect(getAtsFindingTarget("/customSections/0/items/0/period", makeResume())).toEqual({
side: "left",
section: "custom",
itemId: "custom-item-a",
});
});
it("omits the item when no resume data is supplied", () => {
expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({
side: "left",
section: "experience",
});
});
it("omits the item for a section-level pointer", () => {
expect(getAtsFindingTarget("/sections/experience", makeResume())).toEqual({
side: "left",
section: "experience",
});
});
it("omits the item when the index is out of range", () => {
expect(getAtsFindingTarget("/sections/experience/items/9/period", makeResume())).toEqual({
side: "left",
section: "experience",
});
});
it("builds a stable element id", () => {
expect(atsFindingItemElementId("exp-a")).toBe("resume-item-exp-a");
});
});
describe("getAtsFindingLocation", () => {
it("names the section for a section-level pointer", () => {
expect(getAtsFindingLocation("/basics/email")).toBe("Basics");
});
it("adds a one-based item position for an item-level pointer", () => {
expect(getAtsFindingLocation("/sections/experience/items/0/period")).toBe("Experience · Item 1");
});
it("returns null when the pointer has no sidebar home", () => {
expect(getAtsFindingLocation("/metadata/styleRules/0")).toBeNull();
});
});
-182
View File
@@ -1,182 +0,0 @@
import type { AtsRuleCode } from "@reactive-resume/resume/ats";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { LeftSidebarSection, SidebarSection } from "./section";
import { t } from "@lingui/core/macro";
import { match } from "ts-pattern";
import { getSectionTitle, leftSidebarSections } from "./section";
export type AtsFindingMessage = {
title: string;
action: string;
};
export type AtsFindingTarget = {
side: "left" | "right";
section: SidebarSection;
itemId?: string;
};
export const atsFindingItemElementId = (itemId: string) => `resume-item-${itemId}`;
export function getAtsFindingMessage(code: AtsRuleCode): AtsFindingMessage {
return match(code)
.with("MISSING_NAME", () => ({
title: t`Your resume has no name.`,
action: t`Add your full name under Basics.`,
}))
.with("MISSING_EMAIL", () => ({
title: t`Your resume has no email address.`,
action: t`Most systems file candidates by email. Add one under Basics.`,
}))
.with("MALFORMED_EMAIL", () => ({
title: t`This email address will not be recognized.`,
action: t`Use a plain address such as name@example.com, with no surrounding text.`,
}))
.with("MISSING_PHONE", () => ({
title: t`Your resume has no phone number.`,
action: t`Some application systems require one before you can submit.`,
}))
.with("MISSING_LOCATION", () => ({
title: t`Your resume has no location.`,
action: t`Add at least a city and country so roles can be matched to your region.`,
}))
.with("MALFORMED_URL", () => ({
title: t`This link is missing its protocol.`,
action: t`Write the full address, including https://.`,
}))
.with("PICTURE_PRESENT", () => ({
title: t`Your resume includes a photo.`,
action: t`Some parsers mishandle images, and photos are discouraged in some regions.`,
}))
.with("EMPTY_PERIOD", () => ({
title: t`This entry has no dates.`,
action: t`Add a period such as "Jan 2020 - Present" so it lands on your timeline.`,
}))
.with("UNPARSEABLE_PERIOD", () => ({
title: t`These dates will not be read correctly.`,
action: t`Use a recognized form such as "Jan 2020 - Mar 2022" or "2020 - 2022".`,
}))
.with("UNPARSEABLE_DATE", () => ({
title: t`This date will not be read correctly.`,
action: t`Use a recognized form such as "March 2022" or "2022".`,
}))
.with("REVERSED_PERIOD", () => ({
title: t`This period ends before it starts.`,
action: t`Swap the start and end dates.`,
}))
.with("FUTURE_DATED_PERIOD", () => ({
title: t`This period starts in the future.`,
action: t`Correct the year, or use "Present" for ongoing work.`,
}))
.with("SECTION_MISSING_FROM_LAYOUT", () => ({
title: t`This section has content but never appears.`,
action: t`Place it on a page from the Layout panel, or hide it if you meant to park it.`,
}))
.with("NO_VISIBLE_EXPERIENCE", () => ({
title: t`Your resume shows no work experience.`,
action: t`Add an entry, or use projects and volunteer work to show equivalent history.`,
}))
.with("MISSING_EXPERIENCE_DESCRIPTION", () => ({
title: t`This role has no description.`,
action: t`Describe what you did so the entry contributes keywords for matching.`,
}))
.with("NON_STANDARD_SECTION_TITLE", () => ({
title: t`This heading is not one parsers look for.`,
action: t`Prefer a conventional heading such as "Work Experience" or "Education".`,
}))
.with("MULTI_COLUMN_PROSE_SECTION", () => ({
title: t`This section is split across columns.`,
action: t`Columns commonly scramble the order text is read in. Set it to a single column.`,
}))
.with("PROSE_SECTION_IN_SIDEBAR", () => ({
title: t`This section sits in the sidebar.`,
action: t`Move it into the main column and keep the sidebar for short lists.`,
}))
.with("SMALL_BODY_FONT", () => ({
title: t`Your body text is very small.`,
action: t`Use a size of at least 9pt so re-rendered copies stay accurate.`,
}))
.with("TIGHT_LINE_HEIGHT", () => ({
title: t`Your lines are packed very tightly.`,
action: t`Use a line height of at least 1.15 so lines are not merged together.`,
}))
.with("TIGHT_PAGE_MARGINS", () => ({
title: t`Your page margins are very narrow.`,
action: t`Increase them so content stays inside the reliably read area.`,
}))
.exhaustive();
}
function decodePointerToken(token: string): string {
return token.replace(/~1/g, "/").replace(/~0/g, "~");
}
function pointerTokens(pointer: string): string[] {
return pointer.split("/").slice(1).map(decodePointerToken);
}
const isLeftSidebarSection = (value: string): value is LeftSidebarSection =>
(leftSidebarSections as string[]).includes(value);
function resolveItemId(tokens: readonly string[], data?: ResumeData): string | undefined {
if (!data) return undefined;
const itemsIndex = tokens.indexOf("items");
if (itemsIndex === -1) return undefined;
const position = Number(tokens[itemsIndex + 1]);
if (!Number.isInteger(position)) return undefined;
const [head, next] = tokens;
let items: readonly unknown[] | undefined;
if (head === "sections" && next) {
items = (data.sections as Record<string, { items?: readonly unknown[] }>)[next]?.items;
} else if (head === "customSections") {
items = data.customSections[Number(next)]?.items;
}
const item = items?.[position] as { id?: unknown } | undefined;
return typeof item?.id === "string" ? item.id : undefined;
}
export function getAtsFindingTarget(pointer: string, data?: ResumeData): AtsFindingTarget | null {
const tokens = pointerTokens(pointer);
const [head, next] = tokens;
const itemId = resolveItemId(tokens, data);
const withItem = (target: AtsFindingTarget): AtsFindingTarget => (itemId ? { ...target, itemId } : target);
if (head === "basics") return { side: "left", section: "basics" };
if (head === "picture") return { side: "left", section: "picture" };
if (head === "summary") return { side: "left", section: "summary" };
if (head === "customSections") return withItem({ side: "left", section: "custom" });
if (head === "sections" && next && isLeftSidebarSection(next)) {
return withItem({ side: "left", section: next });
}
if (head === "metadata") {
if (next === "typography") return { side: "right", section: "typography" };
if (next === "page") return { side: "right", section: "page" };
if (next === "layout") return { side: "right", section: "layout" };
}
return null;
}
export function getAtsFindingLocation(pointer: string): string | null {
const target = getAtsFindingTarget(pointer);
if (!target) return null;
const tokens = pointerTokens(pointer);
const itemsIndex = tokens.indexOf("items");
const sectionTitle = getSectionTitle(target.section);
if (itemsIndex === -1) return sectionTitle;
const position = Number(tokens[itemsIndex + 1]);
if (!Number.isInteger(position)) return sectionTitle;
const itemNumber = position + 1;
return t`${sectionTitle} · Item ${itemNumber}`;
}
-5
View File
@@ -24,7 +24,6 @@ import {
PaletteIcon,
PhoneIcon,
ReadCvLogoIcon,
SealCheckIcon,
ShareFatIcon,
StarIcon,
TextTIcon,
@@ -50,7 +49,6 @@ export type RightSidebarSection =
| "notes"
| "sharing"
| "statistics"
| "ats"
| "export"
| "information";
@@ -85,7 +83,6 @@ export const rightSidebarSections: RightSidebarSection[] = [
"styles",
"page",
"notes",
"ats",
"export",
"information",
] as const;
@@ -124,7 +121,6 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string =
.with("notes", () => t`Notes`)
.with("sharing", () => t`Sharing`)
.with("statistics", () => t`Statistics`)
.with("ats", () => t`ATS Check`)
.with("export", () => t`Export`)
.with("information", () => t`Information`)
@@ -168,7 +164,6 @@ export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: Ic
.with("notes", () => <NotepadIcon {...iconProps} />)
.with("sharing", () => <ShareFatIcon {...iconProps} />)
.with("statistics", () => <ChartLineIcon {...iconProps} />)
.with("ats", () => <SealCheckIcon {...iconProps} />)
.with("export", () => <DownloadIcon {...iconProps} />)
.with("information", () => <InfoIcon {...iconProps} />)
@@ -10,8 +10,8 @@ import { TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
import { useEditorStore } from "@/features/resume/editor/store";
import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { BuilderAiAssistant } from "./ai-assistant";
import { DocumentMenu } from "./document-menu";
@@ -1,22 +1,23 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { EditorMode } from "@/features/resume/editor/store";
import type { EditorMode, MobileView } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Outlet } from "@tanstack/react-router";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useEffect, useSyncExternalStore } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
import { IssueStepper } from "@/features/resume/editor/check/page-layer";
import { useEditorStore } from "@/features/resume/editor/store";
import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
import { DesignSheet } from "./design-panel";
import { EditorBar } from "./editor-bar";
import { ModePanel } from "./mode-panels";
import { ShareSheet } from "./share-sheet";
import { useEditorHotkeys } from "./use-editor-hotkeys";
import { useEditorMode } from "./use-editor-mode";
const LANDSCAPE_QUERY = "(orientation: landscape)";
@@ -123,8 +124,6 @@ function TabletBody({ mode }: { mode: EditorMode }) {
);
}
type MobileView = "page" | EditorMode;
type MobileBodyProps = {
mode: EditorMode;
onModeChange: (mode: EditorMode) => void;
@@ -138,7 +137,8 @@ const MOBILE_TABS: { view: MobileView; icon: IconName }[] = [
];
function MobileBody({ mode, onModeChange }: MobileBodyProps) {
const [view, setView] = useState<MobileView>(mode);
const view = useEditorStore((state) => state.mobileView) ?? mode;
const setView = useEditorStore((state) => state.setMobileView);
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
const labels: Record<MobileView, string> = { write: t`Write`, page: t`Page`, design: t`Design`, check: t`Check` };
@@ -156,7 +156,8 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) {
<main id="main-content" className={cn("h-full", !pageVisible && "invisible")}>
<Outlet />
</main>
{view === "page" && (
{view === "page" && mode === "check" && <IssueStepper />}
{view === "page" && mode !== "check" && (
<SelectionBar
onEdit={() => {
setView("write");
@@ -1,20 +1,11 @@
import type { EditorMode } from "@/features/resume/editor/store";
import { CheckPanel } from "@/features/resume/editor/check/check-panel";
import { OfflineBanner } from "@/features/resume/editor/save-status";
import { useEditorStore } from "@/features/resume/editor/store";
import { selectionFromPanelElement } from "@/features/resume/editor/write/reveal";
import { WritePanel } from "@/features/resume/editor/write/write-panel";
import { AtsCheckSectionBuilder } from "../-sidebar/right/sections/ats-check";
import { DesignPanel } from "./design-panel";
// Until Check mode is rebuilt (M7), it hosts the existing ATS check.
function CheckPanel() {
return (
<div className="@container p-4">
<AtsCheckSectionBuilder />
</div>
);
}
function WriteMode() {
const select = useEditorStore((state) => state.select);
@@ -10,14 +10,17 @@ import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { templates } from "@/dialogs/resume/template/data";
import { useCurrentBuilderResumeSelector, useResumeData } from "@/features/resume/builder/draft";
import { CheckPageLayer, PageViewToggle } from "@/features/resume/editor/check/page-layer";
import { ParserView } from "@/features/resume/editor/check/parser-view";
import { measureOverflow, runFit } from "@/features/resume/editor/design/fit";
import { PageOverlay } from "@/features/resume/editor/page-overlay";
import { markProposals } from "@/features/resume/editor/proposals/proposals";
import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/features/resume/editor/store";
import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
import { ResumePreview } from "@/features/resume/preview/preview";
import { formatVersionTime, getVersionTitle } from "@/features/resume/share/format";
import { orpc } from "@/libs/orpc/client";
import { useEditorMode } from "./use-editor-mode";
// Page widths in PDF points; 1pt renders as 1 CSS px at 100%.
const PAGE_WIDTH = { a4: 595.28, letter: 612, "free-form": 595.28 } as const;
@@ -51,6 +54,9 @@ export function PageCanvas() {
const setDrawerOpen = useEditorStore((state) => state.setDrawerOpen);
const previewTemplate = useEditorStore((state) => state.previewTemplate);
const historyVersionId = useEditorStore((state) => state.historyVersionId);
const pageView = useEditorStore((state) => state.pageView);
const checkTab = useEditorStore((state) => state.checkTab);
const proposals = useEditorStore((state) => state.proposals);
const sheetOpen = useEditorStore((state) => state.shareTab !== null);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const { i18n } = useLingui();
@@ -73,6 +79,10 @@ export function PageCanvas() {
});
const viewing = historyVersionId !== null && version?.id === historyVersionId ? version : null;
// Check → Writing: proposed edits show on the page, the old text struck through and the new highlighted.
const markEdits = mode === "check" && checkTab === "writing" && pageView === "page" && proposals.length > 0;
const parser = mode === "check" && pageView === "parser" && !viewing;
// Design: a hovered or focused template is drawn on the page until it's applied or the pointer leaves.
const previewData = useMemo(
() =>
@@ -80,8 +90,10 @@ export function PageCanvas() {
? viewing.data
: data && previewTemplate
? { ...data, metadata: { ...data.metadata, template: previewTemplate } }
: undefined,
[data, previewTemplate, viewing],
: data && markEdits
? markProposals(data, proposals)
: undefined,
[data, previewTemplate, viewing, markEdits, proposals],
);
const overflow = data && !previewTemplate && !viewing ? measureOverflow(data, rendered) : null;
// Desktop: the page moves 120px aside so it stays visible beside the Share & export sheet.
@@ -106,7 +118,12 @@ export function PageCanvas() {
if (breakpoint === "tablet") setDrawerOpen(false);
if (isPhone) select(null);
}}
className={cn("absolute inset-0 overflow-auto pt-7 pb-24", isPhone ? "px-4" : "px-10")}
className={cn(
"absolute inset-0 overflow-auto pb-24",
// Check keeps room above the page for the page-view toggle.
mode === "check" && !viewing ? "pt-[68px]" : "pt-7",
isPhone ? "px-4" : "px-10",
)}
>
<ResumePreview
data={previewData}
@@ -116,6 +133,7 @@ export function PageCanvas() {
className={cn(
"mx-auto w-fit transition-transform duration-emphasized ease-enter",
shifted && "-translate-x-[120px] rtl:translate-x-[120px]",
parser && "hidden",
)}
pageClassName={cn("rounded-none shadow-page", viewing && "outline-2 outline-ink outline-offset-4")}
onRender={setRendered}
@@ -143,7 +161,11 @@ export function PageCanvas() {
</span>
) : (
<>
<Trans>Page 1 · {formatLabel} · click any line to edit it</Trans>
{mode === "check" ? (
<Trans>Page 1 · {formatLabel}</Trans>
) : (
<Trans>Page 1 · {formatLabel} · click any line to edit it</Trans>
)}
{overflow && <OverflowChip {...overflow} />}
</>
)}
@@ -163,12 +185,18 @@ export function PageCanvas() {
}
renderPageOverlay={({ pageIndex, pageMap }) =>
// A version from History is read-only: its lines don't open entries.
viewing ? null : <PageOverlay pageIndex={pageIndex} pageMap={pageMap} onSelect={onSelect} />
viewing ? null : mode === "check" ? (
<CheckPageLayer pageIndex={pageIndex} pageMap={pageMap} />
) : (
<PageOverlay pageIndex={pageIndex} pageMap={pageMap} onSelect={onSelect} />
)
}
/>
{parser && <ParserView />}
</div>
<ZoomBar fitScale={fitScale} pageCount={Math.max(1, rendered.pageCount)} />
{mode === "check" && !viewing && <PageViewToggle />}
{!parser && <ZoomBar fitScale={fitScale} pageCount={Math.max(1, rendered.pageCount)} />}
</div>
);
}
@@ -6,10 +6,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/co
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useEditorStore } from "@/features/resume/editor/store";
import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
import { DownloadTab } from "@/features/resume/share/download-tab";
import { HistoryTab } from "@/features/resume/share/history-tab";
import { LinkTab } from "@/features/resume/share/link-tab";
import { useEditorMode } from "./use-editor-mode";
/**
* Share & export: one sheet with Link, Download and History. Share opens Link, the ▾ beside Download PDF
@@ -1,304 +0,0 @@
// @vitest-environment happy-dom
import type { ExperienceItem, ResumeData } from "@reactive-resume/schema/resume/data";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const resumeState = vi.hoisted(() => ({ data: undefined as ResumeData | undefined }));
const editorModeState = vi.hoisted(() => ({ setMode: vi.fn() }));
const deepCheckMocks = vi.hoisted(() => ({
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
runAtsCheck: vi.fn(async () => ({
report: {
version: 1 as const,
score: 91,
cappedBy: [],
categories: [],
checks: [],
findings: [],
tips: [],
counts: { blocker: 0, warning: 0, tip: 0 },
applicableChecks: 40,
passedChecks: 40,
skippedChecks: 2,
jd: null,
file: { name: "resume.pdf", sizeBytes: 4, magicBytesOk: true },
document: { pageCount: 1, truncated: false, wordCount: 320, operatorsAvailable: true },
},
fullText: "Ada Lovelace",
})),
}));
type SectionBaseProps = { children: React.ReactNode };
// The renderer and the engine are exercised by their own tests; here they only have to be callable.
vi.mock("@/features/resume/export/pdf-document", () => ({
createResumePdfBlob: deepCheckMocks.createResumePdfBlob,
}));
vi.mock("@/features/ats-checker/run-ats-check", () => ({
runAtsCheck: deepCheckMocks.runAtsCheck,
blobToPdfFile: (blob: Blob, name: string) => new File([blob], name, { type: "application/pdf" }),
}));
vi.mock("@/features/ats-checker/report/report-view", () => ({
AtsPdfReportView: ({ report }: { report: { score: number } }) => <div>Deep report: {report.score}</div>,
}));
vi.mock("@/features/ats-checker/ai-review/ai-review-card", () => ({
AiReviewCard: () => <div>AI review card</div>,
}));
vi.mock("@/features/resume/builder/draft", () => ({
useResumeData: () => resumeState.data,
}));
vi.mock("../../../-components/use-editor-mode", () => ({
useEditorMode: () => ["check", editorModeState.setMode],
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
}));
const { AtsCheckSectionBuilder } = await import("./ats-check");
const { useEditorStore } = await import("@/features/resume/editor/store");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
vi.clearAllMocks();
resumeState.data = undefined;
document.body.innerHTML = "";
});
const experienceItem = (overrides: Partial<ExperienceItem> = {}): ExperienceItem => ({
id: "exp-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "Jan 2020 - Present",
website: { url: "", label: "", inlineLink: false },
description: "<p>Designed and shipped the difference engine.</p>",
roles: [],
...overrides,
});
function makeResume(mutate: (data: ResumeData) => void = () => undefined): ResumeData {
const data = structuredClone(defaultResumeData);
data.basics.name = "Ada Lovelace";
data.basics.email = "ada@example.com";
data.basics.phone = "+44 20 7946 0100";
data.basics.location = "London, UK";
data.sections.experience.items = [experienceItem()];
data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: [] }];
mutate(data);
return data;
}
const renderPanel = () =>
render(
<I18nProvider i18n={i18n}>
<AtsCheckSectionBuilder />
</I18nProvider>,
);
describe("AtsCheckSectionBuilder", () => {
it("shows no checks at all before the resume is ready", () => {
renderPanel();
expect(screen.queryByText(/checks passed/)).toBeNull();
expect(screen.queryByText(/Deep check/)).toBeNull();
});
it("reports a clean resume as fully passing", () => {
resumeState.data = makeResume();
renderPanel();
expect(screen.getByText("21 of 21 checks passed")).toBeTruthy();
expect(screen.getByText(/Every check passed/)).toBeTruthy();
});
it("lists a finding with its title and remedy", () => {
resumeState.data = makeResume((data) => {
data.basics.email = "ada at example dot com";
});
renderPanel();
expect(screen.getByText("This email address will not be recognized.")).toBeTruthy();
expect(screen.getByText(/Use a plain address/)).toBeTruthy();
expect(screen.getByText("20 of 21 checks passed")).toBeTruthy();
});
it("counts findings by severity", () => {
resumeState.data = makeResume((data) => {
data.basics.email = "";
data.picture.url = "/uploads/ada.png";
});
renderPanel();
expect(screen.getByText("1 error")).toBeTruthy();
expect(screen.getByText("1 note")).toBeTruthy();
});
it("opens what a finding is about in Write when its location is clicked", () => {
resumeState.data = makeResume((data) => {
data.basics.email = "";
});
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Basics/ }));
expect(editorModeState.setMode).toHaveBeenCalledWith("write");
expect(useEditorStore.getState()).toMatchObject({ selection: { kind: "header" }, basicsOpen: true });
});
it("points typography findings at the Type group in Design", () => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 0;
});
const group = document.createElement("section");
group.id = "design-type";
group.scrollIntoView = vi.fn();
document.body.append(group);
resumeState.data = makeResume((data) => {
data.metadata.typography.body.fontSize = 8;
});
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Typography/ }));
expect(editorModeState.setMode).toHaveBeenCalledWith("design");
expect(group.scrollIntoView).toHaveBeenCalled();
group.remove();
vi.unstubAllGlobals();
});
});
describe("the deep check tier", () => {
beforeEach(() => {
resumeState.data = makeResume();
});
it("offers a deep check alongside the live lint", () => {
renderPanel();
expect(screen.getByText("Deep check")).toBeTruthy();
expect(screen.getByRole("button", { name: /Run deep check/ })).toBeTruthy();
});
it("renders the current resume to PDF and reports on those bytes", async () => {
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Run deep check/ }));
await waitFor(() => expect(screen.getByText(/Deep report: 91/)).toBeTruthy());
expect(deepCheckMocks.createResumePdfBlob).toHaveBeenCalledWith(resumeState.data);
expect(deepCheckMocks.runAtsCheck).toHaveBeenCalledWith(expect.any(File), { jobDescription: "" });
});
it("offers the AI review only once a deep check has produced a report", async () => {
renderPanel();
expect(screen.queryByText("AI review card")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /Run deep check/ }));
await waitFor(() => expect(screen.getByText("AI review card")).toBeTruthy());
});
it("passes a pasted job description through to the check", async () => {
renderPanel();
fireEvent.change(screen.getByLabelText(/Job description/), { target: { value: "Kubernetes" } });
fireEvent.click(screen.getByRole("button", { name: /Run deep check/ }));
await waitFor(() =>
expect(deepCheckMocks.runAtsCheck).toHaveBeenCalledWith(expect.any(File), { jobDescription: "Kubernetes" }),
);
});
it("says so plainly when the check cannot finish", async () => {
deepCheckMocks.createResumePdfBlob.mockRejectedValueOnce(new Error("render failed"));
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Run deep check/ }));
await waitFor(() => expect(screen.getByText(/could not finish/)).toBeTruthy());
});
});
describe("navigating to a finding", () => {
// The jump switches editor mode first and scrolls on the next frame, once the panel has rendered.
beforeEach(() => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 0;
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const stubElement = (id: string) => {
const element = document.createElement("div");
element.id = id;
element.scrollIntoView = vi.fn();
document.body.append(element);
return element;
};
it("scrolls to the item a finding belongs to, not the section header", () => {
const section = stubElement("sidebar-experience");
const item = stubElement("resume-item-exp-1");
resumeState.data = makeResume((data) => {
data.sections.experience.items = [experienceItem({ period: "nonsense" })];
});
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Experience/ }));
expect(item.scrollIntoView).toHaveBeenCalled();
expect(section.scrollIntoView).not.toHaveBeenCalled();
});
it("sends two findings in the same section to different items", () => {
const first = stubElement("resume-item-exp-1");
const second = stubElement("resume-item-exp-2");
resumeState.data = makeResume((data) => {
data.sections.experience.items = [
experienceItem({ id: "exp-1", period: "nonsense" }),
experienceItem({ id: "exp-2", period: "gibberish" }),
];
});
renderPanel();
const buttons = screen.getAllByRole("button", { name: /Experience · Item/ });
fireEvent.click(buttons[0] as HTMLElement);
fireEvent.click(buttons[1] as HTMLElement);
expect(first.scrollIntoView).toHaveBeenCalledOnce();
expect(second.scrollIntoView).toHaveBeenCalledOnce();
});
it("falls back to the section header when the item is not mounted", () => {
const section = stubElement("sidebar-experience");
resumeState.data = makeResume((data) => {
data.sections.experience.items = [experienceItem({ period: "nonsense" })];
});
renderPanel();
fireEvent.click(screen.getByRole("button", { name: /Experience/ }));
expect(section.scrollIntoView).toHaveBeenCalled();
});
});
@@ -1,289 +0,0 @@
import type { AtsFinding, AtsSeverity } from "@reactive-resume/resume/ats";
import type { CSSProperties } from "react";
import type { AtsCheckResult } from "@/features/ats-checker/run-ats-check";
import type { SidebarSection } from "@/libs/resume/section";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { ArrowRightIcon, CheckCircleIcon, MagnifyingGlassIcon, WarningCircleIcon } from "@phosphor-icons/react";
import { useCallback, useMemo, useState } from "react";
import { match } from "ts-pattern";
import { lintResumeForAts } from "@reactive-resume/resume/ats";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { Separator } from "@reactive-resume/ui/components/separator";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { cn } from "@reactive-resume/utils/style";
import { AiReviewCard } from "@/features/ats-checker/ai-review/ai-review-card";
import { AtsPdfReportView } from "@/features/ats-checker/report/report-view";
import { blobToPdfFile, runAtsCheck } from "@/features/ats-checker/run-ats-check";
import { useResumeData } from "@/features/resume/builder/draft";
import { getScrollBehavior, revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { getAtsFindingLocation, getAtsFindingMessage, getAtsFindingTarget } from "@/libs/resume/ats";
import { useEditorMode } from "../../../-components/use-editor-mode";
import { SectionBase } from "../shared/section-base";
const MAX_JOB_DESCRIPTION_CHARS = 20_000;
const SEVERITIES = ["error", "warning", "info"] as const;
// Design findings scroll to the Design group that fixes them.
const DESIGN_GROUPS: Partial<Record<SidebarSection, string>> = { typography: "type", page: "page", layout: "template" };
function severityDotClass(severity: AtsSeverity) {
return match(severity)
.with("error", () => "bg-danger")
.with("warning", () => "bg-warn")
.with("info", () => "bg-info-text")
.exhaustive();
}
function severityLabel(severity: AtsSeverity) {
return match(severity)
.with("error", () => t`Error`)
.with("warning", () => t`Warning`)
.with("info", () => t`Note`)
.exhaustive();
}
type SeverityCountProps = {
severity: AtsSeverity;
count: number;
};
function SeverityCount({ severity, count }: SeverityCountProps) {
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
<span className={cn("size-2 shrink-0 rounded-full", severityDotClass(severity))} aria-hidden />
{match(severity)
.with("error", () => <Plural value={count} one="# error" other="# errors" />)
.with("warning", () => <Plural value={count} one="# warning" other="# warnings" />)
.with("info", () => <Plural value={count} one="# note" other="# notes" />)
.exhaustive()}
</span>
);
}
type AtsFindingRowProps = {
finding: AtsFinding;
onJump: (pointer: string) => void;
};
function AtsFindingRow({ finding, onJump }: AtsFindingRowProps) {
const message = getAtsFindingMessage(finding.code);
const location = getAtsFindingLocation(finding.pointer);
return (
<li className="space-y-2 rounded-md border bg-card p-3">
<div className="flex items-start gap-2">
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", severityDotClass(finding.severity))} aria-hidden />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-sm leading-snug">{message.title}</p>
<p className="text-muted-foreground text-xs leading-normal">{message.action}</p>
</div>
<Badge variant="neutral" className="shrink-0">
{severityLabel(finding.severity)}
</Badge>
</div>
{location && (
<Button size="sm" variant="ghost" className="h-7 gap-1.5 px-2 text-xs" onClick={() => onJump(finding.pointer)}>
{location}
<ArrowRightIcon />
</Button>
)}
</li>
);
}
function LiveLintTier() {
const data = useResumeData();
const [, setMode] = useEditorMode();
const report = useMemo(() => (data ? lintResumeForAts(data) : null), [data]);
const onJump = useCallback(
(pointer: string) => {
const target = getAtsFindingTarget(pointer, data);
if (!target) return;
// Content findings open the entry in Write; design findings open their section in Design.
if (target.side === "left") {
const customIndex = target.section === "custom" ? Number(pointer.split("/")[2]) : -1;
const sectionId = customIndex >= 0 ? data?.customSections[customIndex]?.id : target.section;
if (!sectionId) return;
setMode("write");
revealSelectionInPanel(
sectionId === "basics" || sectionId === "picture"
? { kind: "header" }
: target.itemId
? { kind: "item", sectionId, itemId: target.itemId }
: { kind: "section", sectionId },
);
return;
}
setMode("design");
requestAnimationFrame(() => {
document
.getElementById(`design-${DESIGN_GROUPS[target.section] ?? "template"}`)
?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
});
},
[data, setMode],
);
if (!report) return null;
const { counts, findings, passedRules, totalRules } = report;
return (
<div className="space-y-3">
<div className="space-y-3 rounded-md border bg-card p-3">
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
These checks run as you type and never leave your browser. They test whether software can read your resume,
not how well it is written.
</Trans>
</p>
<div className="space-y-2">
<p className="font-medium text-sm leading-none">
<Trans>
{passedRules} of {totalRules} checks passed
</Trans>
</p>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full translate-x-(--fill) rounded-full bg-primary transition-[translate] duration-200 ease-out-strong rtl:-translate-x-(--fill)"
style={{ "--fill": `${(passedRules / totalRules) * 100 - 100}%` } as CSSProperties}
/>
</div>
</div>
{findings.length > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{SEVERITIES.filter((severity) => counts[severity] > 0).map((severity) => (
<SeverityCount key={severity} severity={severity} count={counts[severity]} />
))}
</div>
)}
</div>
{findings.length === 0 ? (
<div className="flex items-center gap-3 rounded-md border border-dashed p-3">
<CheckCircleIcon className="size-5 shrink-0 text-accent-text" />
<p className="text-muted-foreground text-sm leading-normal">
<Trans>Every check passed. Nothing here should stop a parser from reading your resume.</Trans>
</p>
</div>
) : (
<ul className="space-y-2">
{findings.map((finding) => (
<AtsFindingRow key={`${finding.code}:${finding.pointer}`} finding={finding} onJump={onJump} />
))}
</ul>
)}
</div>
);
}
/**
* Tier two and three: render the current resume to a PDF and run the same file-level check the
* standalone page runs, then offer the optional AI review on top of the result.
*
* The render happens in this tab and the report is derived from those bytes, so the deep check
* measures the file a recruiter would actually receive rather than the draft that produced it.
*/
function DeepCheckTier() {
const data = useResumeData();
const [jobDescription, setJobDescription] = useState("");
const [result, setResult] = useState<AtsCheckResult | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const onRun = async () => {
if (!data || isRunning) return;
setIsRunning(true);
setError(null);
try {
const blob = await createResumePdfBlob(data);
setResult(await runAtsCheck(blobToPdfFile(blob, "resume.pdf"), { jobDescription }));
} catch {
setError(t`The deep check could not finish. Please try again.`);
} finally {
setIsRunning(false);
}
};
if (!data) return null;
return (
<div className="space-y-3">
<div className="space-y-3 rounded-md border bg-card p-3">
<div className="space-y-1">
<p className="font-medium text-sm leading-none">
<Trans>Deep check</Trans>
</p>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Renders your current resume to PDF and runs the full file check in your browser. Nothing is uploaded.
</Trans>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="builder-ats-job-description" className="text-xs">
<Trans>Job description (optional)</Trans>
</Label>
<Textarea
id="builder-ats-job-description"
rows={3}
maxLength={MAX_JOB_DESCRIPTION_CHARS}
value={jobDescription}
placeholder={t`Paste a posting to see which of its terms already appear in your resume.`}
onChange={(event) => setJobDescription(event.target.value)}
/>
</div>
<Button size="sm" disabled={isRunning} onClick={() => void onRun()}>
{isRunning ? <Spinner /> : <MagnifyingGlassIcon />}
{isRunning ? <Trans>Checking…</Trans> : result ? <Trans>Run again</Trans> : <Trans>Run deep check</Trans>}
</Button>
</div>
{error && (
<Alert variant="error">
<WarningCircleIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{result && (
<>
<AtsPdfReportView report={result.report} />
<AiReviewCard report={result.report} fullText={result.fullText} jobDescription={jobDescription} />
</>
)}
</div>
);
}
export function AtsCheckSectionBuilder() {
return (
<SectionBase type="ats" className="space-y-4">
<LiveLintTier />
<Separator />
<DeepCheckTier />
</SectionBase>
);
}