Release v5.2.9 (#3382)

* feat(ats): add ATS checker and replace resume analysis

Adds a public, browser-only ATS checker at /ats-checker and an ATS Check
section in the builder's right sidebar. PDFs are parsed locally: text
extraction, reading order, contact and date recovery, section detection,
and file-level readability are scored deterministically, with evidence
cited per finding and skipped checks reported rather than counted as
passes.

Removes the AI-scored resume analysis it supersedes: the resume_analysis
table (dropped via migration), the get_resume_analysis MCP tool, and
POST /ai/analyze-resume. The replacement, POST /ai/ats-review, reviews
extracted resume text and returns qualitative feedback with no score.

Also bumps the version to 5.2.9 and adds the changelog entry.

* chore(deps): bump workspace dependencies

* fix(ats-checker): keep negation inside each 'what this does not do' bullet

The three bullets were bare fragments whose negation came from the
section heading, which translators never see. A dozen locales rendered
them as affirmative assertions or imperatives, so the page claimed the
checker enforces a one-page rule and predicts rejection -- the opposite
of the source, and directly contradicted by the sentence beside it.

Each bullet now carries its own negation, so the polarity cannot be
lost in translation. Re-extracted and refilled across all 53 target
locales.
This commit is contained in:
Amruth Pillai
2026-08-27 03:37:01 +02:00
committed by GitHub
parent e065a10824
commit c288675b16
183 changed files with 72503 additions and 5658 deletions
+25 -25
View File
@@ -18,25 +18,25 @@
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.41",
"@ai-sdk/cerebras": "^3.0.35",
"@ai-sdk/cohere": "^4.0.29",
"@ai-sdk/deepseek": "^3.0.31",
"@ai-sdk/fireworks": "^3.0.38",
"@ai-sdk/google": "^4.0.50",
"@ai-sdk/groq": "^4.0.30",
"@ai-sdk/mistral": "^4.0.32",
"@ai-sdk/openai": "^4.0.46",
"@ai-sdk/openai-compatible": "^3.0.35",
"@ai-sdk/perplexity": "^4.0.31",
"@ai-sdk/togetherai": "^3.0.36",
"@ai-sdk/xai": "^4.0.43",
"@aws-sdk/client-s3": "^3.1116.0",
"@better-auth/api-key": "^1.7.1",
"@better-auth/drizzle-adapter": "^1.7.1",
"@better-auth/infra": "^0.4.2",
"@better-auth/oauth-provider": "^1.7.1",
"@better-auth/passkey": "^1.7.1",
"@ai-sdk/anthropic": "^4.0.44",
"@ai-sdk/cerebras": "^3.0.39",
"@ai-sdk/cohere": "^4.0.33",
"@ai-sdk/deepseek": "^3.0.35",
"@ai-sdk/fireworks": "^3.0.42",
"@ai-sdk/google": "^4.0.54",
"@ai-sdk/groq": "^4.0.33",
"@ai-sdk/mistral": "^4.0.35",
"@ai-sdk/openai": "^4.0.50",
"@ai-sdk/openai-compatible": "^3.0.39",
"@ai-sdk/perplexity": "^4.0.34",
"@ai-sdk/togetherai": "^3.0.40",
"@ai-sdk/xai": "^4.0.47",
"@aws-sdk/client-s3": "^3.1119.0",
"@better-auth/api-key": "^1.7.2",
"@better-auth/drizzle-adapter": "^1.7.2",
"@better-auth/infra": "^0.4.3",
"@better-auth/oauth-provider": "^1.7.2",
"@better-auth/passkey": "^1.7.2",
"@bramus/specificity": "^2.4.2",
"@hono/node-server": "^2.1.1",
"@modelcontextprotocol/sdk": "^1.30.0",
@@ -57,9 +57,9 @@
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^7.0.77",
"ai": "^7.0.83",
"bcrypt": "^6.0.0",
"better-auth": "1.7.1",
"better-auth": "1.7.2",
"cjk-regex": "^3.4.0",
"css-tree": "^3.2.1",
"deepmerge-ts": "^8.0.2",
@@ -67,7 +67,7 @@
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.51.0",
"fast-json-patch": "^3.1.1",
"hono": "^4.13.4",
"hono": "^4.13.5",
"jsonrepair": "^3.15.0",
"node-html-parser": "^9.0.1",
"nodemailer": "^9.0.5",
@@ -75,10 +75,10 @@
"pg": "^8.23.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.8",
"react-email": "^6.9.2",
"react-email": "^6.9.3",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.35.3",
"sharp": "^0.35.4",
"tokenx": "^2.1.0",
"ts-pattern": "^5.9.0",
"unique-names-generator": "^4.7.1",
@@ -87,7 +87,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.2.0",
"@types/node": "^26.4.0",
"@types/pg": "^8.23.1",
"@types/react": "^19.2.18",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
+1 -1
View File
@@ -54,7 +54,7 @@ export function createMcpServer(request: Request) {
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
].join(" "),
},
);
+1
View File
@@ -33,6 +33,7 @@ describe("SEO static endpoints", () => {
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
expect(text).toContain("<loc>https://app.example.com/</loc>");
expect(text).toContain("<loc>https://app.example.com/ats-checker</loc>");
expect(text).not.toContain("docs.rxresu.me");
expect(text).not.toContain("/auth");
expect(text).not.toContain("/dashboard");
+3
View File
@@ -42,6 +42,9 @@ export function handleSitemap(options?: StaticSeoOptions) {
" <url>",
` <loc>${baseUrl}/</loc>`,
" </url>",
" <url>",
` <loc>${baseUrl}/ats-checker</loc>`,
" </url>",
"</urlset>",
"",
].join("\n");
+41
View File
@@ -89,6 +89,47 @@ describe("web app fallback classification", () => {
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
});
describe("the ATS checker page", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
it("serves an indexable shell rather than a 404", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const response = await handleWebApp(new Request("https://example.com/ats-checker"));
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
expect(response.headers.get("X-Robots-Tag")).toBeNull();
});
it("replaces the shell metadata with the checker's own", async () => {
vi.mocked(fs.readFile).mockResolvedValue(shell);
const html = await (await handleWebApp(new Request("https://example.com/ats-checker"))).text();
expect(html).toContain("<title>ATS Checker - Reactive Resume</title>");
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/ats-checker">');
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/ats-checker.png">');
expect(html).toContain('id="ats-checker-structured-data"');
expect(html).not.toContain("Marketing copy.");
});
it("answers HEAD without a body", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker", { method: "HEAD" }));
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
});
it("does not treat the checker path as a public resume owner", async () => {
const response = await handleWebApp(new Request("https://example.com/ats-checker/anything"));
expect(response.status).toBe(404);
expect(mocks.getPublicResumeSocialMeta).not.toHaveBeenCalled();
});
});
describe("public resume social cards", () => {
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
+57 -1
View File
@@ -20,6 +20,13 @@ function resolveWebDistPath() {
const staticRoot = resolveWebDistPath();
const indexHtmlPath = `${staticRoot}/index.html`;
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
/**
* Marketing pages the SPA owns that search engines should index.
*
* Without an entry here the fallback below returns 404 for the path in production — the dev Vite
* server serves the shell for anything, so this failure only ever shows up once deployed.
*/
const indexableAppPaths = new Set(["/ats-checker"]);
const reservedPublicResumeSegments = new Set([
"api",
"mcp",
@@ -30,6 +37,7 @@ const reservedPublicResumeSegments = new Set([
"builder",
"agent",
"templates",
"ats-checker",
]);
function isAssetPath(pathname: string): boolean {
@@ -160,6 +168,43 @@ function createRootSeoMarkup(canonicalUrl: string) {
`;
}
const ATS_CHECKER_TITLE = "ATS Checker - Reactive Resume";
// Kept under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
const ATS_CHECKER_DESCRIPTION =
"Check whether software can read your resume PDF. Runs in your browser, so your file is never uploaded.";
function createAtsCheckerSeoMarkup(origin: string) {
const canonicalUrl = `${origin}/ats-checker`;
const imageUrl = `${origin}/opengraph/ats-checker.png`;
const structuredData = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "ATS Checker",
url: canonicalUrl,
description: ATS_CHECKER_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
isPartOf: { "@type": "WebSite", name: "Reactive Resume", url: `${origin}/` },
};
return `
<link rel="canonical" href="${canonicalUrl}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ATS_CHECKER_TITLE}">
<meta property="og:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ATS_CHECKER_TITLE}">
<meta name="twitter:description" content="${ATS_CHECKER_DESCRIPTION}">
<meta name="twitter:image" content="${imageUrl}">
<script id="ats-checker-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
`;
}
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
// HTML unescaped.
const escapeAttribute = (value: string) =>
@@ -215,7 +260,9 @@ export const serveWebDistStatic = serveStatic({
});
function getFallbackResponseHeaders(pathname: string) {
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
if (pathname === "/" || indexableAppPaths.has(pathname)) {
return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
}
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
return {
"Content-Type": "text/html; charset=UTF-8",
@@ -258,6 +305,15 @@ export async function handleWebApp(request: Request) {
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
}
if (pathname === "/ats-checker") {
const origin = new URL(env.APP_URL).origin;
const withTitle = html
.replace(/<title>[^<]*<\/title>/, `<title>${ATS_CHECKER_TITLE}</title>`)
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${ATS_CHECKER_DESCRIPTION}">`);
return new Response(withTitle.replace("</head>", `${createAtsCheckerSeoMarkup(origin)}</head>`), { headers });
}
if (isPublicResumePath(pathname)) {
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
if (resumeSeo) {
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -67
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1020 -68
View File
File diff suppressed because it is too large Load Diff
+1015 -62
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -16,12 +16,12 @@
"lingui:extract": "lingui extract --clean --overwrite"
},
"dependencies": {
"@ai-sdk/react": "^4.0.80",
"@ai-sdk/react": "^4.0.86",
"@base-ui/react": "^1.7.0",
"@better-auth/api-key": "^1.7.1",
"@better-auth/infra": "^0.4.2",
"@better-auth/oauth-provider": "^1.7.1",
"@better-auth/passkey": "^1.7.1",
"@better-auth/api-key": "^1.7.2",
"@better-auth/infra": "^0.4.3",
"@better-auth/oauth-provider": "^1.7.2",
"@better-auth/passkey": "^1.7.2",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.0",
"@codemirror/lang-css": "^6.3.1",
@@ -54,20 +54,20 @@
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-form": "^1.33.5",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.102.2",
"@tanstack/react-query": "^5.102.6",
"@tanstack/react-router": "^1.170.32",
"@tiptap/extension-color": "^3.30.3",
"@tiptap/extension-highlight": "^3.30.3",
"@tiptap/extension-text-align": "^3.30.3",
"@tiptap/extension-text-style": "^3.30.3",
"@tiptap/pm": "^3.30.3",
"@tiptap/react": "^3.30.3",
"@tiptap/starter-kit": "^3.30.3",
"@tiptap/extension-color": "^3.30.5",
"@tiptap/extension-highlight": "^3.30.5",
"@tiptap/extension-text-align": "^3.30.5",
"@tiptap/extension-text-style": "^3.30.5",
"@tiptap/pm": "^3.30.5",
"@tiptap/react": "^3.30.5",
"@tiptap/starter-kit": "^3.30.5",
"@types/js-cookie": "^3.0.6",
"@uiw/color-convert": "^2.10.3",
"@uiw/react-color-colorful": "^2.10.3",
"ai": "^7.0.77",
"better-auth": "1.7.1",
"ai": "^7.0.83",
"better-auth": "1.7.2",
"buffer": "^6.0.3",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-rc.4",
@@ -104,7 +104,7 @@
"@shadcn/helpers": "^0.2.0",
"@tanstack/devtools-vite": "^0.8.5",
"@tanstack/react-devtools": "^0.10.12",
"@tanstack/react-query-devtools": "^5.102.2",
"@tanstack/react-query-devtools": "^5.102.6",
"@tanstack/react-router-devtools": "^1.167.1",
"@tanstack/router-plugin": "^1.168.35",
"@types/babel__core": "^7.20.5",
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,161 @@
import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf";
import type { AtsAiReview } from "./ai-review-results";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
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 { getPdfFindingMessage } from "../messages";
import { AiReviewResults } from "./ai-review-results";
/** Matches the procedure's input cap; the text is trimmed here so the request is never rejected. */
const MAX_EXTRACTED_TEXT_CHARS = 50_000;
const MAX_FINDINGS = 120;
type AiReviewCardProps = {
report: PdfAtsReport;
fullText: string;
jobDescription?: string;
};
/**
* Tier three: opt-in, per run, and only after the deterministic report already exists.
*
* Everything sent is stated on the card before the button is pressed. The PDF itself never leaves
* the browser. Only the text already extracted from it travels.
*/
export function AiReviewCard({ report, fullText, jobDescription }: AiReviewCardProps) {
const { usableProviders, hasUsableProvider, isLoading } = useHasUsableAiProvider();
const [providerOverride, setProviderOverride] = useState<string | null | undefined>(undefined);
const [review, setReview] = useState<AtsAiReview | null>(null);
const aiProviderId = providerOverride ?? usableProviders[0]?.id ?? null;
const { mutate, isPending } = useMutation({
...orpc.ai.atsReview.mutationOptions(),
onSuccess: setReview,
onError: (error) => {
toast.add({
type: "error",
description: getOrpcErrorMessage(error, {
byCode: {
BAD_GATEWAY: t`Your AI provider could not be reached. Check its settings and try again.`,
BAD_REQUEST: t`The provider returned a review that could not be read. Try again.`,
PRECONDITION_FAILED: t`AI providers are unavailable until ENCRYPTION_SECRET is configured.`,
},
fallback: t`Failed to review this resume.`,
}),
});
},
});
if (isLoading) return null;
if (!hasUsableProvider) return <NoProviderState />;
const trimmedText = fullText.slice(0, MAX_EXTRACTED_TEXT_CHARS).trim();
const onRun = () => {
if (!trimmedText) return;
mutate({
...(aiProviderId ? { aiProviderId } : {}),
extractedText: trimmedText,
findings: report.findings.slice(0, MAX_FINDINGS).map((finding) => ({
code: finding.code,
severity: finding.severity,
message: getPdfFindingMessage(finding.code).title,
})),
...(jobDescription?.trim() ? { jobDescription: jobDescription.trim() } : {}),
});
};
return (
<div className="space-y-3 rounded-md border p-4">
<div className="flex items-center gap-2">
<SparkleIcon className="size-4 shrink-0 text-primary" />
<h4 className="font-semibold text-sm">
<Trans>Review the writing with AI</Trans>
</h4>
</div>
<p className="text-muted-foreground text-sm leading-normal">
<Trans>
The checks above are mechanical. This asks a language model what a reader would think of your bullets, and
suggests rewrites. It produces no score.
</Trans>
</p>
<p className="text-muted-foreground text-xs leading-normal">
{jobDescription?.trim() ? (
<Trans>
Sends the text already extracted from your PDF, plus the job description you pasted, to the AI provider you
choose below. The PDF file itself is never uploaded.
</Trans>
) : (
<Trans>
Sends the text already extracted from your PDF to the AI provider you choose below. The PDF file itself is
never uploaded.
</Trans>
)}
</p>
<div className="space-y-2">
<Label>
<Trans>AI provider</Trans>
</Label>
<AiProviderPicker
value={aiProviderId}
providers={usableProviders}
disabled={isPending}
onValueChange={setProviderOverride}
/>
</div>
<Button size="sm" disabled={isPending || !trimmedText} onClick={onRun}>
{isPending ? <Spinner /> : <SparkleIcon />}
{isPending ? <Trans>Reviewing…</Trans> : <Trans>Run AI review</Trans>}
</Button>
{review && <AiReviewResults review={review} />}
</div>
);
}
function NoProviderState() {
return (
<Alert>
<InfoIcon />
<AlertDescription className="space-y-3">
<p>
<Trans>
Connect your own AI provider to get a review of the writing. The checks above need no provider and no
account.
</Trans>
</p>
<Button
size="sm"
variant="outline"
nativeButton={false}
render={
<Link to="/dashboard/settings/integrations">
<Trans>Open Integrations Settings</Trans>
<ArrowRightIcon />
</Link>
}
/>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,133 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { match } from "ts-pattern";
import { Badge } from "@reactive-resume/ui/components/badge";
import { cn } from "@reactive-resume/utils/style";
export type AtsAiReview = RouterOutput["ai"]["atsReview"];
type Impact = AtsAiReview["suggestions"][number]["impact"];
function impactDotClass(impact: Impact) {
return match(impact)
.with("high", () => "bg-rose-600")
.with("medium", () => "bg-amber-600")
.with("low", () => "bg-emerald-600")
.exhaustive();
}
function impactLabel(impact: Impact) {
return match(impact)
.with("high", () => t`High impact`)
.with("medium", () => t`Medium impact`)
.with("low", () => t`Low impact`)
.exhaustive();
}
type AiReviewResultsProps = {
review: AtsAiReview;
};
export function AiReviewResults({ review }: AiReviewResultsProps) {
return (
<div className="space-y-3">
{review.summary && <p className="text-muted-foreground text-sm leading-normal">{review.summary}</p>}
{review.suggestions.length > 0 && (
<div className="space-y-2">
<h5 className="font-semibold text-sm">
<Trans>Suggestions</Trans>
</h5>
<ul className="space-y-2">
{review.suggestions.map((suggestion) => (
<li
key={`${suggestion.section ?? ""}:${suggestion.issue}`}
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", impactDotClass(suggestion.impact))}
aria-hidden
/>
<p className="min-w-0 flex-1 text-sm leading-snug">{suggestion.issue}</p>
<Badge variant="secondary" className="shrink-0">
{impactLabel(suggestion.impact)}
</Badge>
</div>
{suggestion.section && (
<p className="text-muted-foreground text-xs">
<Trans>In {suggestion.section}</Trans>
</p>
)}
{suggestion.rewrite && (
<p className="rounded bg-muted p-2 text-muted-foreground text-xs leading-normal">
{suggestion.rewrite}
</p>
)}
</li>
))}
</ul>
</div>
)}
{review.strengths.length > 0 && (
<div className="space-y-2">
<h5 className="font-semibold text-sm">
<Trans>Strengths</Trans>
</h5>
<ul className="list-outside list-disc space-y-1 ps-5 text-muted-foreground text-sm">
{review.strengths.map((strength) => (
<li key={strength}>{strength}</li>
))}
</ul>
</div>
)}
{review.jdAlignment && (
<div className="space-y-2 rounded-md border bg-card p-3">
<h5 className="font-semibold text-sm">
<Trans>Against the job description</Trans>
</h5>
{review.jdAlignment.verdict && (
<p className="text-muted-foreground text-sm leading-normal">{review.jdAlignment.verdict}</p>
)}
{review.jdAlignment.missingConcepts.length > 0 && (
<div className="space-y-1.5">
<p className="font-medium text-muted-foreground text-xs">
<Trans>Not evidenced in your resume</Trans>
</p>
<div className="flex flex-wrap gap-1.5">
{review.jdAlignment.missingConcepts.map((concept) => (
<Badge key={concept} variant="outline" className="font-normal">
{concept}
</Badge>
))}
</div>
</div>
)}
{review.jdAlignment.strengths.length > 0 && (
<ul className="list-outside list-disc space-y-1 ps-5 text-muted-foreground text-sm">
{review.jdAlignment.strengths.map((strength) => (
<li key={strength}>{strength}</li>
))}
</ul>
)}
</div>
)}
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
This review is a language model's opinion of your writing. It does not change the score above, and it can be
wrong. Read it as a second opinion, not a verdict.
</Trans>
</p>
</div>
);
}
@@ -0,0 +1,33 @@
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, SparkleIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Button } from "@reactive-resume/ui/components/button";
/**
* What a signed-out visitor sees below the report. The deterministic check above is complete on
* its own; this is an offer, not a paywall on the result they came for.
*/
export function LockedAiCard() {
return (
<div className="space-y-3 rounded-md border border-dashed p-4">
<div className="flex items-center gap-2">
<SparkleIcon className="size-4 shrink-0 text-muted-foreground" />
<h4 className="font-semibold text-sm">
<Trans>Want a second opinion on the writing?</Trans>
</h4>
</div>
<p className="text-muted-foreground text-sm leading-normal">
<Trans>
Sign in and connect your own AI provider to get rewrite suggestions for weak bullets. The checks above are
free and need no account.
</Trans>
</p>
<Button size="sm" variant="outline" nativeButton={false} render={<Link to="/auth/login" />}>
<Trans>Sign in</Trans>
<ArrowRightIcon />
</Button>
</div>
);
}
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
const pdfjsMock = vi.hoisted(() => {
const page = {
pageNumber: 1,
rotate: 0,
view: [0, 0, 595.28, 841.89],
commonObjs: { has: () => true, get: () => ({ name: "AAAAAA+Inter" }) },
getTextContent: vi.fn(async () => ({
items: [
{
str: "Ada Lovelace",
transform: [10, 0, 0, 10, 56, 780],
width: 60,
height: 10,
fontName: "g_d0_f1",
hasEOL: true,
},
],
})),
getAnnotations: vi.fn(async () => []),
getOperatorList: vi.fn(async () => ({ fnArray: [], argsArray: [] })),
cleanup: vi.fn(),
};
const document = {
numPages: 1,
getPage: vi.fn(async () => page),
getMetadata: vi.fn(async () => ({ info: { Producer: "Test", Language: "en-GB" } })),
getMarkInfo: vi.fn(async () => ({ Marked: true })),
};
const loadingTask = { destroy: vi.fn(), promise: Promise.resolve(document) };
return {
document,
loadingTask,
page,
legacyModule: {
getDocument: vi.fn(() => loadingTask),
GlobalWorkerOptions: {} as { workerSrc?: string },
},
};
});
vi.mock("pdfjs-dist", () => {
throw new Error("Modern pdfjs-dist runtime should not be imported by the ATS checker.");
});
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => pdfjsMock.legacyModule);
const pdfFile = (bytes: number[], name = "resume.pdf") =>
new File([new Uint8Array(bytes)], name, { type: "application/pdf" });
const PDF_HEADER = [0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37];
const { extractPdf, hasPdfMagicBytes, PdfTooLargeError, PdfUnreadableError } = await import("./extract-client");
beforeEach(() => {
pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc = undefined;
pdfjsMock.legacyModule.getDocument.mockClear();
pdfjsMock.loadingTask.destroy.mockClear();
pdfjsMock.page.getOperatorList.mockClear();
});
describe("hasPdfMagicBytes", () => {
it("accepts a real PDF header and rejects anything else", async () => {
await expect(hasPdfMagicBytes(new Blob([new Uint8Array(PDF_HEADER)]))).resolves.toBe(true);
await expect(hasPdfMagicBytes(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]))).resolves.toBe(false);
});
});
describe("extractPdf", () => {
it("loads the legacy PDF.js runtime and configures its worker", async () => {
await extractPdf(pdfFile(PDF_HEADER));
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
);
});
it("asks for the extra font properties the font checks depend on", async () => {
await extractPdf(pdfFile(PDF_HEADER));
expect(pdfjsMock.legacyModule.getDocument).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.any(Uint8Array), fontExtraProperties: true }),
);
});
it("destroys the loading task once it is done", async () => {
await extractPdf(pdfFile(PDF_HEADER));
expect(pdfjsMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
});
it("returns plain serialisable JSON", async () => {
const raw = await extractPdf(pdfFile(PDF_HEADER, "ada.pdf"));
expect(raw.file).toEqual({ name: "ada.pdf", sizeBytes: PDF_HEADER.length, magicBytesOk: true });
expect(raw.pages[0]?.items[0]?.str).toBe("Ada Lovelace");
expect(JSON.parse(JSON.stringify(raw))).toEqual(raw);
});
it("refuses a file that is not a PDF before loading PDF.js at all", async () => {
pdfjsMock.legacyModule.getDocument.mockClear();
await expect(extractPdf(pdfFile([0x50, 0x4b, 0x03, 0x04], "resume.pdf"))).rejects.toBeInstanceOf(
PdfUnreadableError,
);
expect(pdfjsMock.legacyModule.getDocument).not.toHaveBeenCalled();
});
it("refuses an oversized file before reading it into memory", async () => {
const huge = pdfFile(PDF_HEADER);
Object.defineProperty(huge, "size", { value: 40_000_000 });
await expect(extractPdf(huge)).rejects.toBeInstanceOf(PdfTooLargeError);
});
it("reports progress as it works", async () => {
const phases: string[] = [];
await extractPdf(pdfFile(PDF_HEADER), { onProgress: (progress) => phases.push(progress.phase) });
expect(phases[0]).toBe("loading");
expect(phases).toContain("text");
});
});
@@ -0,0 +1,93 @@
import type { HarvestProgress, RawExtraction } from "@reactive-resume/resume/ats-pdf";
import { HARVEST_DEFAULTS, harvestPdfDocument } from "@reactive-resume/resume/ats-pdf";
/**
* The only file in the app that talks to PDF.js for the ATS checker.
*
* Everything downstream works on the plain JSON this produces, which is what lets the analysis
* itself live in a universal package, and what would let a server or MCP adapter reuse all of it
* without a browser.
*
* The file never leaves the browser. It is read into an ArrayBuffer, handed to PDF.js, and the
* bytes are dropped when this function returns.
*/
/** Refused before PDF.js is even loaded: nothing good happens after this size in a browser tab. */
export const MAX_UPLOAD_BYTES = 25_000_000;
export type ExtractProgress = HarvestProgress | { phase: "loading"; page: 0; pageCount: 0 };
export type ExtractOptions = {
onProgress?: (progress: ExtractProgress) => void;
signal?: AbortSignal;
};
export class PdfPasswordRequiredError extends Error {
constructor() {
super("This PDF is password protected.");
this.name = "PdfPasswordRequiredError";
}
}
export class PdfTooLargeError extends Error {
constructor(readonly sizeBytes: number) {
super("This PDF is too large to check in the browser.");
this.name = "PdfTooLargeError";
}
}
export class PdfUnreadableError extends Error {
constructor(cause?: unknown) {
super("This file could not be read as a PDF.");
this.name = "PdfUnreadableError";
this.cause = cause;
}
}
const PDF_MAGIC_BYTES = [0x25, 0x50, 0x44, 0x46] as const; // "%PDF"
/** Sniffs the format from the bytes rather than trusting the extension or the reported MIME type. */
export async function hasPdfMagicBytes(file: Blob): Promise<boolean> {
const header = new Uint8Array(await file.slice(0, PDF_MAGIC_BYTES.length).arrayBuffer());
return PDF_MAGIC_BYTES.every((byte, index) => header[index] === byte);
}
function isPasswordException(error: unknown): boolean {
return typeof error === "object" && error !== null && "name" in error && error.name === "PasswordException";
}
export async function extractPdf(file: File, options: ExtractOptions = {}): Promise<RawExtraction> {
if (file.size > MAX_UPLOAD_BYTES) throw new PdfTooLargeError(file.size);
const magicBytesOk = await hasPdfMagicBytes(file);
if (!magicBytesOk) throw new PdfUnreadableError();
options.onProgress?.({ phase: "loading", page: 0, pageCount: 0 });
// Loaded lazily, and from the legacy build: the same entry point and worker configuration the
// builder's preview uses, because GlobalWorkerOptions is module-global and shared with it.
const { GlobalWorkerOptions, getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
const data = new Uint8Array(await file.arrayBuffer());
// fontExtraProperties keeps the real font objects reachable through commonObjs, which is the
// only honest source for Type 3 / invalid / non-embedded font facts.
const loadingTask = getDocument({ data, fontExtraProperties: true });
try {
const document = await loadingTask.promise;
return await harvestPdfDocument(document, {
file: { name: file.name, sizeBytes: file.size, magicBytesOk },
maxPages: HARVEST_DEFAULTS.maxPages,
...(options.onProgress ? { onProgress: options.onProgress } : {}),
...(options.signal ? { signal: options.signal } : {}),
});
} catch (error) {
if (isPasswordException(error)) throw new PdfPasswordRequiredError();
throw error;
} finally {
void loadingTask.destroy();
}
}
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { PDF_ATS_RULE_CODES } from "@reactive-resume/resume/ats-pdf";
import {
getPdfCategoryDescription,
getPdfCategoryLabel,
getPdfFindingMessage,
getPdfSeverityLabel,
getPdfSkipReasonLabel,
} from "./messages";
const CATEGORIES = ["parseability", "layout", "sections", "contact", "dates", "content"] as const;
const SEVERITIES = ["blocker", "warning", "tip"] as const;
const SKIP_REASONS = [
"no-text",
"no-operators",
"not-english",
"not-applicable",
"encrypted",
"insufficient-data",
] as const;
beforeAll(() => {
i18n.load("en", {});
i18n.activate("en");
});
describe("getPdfFindingMessage", () => {
it("covers every rule the engine can report", () => {
for (const code of PDF_ATS_RULE_CODES) {
const message = getPdfFindingMessage(code);
expect(message.title.length, code).toBeGreaterThan(0);
expect(message.action.length, code).toBeGreaterThan(0);
expect(message.title, code).not.toBe(code);
}
});
it("gives each rule its own wording", () => {
const titles = PDF_ATS_RULE_CODES.map((code) => getPdfFindingMessage(code).title);
expect(new Set(titles).size).toBe(titles.length);
});
it("never promises a rejection it cannot predict", () => {
for (const code of PDF_ATS_RULE_CODES) {
const message = getPdfFindingMessage(code);
expect(`${message.title} ${message.action}`.toLowerCase()).not.toMatch(/will be rejected|auto[- ]reject/);
}
});
});
describe("labels", () => {
it("names every category and severity", () => {
for (const category of CATEGORIES) {
expect(getPdfCategoryLabel(category).length).toBeGreaterThan(0);
expect(getPdfCategoryDescription(category).length).toBeGreaterThan(0);
}
for (const severity of SEVERITIES) expect(getPdfSeverityLabel(severity).length).toBeGreaterThan(0);
});
it("explains every reason a check can be skipped", () => {
for (const reason of SKIP_REASONS) expect(getPdfSkipReasonLabel(reason).length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,383 @@
import type { PdfCategory, PdfRuleCode, PdfSeverity, PdfSkipReason } from "@reactive-resume/resume/ats-pdf";
import { t } from "@lingui/core/macro";
import { match } from "ts-pattern";
export type PdfFindingMessage = {
title: string;
action: string;
};
/**
* Every rule code the engine can report, in the reader's language.
*
* Flat and exhaustive on purpose. `.exhaustive()` over the engine's own code union makes a new
* rule a compile error that names the missing code, so a raw code can never reach the UI, and
* one arm per code means adding a rule is one edit here, not three.
*
* House style: say what is true about the file, then what to do about it. Never claim a file will
* be rejected. No tool knows that.
*/
export function getPdfFindingMessage(code: PdfRuleCode): PdfFindingMessage {
return (
match(code)
// ---- Parseability: nothing readable came out ----
.with("NO_TEXT_LAYER", () => ({
title: t`No text could be read from this file.`,
action: t`Export the resume as a text PDF rather than as an image or a scan.`,
}))
.with("IMAGE_ONLY_DOCUMENT", () => ({
title: t`The pages are pictures, with no text behind them.`,
action: t`Export from the original document instead of scanning or screenshotting it.`,
}))
.with("GARBLED_TEXT", () => ({
title: t`The text comes out unreadable.`,
action: t`Re-export with fonts embedded, then open the file and check that the text copies out cleanly.`,
}))
.with("TYPE3_FONT", () => ({
title: t`This file uses Type 3 fonts, which carry drawings instead of characters.`,
action: t`Re-export using an ordinary TrueType or OpenType font.`,
}))
.with("INVALID_EMBEDDED_FONT", () => ({
title: t`An embedded font could not be read.`,
action: t`Re-export from the original document with fonts embedded properly.`,
}))
// ---- Parseability: the file is the wrong kind of thing ----
.with("FILE_TOO_LARGE", () => ({
title: t`This file is larger than many application forms accept.`,
action: t`Keep it under 2.5 MB, usually by removing or shrinking images.`,
}))
.with("ENCRYPTED_PDF", () => ({
title: t`This file is encrypted.`,
action: t`Save an unprotected copy, with no password and no permissions restrictions.`,
}))
.with("XFA_FORM", () => ({
title: t`This is an XFA form, so most software reads only the empty shell.`,
action: t`Print or export the form to a flat PDF before sending it.`,
}))
.with("PDF_PORTFOLIO", () => ({
title: t`This is a PDF Portfolio: the visible page is only a cover.`,
action: t`Send the resume itself rather than a portfolio wrapper.`,
}))
.with("ACROFORM_FIELDS", () => ({
title: t`This file contains form fields.`,
action: t`Flatten the form so the text is part of the page.`,
}))
// ---- Parseability: the characters came out wrong ----
.with("REPLACEMENT_CHARACTERS", () => ({
title: t`Some characters come out as question marks.`,
action: t`Re-export with fonts embedded, and check the text copies out cleanly.`,
}))
.with("PRIVATE_USE_CHARACTERS", () => ({
title: t`Some glyphs have no meaning outside this file.`,
action: t`Replace icon fonts with plain text, then re-export.`,
}))
.with("LIGATURE_CHARACTERS", () => ({
title: t`Ligatures can drop letters out of words such as "workflow".`,
action: t`Turn off discretionary ligatures when exporting.`,
}))
.with("NON_EMBEDDED_FONTS", () => ({
title: t`A font is referenced but not embedded.`,
action: t`Embed all fonts when exporting, so characters map the way you intended.`,
}))
.with("LOST_WORD_SPACING", () => ({
title: t`Words run together when the text is extracted.`,
action: t`Re-export from the original document rather than converting from another format.`,
}))
.with("SPLIT_CHARACTER_SPACING", () => ({
title: t`Text is written one character at a time.`,
action: t`Avoid heavy letter-spacing effects, then re-export.`,
}))
// ---- Parseability: what the document declares about itself ----
.with("INVISIBLE_TEXT", () => ({
title: t`This file contains text drawn invisibly.`,
action: t`If it came from a scan, that is expected. Otherwise remove it; recruiters read it as keyword stuffing.`,
}))
.with("WHITE_TEXT", () => ({
title: t`This file contains white-on-white text.`,
action: t`Delete it. Hidden keywords are treated as a deliberate attempt to game the search.`,
}))
.with("LOW_TEXT_DENSITY", () => ({
title: t`Very little text was recovered for this many pages.`,
action: t`Open the exported file and confirm you can select and copy every part of it.`,
}))
.with("HIGH_IMAGE_COVERAGE", () => ({
title: t`Images cover most of the page.`,
action: t`Keep resume content as real text rather than as pictures of text.`,
}))
.with("VERTICAL_TEXT", () => ({
title: t`Some text is rotated.`,
action: t`Lay the text out horizontally. Rotated runs are often dropped or misplaced.`,
}))
.with("ROTATED_PAGES", () => ({
title: t`A page is rotated.`,
action: t`Save the file with every page upright.`,
}))
.with("NON_STANDARD_PAGE_SIZE", () => ({
title: t`This is not a common resume page size.`,
action: t`Use A4 or US Letter so the layout survives being re-rendered.`,
}))
.with("UNTAGGED_PDF", () => ({
title: t`This file has no accessibility tags.`,
action: t`Tags give software an explicit reading order. Enable them when exporting, if your tool offers it.`,
}))
.with("MISSING_DOCUMENT_LANGUAGE", () => ({
title: t`This file declares no language.`,
action: t`Set the document language when exporting.`,
}))
.with("LARGE_FILE_SIZE", () => ({
title: t`This file is large for a resume.`,
action: t`Compress or remove large images to keep uploads quick.`,
}))
.with("TRUNCATED_ANALYSIS", () => ({
title: t`Only the first pages were checked.`,
action: t`Nothing to fix. The remaining pages were simply not covered by this report.`,
}))
// ---- Layout ----
.with("MULTI_COLUMN_LAYOUT", () => ({
title: t`This page is in columns, and the text extracts out of order.`,
action: t`Use a single column for anything that has to be read in sequence.`,
}))
.with("READING_ORDER_INVERSION", () => ({
title: t`The order the text is stored in disagrees badly with the order it reads in.`,
action: t`Re-export from a single-column layout, then copy the text out and check it reads top to bottom.`,
}))
.with("READING_ORDER_RISK", () => ({
title: t`Some lines are stored out of reading order.`,
action: t`Copy the text out of the exported file and read it through; fix anything that arrives in the wrong place.`,
}))
.with("COLUMN_GUTTER", () => ({
title: t`A full-height gap splits this page into two columns.`,
action: t`Prefer a single column, or keep the side column to short, self-contained lists.`,
}))
.with("TABLE_LIKE_LAYOUT", () => ({
title: t`Content is laid out in a grid.`,
action: t`Replace tables with plain paragraphs and lists. Cells extract in storage order, not reading order.`,
}))
.with("TEXT_IN_MARGIN_ZONE", () => ({
title: t`Text sits in the header or footer area.`,
action: t`Move real content into the body of the page; some software discards the margins.`,
}))
.with("TIGHT_LINE_SPACING", () => ({
title: t`Your lines are packed very tightly.`,
action: t`Increase line spacing so two lines are not merged into one.`,
}))
.with("SMALL_BODY_TEXT", () => ({
title: t`Your body text is very small.`,
action: t`Use at least 9pt.`,
}))
.with("MANY_DISTINCT_FONTS", () => ({
title: t`This file uses a lot of different fonts.`,
action: t`Keep to two or three families. Each extra font is another chance for one to map badly.`,
}))
.with("NARROW_PAGE_MARGINS", () => ({
title: t`Your margins are very narrow.`,
action: t`Leave at least half an inch around the page.`,
}))
.with("TEXT_OUTSIDE_PAGE", () => ({
title: t`Some text falls outside the page.`,
action: t`Move all content inside the page boundaries and re-export.`,
}))
.with("NON_STANDARD_BULLET_GLYPHS", () => ({
title: t`Your bullets are drawn with unusual characters.`,
action: t`Use your editor's normal bullet list, so stray glyphs do not land mid-sentence.`,
}))
.with("DENSE_PAGE", () => ({
title: t`This page is very densely packed.`,
action: t`Add some whitespace between sections so a person can skim it.`,
}))
.with("REPEATED_HEADER_FOOTER", () => ({
title: t`The same line repeats on every page.`,
action: t`Keep running heads short, or drop them. They repeat in the extracted text too.`,
}))
// ---- Sections ----
.with("NO_EXPERIENCE_SECTION", () => ({
title: t`No work-experience section was found.`,
action: t`Add a section headed "Experience" or "Work Experience". It is the field most systems rank on.`,
}))
.with("NO_RECOGNIZED_HEADINGS", () => ({
title: t`No conventional section headings were found.`,
action: t`Use plain headings such as "Experience", "Education" and "Skills".`,
}))
.with("FEW_SECTION_HEADINGS", () => ({
title: t`Only one recognisable section heading was found.`,
action: t`Break the resume into the usual sections so it can be segmented.`,
}))
.with("NO_EDUCATION_SECTION", () => ({
title: t`No education section was found.`,
action: t`Add a section headed "Education". Many systems have a required education field.`,
}))
.with("NO_SKILLS_SECTION", () => ({
title: t`No skills section was found.`,
action: t`List the tools and technologies you use. Recruiters search skills more than any other field.`,
}))
.with("HEADINGS_NOT_DISTINGUISHED", () => ({
title: t`Your headings look the same as your body text.`,
action: t`Make them visibly larger, bolder or capitalised.`,
}))
.with("VERY_SHORT_DOCUMENT", () => ({
title: t`Very few words were recovered for a resume.`,
action: t`Check the exported file contains everything you expect, and that all of it is selectable text.`,
}))
.with("NO_BULLET_POINTS", () => ({
title: t`No bullet lists were found.`,
action: t`Describe each role with short bullets. Solid paragraphs are harder to skim and to segment.`,
}))
.with("NO_ROLE_LINES", () => ({
title: t`No lines look like a job title paired with an employer.`,
action: t`Write each role as a clear title and company line, with the dates alongside.`,
}))
.with("NO_SUMMARY_SECTION", () => ({
title: t`There is no summary paragraph.`,
action: t`Add two or three lines at the top describing what you do.`,
}))
// ---- Contact details ----
.with("NO_EMAIL", () => ({
title: t`No email address was found.`,
action: t`Put a plain email address near the top of the first page. It is what most systems file you under.`,
}))
.with("NO_PHONE", () => ({
title: t`No phone number was found.`,
action: t`Add one with its country code; some application forms require it before you can submit.`,
}))
.with("NO_NAME_LINE", () => ({
title: t`The top of the first page does not look like a name.`,
action: t`Start the resume with your name on its own line.`,
}))
.with("CONTACT_NOT_ON_FIRST_PAGE", () => ({
title: t`Your contact details are not on the first page.`,
action: t`Move your email and phone number to the top of page one.`,
}))
.with("EMAIL_SPLIT_ACROSS_ITEMS", () => ({
title: t`Your email address is broken across pieces of text.`,
action: t`Write it as one uninterrupted piece, with no special spacing.`,
}))
.with("LINK_TEXT_URL_MISMATCH", () => ({
title: t`A link points somewhere other than the address it shows.`,
action: t`Make the visible text and the destination match.`,
}))
.with("MULTIPLE_EMAILS", () => ({
title: t`More than one email address appears.`,
action: t`Keep a single address, so nothing has to guess which one to use.`,
}))
.with("NO_PROFESSIONAL_LINK", () => ({
title: t`There is no link to a profile, portfolio or repository.`,
action: t`Add one link that shows your work.`,
}))
.with("BARE_URL_WITHOUT_PROTOCOL", () => ({
title: t`A web address is written without https://.`,
action: t`Write the full address so it is recognised as a link.`,
}))
.with("PHONE_FORMAT_UNUSUAL", () => ({
title: t`Your phone number is written in an unusual shape.`,
action: t`Use a plain international form, such as +1 555 010 1234.`,
}))
// ---- Dates ----
.with("NO_DATES_FOUND", () => ({
title: t`No dates were found.`,
action: t`Give every role a period, such as "Jan 2020 - Present", so your timeline can be reconstructed.`,
}))
.with("FEW_DATES", () => ({
title: t`Most entries have no dates.`,
action: t`Give every role and qualification a date range.`,
}))
.with("UNPARSEABLE_DATE_RANGE", () => ({
title: t`A date range is written in a form most software cannot read.`,
action: t`Use a recognised form such as "Jan 2020 - Mar 2022" or "2020 - 2022".`,
}))
.with("REVERSED_DATE_RANGE", () => ({
title: t`A date range ends before it starts.`,
action: t`Swap the start and end dates.`,
}))
.with("FUTURE_DATED_ENTRY", () => ({
title: t`A date is in the future.`,
action: t`Correct the year, or write "Present" for ongoing work.`,
}))
.with("MIXED_DATE_FORMATS", () => ({
title: t`Your dates are written several different ways.`,
action: t`Pick one format and use it everywhere.`,
}))
.with("NO_CURRENT_ROLE_MARKER", () => ({
title: t`Nothing marks a role as ongoing.`,
action: t`Write "Present" as the end date of your current role.`,
}))
.with("AMBIGUOUS_NUMERIC_DATE", () => ({
title: t`A numeric date reads differently in different countries.`,
action: t`Spell the month, as in "Mar 2022".`,
}))
// ---- Content: unscored advice ----
.with("NO_QUANTIFIED_IMPACT", () => ({
title: t`Almost none of your bullets carry a number.`,
action: t`Add figures where you have them: volumes, percentages, timeframes, team sizes.`,
}))
.with("WEAK_ACTION_VERBS", () => ({
title: t`Most bullets open with filler rather than something you did.`,
action: t`Start each one with a concrete verb: built, led, reduced, migrated.`,
}))
.with("FIRST_PERSON_PRONOUNS", () => ({
title: t`Your resume uses "I" and "my".`,
action: t`Drop the pronouns and start from the verb. That is the convention in most markets.`,
}))
.with("LONG_BULLETS", () => ({
title: t`Some bullets run long.`,
action: t`Keep them to roughly two lines, so a skim-reader finishes them.`,
}))
.with("HIGH_PAGE_COUNT", () => ({
title: t`This resume is long.`,
action: t`Length is not a defect, and there is no universal one-page rule. Keep the pages that earn their place.`,
}))
.with("EMPLOYMENT_GAP", () => ({
title: t`There is a gap between two dated roles.`,
action: t`A gap is not a problem. Add a one-line explanation if you want to; many strong resumes simply do not.`,
}))
.with("ALL_CAPS_RUNS", () => ({
title: t`Long stretches are set in capitals.`,
action: t`Reserve capitals for short headings. They slow reading and can defeat case-sensitive search.`,
}))
.with("REPEATED_PHRASES", () => ({
title: t`Many bullets start with the same phrase.`,
action: t`Vary the openings so the section does not read as boilerplate.`,
}))
.exhaustive()
);
}
export function getPdfCategoryLabel(category: PdfCategory): string {
return match(category)
.with("parseability", () => t`Readability`)
.with("layout", () => t`Layout`)
.with("sections", () => t`Sections`)
.with("contact", () => t`Contact details`)
.with("dates", () => t`Dates`)
.with("content", () => t`Writing`)
.exhaustive();
}
export function getPdfCategoryDescription(category: PdfCategory): string {
return match(category)
.with("parseability", () => t`Whether software can recover the words at all.`)
.with("layout", () => t`Whether the page geometry preserves reading order.`)
.with("sections", () => t`Whether the resume is segmented the way software expects.`)
.with("contact", () => t`Whether a recruiter can reach you.`)
.with("dates", () => t`Whether your timeline can be reconstructed.`)
.with("content", () => t`Advice for the person reading it. None of this affects the score.`)
.exhaustive();
}
export function getPdfSeverityLabel(severity: PdfSeverity): string {
return match(severity)
.with("blocker", () => t`Blocker`)
.with("warning", () => t`Warning`)
.with("tip", () => t`Tip`)
.exhaustive();
}
export function getPdfSkipReasonLabel(reason: PdfSkipReason): string {
return match(reason)
.with("no-text", () => t`No text could be read from this file.`)
.with("no-operators", () => t`This file's page contents could not be inspected in time.`)
.with("not-english", () => t`This check only runs on resumes written in English.`)
.with("not-applicable", () => t`This check does not apply to this file.`)
.with("encrypted", () => t`This file is encrypted.`)
.with("insufficient-data", () => t`There was not enough information to run this check.`)
.exhaustive();
}
@@ -0,0 +1,100 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { m } from "motion/react";
/**
* The page's one visual argument: the same resume, twice. On the left, the page a person sees. On
* the right, the plain text a parser gets back from it.
*
* Both halves are real. The left is the template preview this app already ships; the right is the
* text an extractor recovers from that exact file, labelled with the field each line lands in.
* Nothing here imitates a product screenshot.
*
* Motion plays once on mount rather than looping: this sits directly above the upload control, and
* a permanent animation beside a call to action competes with it. `MotionConfig reducedMotion="user"`
* in the root route degrades the sweep for anyone who has asked for less movement.
*/
const PREVIEW_TEMPLATE = "onyx";
/**
* Read off the top of `public/templates/jpg/onyx.jpg`, so the two halves genuinely correspond, and
* capped at six rows so the header stays short enough to keep the upload control in view.
*/
const EXTRACTED_FIELDS = [
{ label: () => t`Name`, value: "Marcus Chen" },
{ label: () => t`Headline`, value: "Frontend Engineer & Interactive Developer" },
{ label: () => t`Location`, value: "London, UK" },
{ label: () => t`Phone`, value: "+1 (555) 291-4756" },
{ label: () => t`Sections`, value: "Summary, Skills, Experience" },
{ label: () => t`Dates`, value: "March 2022 - Present" },
] as const;
const SWEEP_SECONDS = 1.8;
export function ParsePreview() {
return (
<figure className="m-0 overflow-hidden rounded-lg border bg-card">
<div className="grid grid-cols-2 divide-x divide-border border-b">
<Caption>
<Trans>What a person sees</Trans>
</Caption>
<Caption>
<Trans>What a parser reads</Trans>
</Caption>
</div>
<div className="grid h-64 grid-cols-2 divide-x divide-border">
<div className="relative isolate overflow-hidden bg-muted/40">
<img
loading="lazy"
decoding="async"
width={520}
height={735}
src={`/templates/jpg/${PREVIEW_TEMPLATE}.jpg`}
alt={t`A resume laid out for a human reader, with a photo, headings and columns`}
className="h-full w-full object-cover object-top"
/>
{/* The sweep that ties the two halves together. */}
<m.div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-12 bg-linear-to-b from-transparent via-primary/30 to-transparent"
initial={{ y: "-100%", opacity: 0 }}
animate={{ y: ["-100%", "540%"], opacity: [0, 1, 1, 0] }}
transition={{ duration: SWEEP_SECONDS, ease: "easeInOut", times: [0, 0.1, 0.85, 1] }}
/>
</div>
<ol className="grid grid-rows-6 divide-y divide-border">
{EXTRACTED_FIELDS.map((field, index) => (
<m.li
key={field.value}
className="flex min-w-0 flex-col justify-center gap-0.5 px-3"
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.3,
// Each line lands as the sweep passes the band it came from.
delay: 0.2 + index * (SWEEP_SECONDS / (EXTRACTED_FIELDS.length + 2)),
}}
>
<span className="font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wider">
{field.label()}
</span>
<span className="truncate font-mono text-foreground text-xs">{field.value}</span>
</m.li>
))}
</ol>
</div>
<figcaption className="border-t px-3 py-2 text-muted-foreground text-xs leading-normal">
<Trans>A clean single-column export keeps every fact. Columns and images are where they get lost.</Trans>
</figcaption>
</figure>
);
}
function Caption({ children }: { children: React.ReactNode }) {
return <p className="px-3 py-2 font-medium text-muted-foreground text-xs">{children}</p>;
}
@@ -0,0 +1,54 @@
import type { PdfFinding, PdfSeverity } from "@reactive-resume/resume/ats-pdf";
import { match } from "ts-pattern";
import { Badge } from "@reactive-resume/ui/components/badge";
import { cn } from "@reactive-resume/utils/style";
import { getPdfFindingMessage, getPdfSeverityLabel } from "../messages";
function severityDotClass(severity: PdfSeverity) {
return match(severity)
.with("blocker", () => "bg-rose-600")
.with("warning", () => "bg-amber-600")
.with("tip", () => "bg-sky-600")
.exhaustive();
}
type FindingRowProps = {
finding: PdfFinding;
};
export function FindingRow({ finding }: FindingRowProps) {
const message = getPdfFindingMessage(finding.code);
const evidence = finding.evidence;
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="secondary" className="shrink-0">
{getPdfSeverityLabel(finding.severity)}
</Badge>
</div>
{(evidence?.snippet || evidence?.page !== undefined) && (
<div className="flex flex-wrap items-center gap-2">
{evidence.snippet && (
<code className="min-w-0 truncate rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
{evidence.snippet}
</code>
)}
{evidence.page !== undefined && (
<Badge variant="outline" className="shrink-0 font-normal text-xs">
Page {evidence.page}
</Badge>
)}
</div>
)}
</li>
);
}
@@ -0,0 +1,89 @@
import type { JdMatchReport } from "@reactive-resume/resume/ats-pdf";
import { Trans } from "@lingui/react/macro";
import { Badge } from "@reactive-resume/ui/components/badge";
type JdCoverageProps = {
jd: JdMatchReport;
};
/**
* Keyword coverage, reported as a count rather than a percentage of anything.
*
* Deliberately kept apart from the score: how well a resume matches one posting's vocabulary is a
* different question from whether software can read the file, and blending them would make both
* numbers mean less.
*/
export function JdCoverage({ jd }: JdCoverageProps) {
if (jd.totalTerms === 0) {
return (
<p className="rounded-md border border-dashed p-3 text-muted-foreground text-xs leading-normal">
<Trans>
No specific terms could be pulled out of that job description, so it may be mostly boilerplate. Paste the
requirements section for a more useful comparison.
</Trans>
</p>
);
}
const matched = jd.terms.filter((term) => term.resumeCount > 0);
const missing = jd.terms.filter((term) => term.resumeCount === 0);
return (
<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>
{jd.matchedCount} of {jd.totalTerms} terms found
</Trans>
</p>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>Counted separately from the parse score. Coverage is not a prediction of anything.</Trans>
</p>
</div>
{missing.length > 0 && (
<div className="space-y-1.5">
<p className="font-medium text-muted-foreground text-xs">
<Trans>Not in your resume</Trans>
</p>
<div className="flex flex-wrap gap-1.5">
{missing.map((term) => (
<Badge key={term.term} variant="outline" className="font-normal">
{term.term}
</Badge>
))}
</div>
</div>
)}
{matched.length > 0 && (
<div className="space-y-1.5">
<p className="font-medium text-muted-foreground text-xs">
<Trans>Already covered</Trans>
</p>
<div className="flex flex-wrap gap-1.5">
{matched.map((term) => (
<Badge key={term.term} variant="secondary" className="font-normal">
{term.term}
</Badge>
))}
</div>
</div>
)}
{jd.stuffedTerms.length > 0 && (
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Repeated far more often than the posting itself uses them: {jd.stuffedTerms.join(", ")}. Recruiters notice.
</Trans>
</p>
)}
{jd.documentHasHiddenText && (
<p className="text-muted-foreground text-xs leading-normal">
<Trans>This file contains text a reader cannot see, so some of these matches may be against it.</Trans>
</p>
)}
</div>
);
}
@@ -0,0 +1,163 @@
import type { PdfAtsReport, PdfCategory, PdfCategoryScore } from "@reactive-resume/resume/ats-pdf";
import { Trans } from "@lingui/react/macro";
import { CheckCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
import { Badge } from "@reactive-resume/ui/components/badge";
import { cn } from "@reactive-resume/utils/style";
import { getPdfCategoryDescription, getPdfCategoryLabel } from "../messages";
import { FindingRow } from "./finding-row";
import { JdCoverage } from "./jd-coverage";
import { ScoreHeader } from "./score-header";
type ReportViewProps = {
report: PdfAtsReport;
className?: string;
};
function categoryTone(score: number) {
if (score >= 80) return "text-emerald-600";
if (score >= 60) return "text-amber-600";
return "text-rose-600";
}
/**
* The whole report, built compact-first so the same component serves the narrow builder sidebar
* and the full-width page without a second implementation.
*/
export function AtsPdfReportView({ report, className }: ReportViewProps) {
const findingsByCategory = useMemo(() => {
const grouped = new Map<PdfCategory, PdfAtsReport["findings"]>();
for (const finding of report.findings) {
grouped.set(finding.category, [...(grouped.get(finding.category) ?? []), finding]);
}
return grouped;
}, [report.findings]);
const openCategories = report.categories
.filter((category) => (findingsByCategory.get(category.category)?.length ?? 0) > 0)
.map((category) => category.category);
return (
<div className={cn("space-y-4", className)}>
<ScoreHeader report={report} />
{report.document.truncated && (
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Only the first {report.document.pageCount} pages were checked, so this report does not cover the whole file.
</Trans>
</p>
)}
<Accordion defaultValue={openCategories} className="rounded-md border px-3">
{report.categories.map((category) => (
<CategorySection
key={category.category}
category={category}
findings={findingsByCategory.get(category.category) ?? []}
/>
))}
<WritingSection tips={report.tips} />
</Accordion>
{report.jd && <JdCoverage jd={report.jd} />}
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
This measures how faithfully software can extract this file's text. It does not predict whether an application
will be rejected. No tool can. Your file was read in this browser and never uploaded.
</Trans>
</p>
</div>
);
}
type CategorySectionProps = {
category: PdfCategoryScore;
findings: PdfAtsReport["findings"];
};
function CategorySection({ category, findings }: CategorySectionProps) {
return (
<AccordionItem value={category.category}>
<AccordionTrigger>
<span className="flex min-w-0 flex-1 items-center gap-2 pe-2">
<span className="min-w-0 truncate">{getPdfCategoryLabel(category.category)}</span>
<Badge variant="secondary" className="shrink-0 tabular-nums">
<span className={categoryTone(category.score)}>{category.score}</span>
</Badge>
{findings.length > 0 && (
<span className="shrink-0 font-normal text-muted-foreground text-xs">
<Trans>{findings.length} to fix</Trans>
</span>
)}
</span>
</AccordionTrigger>
<AccordionContent className="space-y-3">
<p className="text-muted-foreground text-xs leading-normal">
{getPdfCategoryDescription(category.category)}{" "}
<Trans>
{category.passedChecks} of {category.applicableChecks} applicable checks passed.
</Trans>
</p>
{findings.length === 0 ? (
<div className="flex items-center gap-2 rounded-md border border-dashed p-2.5">
<CheckCircleIcon className="size-4 shrink-0 text-emerald-600" />
<span className="text-muted-foreground text-xs leading-normal">
<Trans>Nothing to fix here.</Trans>
</span>
</div>
) : (
<ul className="space-y-2">
{findings.map((finding) => (
<FindingRow key={finding.code} finding={finding} />
))}
</ul>
)}
</AccordionContent>
</AccordionItem>
);
}
type WritingSectionProps = {
tips: PdfAtsReport["tips"];
};
/** Unscored advice, kept visually separate so nobody reads it as part of the number above. */
function WritingSection({ tips }: WritingSectionProps) {
return (
<AccordionItem value="content">
<AccordionTrigger>
<span className="flex min-w-0 flex-1 items-center gap-2 pe-2">
<span className="min-w-0 truncate">{getPdfCategoryLabel("content")}</span>
<Badge variant="outline" className="shrink-0 font-normal">
<Trans>Not scored</Trans>
</Badge>
</span>
</AccordionTrigger>
<AccordionContent className="space-y-3">
<p className="text-muted-foreground text-xs leading-normal">{getPdfCategoryDescription("content")}</p>
{tips.length === 0 ? (
<div className="flex items-center gap-2 rounded-md border border-dashed p-2.5">
<CheckCircleIcon className="size-4 shrink-0 text-emerald-600" />
<span className="text-muted-foreground text-xs leading-normal">
<Trans>Nothing to suggest.</Trans>
</span>
</div>
) : (
<ul className="space-y-2">
{tips.map((tip) => (
<FindingRow key={tip.code} finding={tip} />
))}
</ul>
)}
</AccordionContent>
</AccordionItem>
);
}
@@ -0,0 +1,72 @@
import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf";
import { Trans } from "@lingui/react/macro";
import { InfoIcon } from "@phosphor-icons/react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { cn } from "@reactive-resume/utils/style";
import { getPdfFindingMessage } from "../messages";
function scoreTone(score: number) {
if (score >= 80) return { text: "text-emerald-600", bar: "bg-emerald-600" };
if (score >= 60) return { text: "text-amber-600", bar: "bg-amber-600" };
return { text: "text-rose-600", bar: "bg-rose-600" };
}
type ScoreHeaderProps = {
report: PdfAtsReport;
};
export function ScoreHeader({ report }: ScoreHeaderProps) {
const tone = scoreTone(report.score);
const [cap] = report.cappedBy;
return (
<div className="space-y-3 rounded-md border bg-card p-3">
<div className="flex items-baseline gap-2">
<span className={cn("font-bold text-4xl tabular-nums leading-none", tone.text)}>{report.score}</span>
<span className="text-muted-foreground text-sm">
<Trans>out of 100</Trans>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-[width] duration-300", tone.bar)}
style={{ width: `${report.score}%` }}
/>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground text-xs">
<span>
<Trans>
{report.passedChecks} of {report.applicableChecks} applicable checks passed
</Trans>
</span>
{report.skippedChecks > 0 && (
<Tooltip>
<TooltipTrigger
render={
<span className="inline-flex cursor-help items-center gap-1 underline decoration-dotted underline-offset-2">
<Trans>{report.skippedChecks} skipped</Trans>
<InfoIcon className="size-3" />
</span>
}
/>
<TooltipContent side="bottom" className="max-w-64">
<Trans>
Some checks need information this file does not carry: page contents that could not be inspected, or
text in a language these checks do not cover. They are counted as neither passed nor failed.
</Trans>
</TooltipContent>
</Tooltip>
)}
</div>
{cap && (
<p className="rounded-md bg-muted/60 p-2 text-muted-foreground text-xs leading-normal">
<Trans>The score is capped because of a blocking problem: {getPdfFindingMessage(cap).title}</Trans>
</p>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf";
import type { ExtractProgress } from "./extract-client";
import { extractPdf } from "./extract-client";
export type AtsCheckResult = {
report: PdfAtsReport;
/** Extracted text in reading order. Kept for the optional AI tier; never sent anywhere on its own. */
fullText: string;
};
export type RunAtsCheckOptions = {
jobDescription?: string;
onProgress?: (progress: ExtractProgress) => void;
signal?: AbortSignal;
};
/**
* The whole deterministic check, end to end, in the browser.
*
* The engine is imported lazily alongside PDF.js so neither lands in the initial bundle: a visitor
* who never uploads a file never downloads either.
*/
export async function runAtsCheck(file: File, options: RunAtsCheckOptions = {}): Promise<AtsCheckResult> {
const raw = await extractPdf(file, {
...(options.onProgress ? { onProgress: options.onProgress } : {}),
...(options.signal ? { signal: options.signal } : {}),
});
const { analyzePdfResume, buildExtractedDocument } = await import("@reactive-resume/resume/ats-pdf");
const report = analyzePdfResume(raw, {
...(options.jobDescription?.trim() ? { jobDescription: options.jobDescription } : {}),
});
return { report, fullText: buildExtractedDocument(raw).fullText };
}
/** Wraps a rendered PDF blob so the builder's deep check runs through exactly the same path. */
export function blobToPdfFile(blob: Blob, name: string): File {
return new File([blob], name.endsWith(".pdf") ? name : `${name}.pdf`, { type: "application/pdf" });
}
+135
View File
@@ -0,0 +1,135 @@
import type { ReactNode } from "react";
import type { ExtractProgress } from "./extract-client";
import type { AtsCheckResult } from "./run-ats-check";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowClockwiseIcon, WarningCircleIcon } from "@phosphor-icons/react";
import { useCallback, useRef, useState } from "react";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { PdfPasswordRequiredError, PdfTooLargeError, PdfUnreadableError } from "./extract-client";
import { AtsPdfReportView } from "./report/report-view";
import { runAtsCheck } from "./run-ats-check";
import { AtsUploader } from "./uploader";
const MAX_JOB_DESCRIPTION_CHARS = 20_000;
function progressLabel(progress: ExtractProgress | null): string {
if (!progress || progress.phase === "loading") return t`Opening the file…`;
if (progress.phase === "text") return t`Reading page ${progress.page} of ${progress.pageCount}…`;
return t`Inspecting page ${progress.page} of ${progress.pageCount}…`;
}
function errorMessage(error: unknown): string {
if (error instanceof PdfPasswordRequiredError) {
return t`This PDF is password protected. Save an unprotected copy and try again.`;
}
if (error instanceof PdfTooLargeError) return t`That file is too large to check in the browser.`;
if (error instanceof PdfUnreadableError) return t`That file could not be read as a PDF.`;
return t`Something went wrong while reading that file. Please try again.`;
}
export type AtsAiTierContext = {
result: AtsCheckResult;
jobDescription: string;
};
type AtsCheckerToolProps = {
/** Rendered under the report once a check has run. */
renderAiTier?: (context: AtsAiTierContext) => ReactNode;
};
export function AtsCheckerTool({ renderAiTier }: AtsCheckerToolProps) {
const [file, setFile] = useState<File | null>(null);
const [jobDescription, setJobDescription] = useState("");
const [result, setResult] = useState<AtsCheckResult | null>(null);
const [progress, setProgress] = useState<ExtractProgress | null>(null);
const [error, setError] = useState<unknown>(null);
const [isRunning, setIsRunning] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const run = useCallback(async (selected: File, description: string) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setIsRunning(true);
setError(null);
setResult(null);
setProgress(null);
try {
const next = await runAtsCheck(selected, {
jobDescription: description,
onProgress: setProgress,
signal: controller.signal,
});
if (!controller.signal.aborted) setResult(next);
} catch (caught) {
if (!controller.signal.aborted) setError(caught);
} finally {
if (!controller.signal.aborted) {
setIsRunning(false);
setProgress(null);
}
}
}, []);
const onSelect = (selected: File) => {
setFile(selected);
void run(selected, jobDescription);
};
return (
<div className="space-y-4">
<AtsUploader onSelect={onSelect} disabled={isRunning} selectedFileName={file?.name ?? null} />
<div className="space-y-2">
<Label htmlFor="ats-job-description">
<Trans>Job description (optional)</Trans>
</Label>
<Textarea
id="ats-job-description"
rows={4}
maxLength={MAX_JOB_DESCRIPTION_CHARS}
value={jobDescription}
placeholder={t`Paste the posting to see which of its terms already appear in your resume.`}
onChange={(event) => setJobDescription(event.target.value)}
/>
</div>
{file && !isRunning && (
<Button variant="outline" size="sm" onClick={() => void run(file, jobDescription)}>
<ArrowClockwiseIcon />
<Trans>Check again</Trans>
</Button>
)}
{isRunning && (
<div className="flex items-center gap-2 rounded-md border border-dashed p-3">
<Spinner className="size-4 shrink-0" />
<span className="text-muted-foreground text-sm">{progressLabel(progress)}</span>
</div>
)}
{error != null && (
<Alert variant="destructive">
<WarningCircleIcon />
<AlertDescription>{errorMessage(error)}</AlertDescription>
</Alert>
)}
{result && (
<>
<AtsPdfReportView report={result.report} />
{renderAiTier?.({ result, jobDescription })}
</>
)}
</div>
);
}
@@ -0,0 +1,77 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FilePdfIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { toast } from "@reactive-resume/ui/components/toast";
import { hasPdfMagicBytes, MAX_UPLOAD_BYTES } from "./extract-client";
type UploaderProps = {
onSelect: (file: File) => void;
disabled?: boolean;
selectedFileName?: string | null;
};
/**
* PDF only, and decided from the bytes rather than the extension: a `.docx` renamed `.pdf` is
* rejected here rather than failing deep inside PDF.js with an unhelpful error.
*/
export function AtsUploader({ onSelect, disabled, selectedFileName }: UploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isChecking, setIsChecking] = useState(false);
const onChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const selected = event.target.files?.[0];
// Reset so re-picking the same file still fires a change event.
event.target.value = "";
if (!selected) return;
setIsChecking(true);
try {
if (selected.size > MAX_UPLOAD_BYTES) {
toast.add({ type: "error", description: t`That file is too large to check in the browser.` });
return;
}
if (!(await hasPdfMagicBytes(selected))) {
toast.add({
type: "error",
description: t`That file is not a PDF. Export your resume as a PDF and try again.`,
});
return;
}
onSelect(selected);
} finally {
setIsChecking(false);
}
};
return (
<div className="space-y-2">
<input ref={inputRef} type="file" accept="application/pdf,.pdf" className="hidden" onChange={onChange} />
<Button
variant="outline"
disabled={disabled || isChecking}
className="h-auto w-full flex-col gap-2 border-dashed py-6"
onClick={() => inputRef.current?.click()}
>
<UploadSimpleIcon className="size-5" />
<span className="font-medium text-sm">
<Trans>Choose a PDF</Trans>
</span>
<span className="font-normal text-muted-foreground text-xs">
<Trans>Checked in your browser. The file is never uploaded.</Trans>
</span>
</Button>
{selectedFileName && (
<p className="flex items-center gap-1.5 text-muted-foreground text-xs">
<FilePdfIcon className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{selectedFileName}</span>
</p>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { t } from "@lingui/core/macro";
import { Combobox } from "@/components/ui/combobox";
export type UsableAiProvider = {
id: string;
label: string;
provider: AIProvider;
model: string;
};
/** Two accounts on the same provider are only told apart by their label, so all three are shown. */
function aiProviderLabel(provider: UsableAiProvider) {
return `${provider.label} · ${provider.provider} · ${provider.model}`;
}
type AiProviderPickerProps = {
value: string | null;
providers: readonly UsableAiProvider[];
isLoading?: boolean;
disabled?: boolean;
onValueChange: (value: string | null) => void;
};
export function AiProviderPicker({ value, providers, isLoading, disabled, onValueChange }: AiProviderPickerProps) {
const options = providers.map((provider) => ({
value: provider.id,
label: aiProviderLabel(provider),
keywords: [provider.label, provider.provider, provider.model],
}));
return (
<Combobox
value={value}
options={options}
disabled={disabled || isLoading || options.length === 0}
placeholder={isLoading ? t`Loading providers…` : t`Select an AI provider`}
onValueChange={onValueChange}
/>
);
}
-4
View File
@@ -72,10 +72,6 @@ export function getAtsFindingMessage(code: AtsRuleCode): AtsFindingMessage {
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("EMPTY_RENDERED_SECTION", () => ({
title: t`This section renders as an empty heading.`,
action: t`Add an item to it, or hide 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.`,
-5
View File
@@ -4,7 +4,6 @@ import { t } from "@lingui/core/macro";
import {
ArticleIcon,
BooksIcon,
BrainIcon,
BriefcaseIcon,
CertificateIcon,
ChartLineIcon,
@@ -54,7 +53,6 @@ export type RightSidebarSection =
| "sharing"
| "statistics"
| "ats"
| "analysis"
| "export"
| "information";
@@ -90,7 +88,6 @@ export const rightSidebarSections: RightSidebarSection[] = [
"page",
"notes",
"ats",
"analysis",
"export",
"information",
] as const;
@@ -130,7 +127,6 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string =
.with("sharing", () => t`Sharing`)
.with("statistics", () => t`Statistics`)
.with("ats", () => t`ATS Check`)
.with("analysis", () => t`Resume Analysis`)
.with("export", () => t`Export`)
.with("information", () => t`Information`)
@@ -175,7 +171,6 @@ export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: Ic
.with("sharing", () => <ShareFatIcon {...iconProps} />)
.with("statistics", () => <ChartLineIcon {...iconProps} />)
.with("ats", () => <SealCheckIcon {...iconProps} />)
.with("analysis", () => <BrainIcon {...iconProps} />)
.with("export", () => <DownloadIcon {...iconProps} />)
.with("information", () => <InfoIcon {...iconProps} />)
+21
View File
@@ -15,6 +15,7 @@ import { Route as AuthRouteRouteImport } from "./routes/auth/route";
import { Route as DashboardRouteRouteImport } from "./routes/dashboard/route";
import { Route as UsernameSlugRouteImport } from "./routes/$username/$slug";
import { Route as HomeIndexRouteImport } from "./routes/_home/index";
import { Route as HomeAtsCheckerRouteImport } from "./routes/_home/ats-checker";
import { Route as AgentIndexRouteImport } from "./routes/agent/index";
import { Route as AgentThreadIdRouteImport } from "./routes/agent/$threadId";
import { Route as AgentNewRouteImport } from "./routes/agent/new";
@@ -70,6 +71,11 @@ const HomeIndexRoute = HomeIndexRouteImport.update({
path: "/",
getParentRoute: () => HomeRouteRoute,
} as any);
const HomeAtsCheckerRoute = HomeAtsCheckerRouteImport.update({
id: "/ats-checker",
path: "/ats-checker",
getParentRoute: () => HomeRouteRoute,
} as any);
const AgentIndexRoute = AgentIndexRouteImport.update({
id: "/",
path: "/",
@@ -211,6 +217,7 @@ export interface FileRoutesByFullPath {
"/dashboard": typeof DashboardRouteRouteWithChildren;
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
"/$username/$slug": typeof UsernameSlugRoute;
"/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
"/agent/new": typeof AgentNewRoute;
"/auth/error": typeof AuthErrorRoute;
@@ -238,6 +245,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
"/$username/$slug": typeof UsernameSlugRoute;
"/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
"/agent/new": typeof AgentNewRoute;
"/auth/error": typeof AuthErrorRoute;
@@ -272,6 +280,7 @@ export interface FileRoutesById {
"/dashboard": typeof DashboardRouteRouteWithChildren;
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
"/$username/$slug": typeof UsernameSlugRoute;
"/_home/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
"/agent/new": typeof AgentNewRoute;
"/auth/error": typeof AuthErrorRoute;
@@ -307,6 +316,7 @@ export interface FileRouteTypes {
| "/dashboard"
| "/builder/$resumeId"
| "/$username/$slug"
| "/ats-checker"
| "/agent/$threadId"
| "/agent/new"
| "/auth/error"
@@ -334,6 +344,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo;
to:
| "/$username/$slug"
| "/ats-checker"
| "/agent/$threadId"
| "/agent/new"
| "/auth/error"
@@ -367,6 +378,7 @@ export interface FileRouteTypes {
| "/dashboard"
| "/builder/$resumeId"
| "/$username/$slug"
| "/_home/ats-checker"
| "/agent/$threadId"
| "/agent/new"
| "/auth/error"
@@ -448,6 +460,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof HomeIndexRouteImport;
parentRoute: typeof HomeRouteRoute;
};
"/_home/ats-checker": {
id: "/_home/ats-checker";
path: "/ats-checker";
fullPath: "/ats-checker";
preLoaderRoute: typeof HomeAtsCheckerRouteImport;
parentRoute: typeof HomeRouteRoute;
};
"/agent/": {
id: "/agent/";
path: "/";
@@ -627,10 +646,12 @@ declare module "@tanstack/react-router" {
}
interface HomeRouteRouteChildren {
HomeAtsCheckerRoute: typeof HomeAtsCheckerRoute;
HomeIndexRoute: typeof HomeIndexRoute;
}
const HomeRouteRouteChildren: HomeRouteRouteChildren = {
HomeAtsCheckerRoute: HomeAtsCheckerRoute,
HomeIndexRoute: HomeIndexRoute,
};
@@ -1,4 +1,5 @@
import type { Icon } from "@phosphor-icons/react";
import type { LinkProps } from "@tanstack/react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
@@ -16,9 +17,11 @@ import {
LockSimpleIcon,
PaletteIcon,
ProhibitIcon,
SealCheckIcon,
ShieldCheckIcon,
TranslateIcon,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { m } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
@@ -27,6 +30,8 @@ type Feature = {
icon: Icon;
title: string;
description: string;
/** When set, the whole card becomes a link to this route. */
to?: LinkProps["to"];
};
type FeatureCardProps = Feature;
@@ -122,6 +127,13 @@ const getFeatures = (): Feature[] => [
title: t`API Access`,
description: t`Access your resumes and data programmatically using the API.`,
},
{
id: "ats-checker",
icon: SealCheckIcon,
title: t`ATS Checker`,
description: t`See what an applicant tracking system can read from your resume. Runs in your browser.`,
to: "/ats-checker",
},
{
id: "more",
icon: DotsThreeIcon,
@@ -130,8 +142,8 @@ const getFeatures = (): Feature[] => [
},
];
function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
return (
function FeatureCard({ icon: Icon, title, description, to }: FeatureCardProps) {
const card = (
<m.div
className={cn(
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300 will-change-[transform,opacity]",
@@ -163,6 +175,14 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
</div>
</m.div>
);
if (!to) return card;
return (
<Link to={to} className="contents">
{card}
</Link>
);
}
export function Features() {
+211
View File
@@ -0,0 +1,211 @@
import { Trans } from "@lingui/react/macro";
import { CheckCircleIcon, ProhibitIcon, ShieldCheckIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { m } from "motion/react";
import { lazy, Suspense } from "react";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
import { Spotlight } from "@/components/animation/spotlight";
import { AiReviewCard } from "@/features/ats-checker/ai-review/ai-review-card";
import { LockedAiCard } from "@/features/ats-checker/ai-review/locked-card";
import { ParsePreview } from "@/features/ats-checker/parse-preview";
import { Footer } from "./-sections/footer";
const PAGE_TITLE = "ATS Checker - Reactive Resume";
const PAGE_DESCRIPTION =
"Check whether software can read your resume PDF. Runs entirely in your browser, so your file is never uploaded.";
// The checker pulls in PDF.js and the analysis engine, neither of which the page copy needs.
const AtsCheckerTool = lazy(() =>
import("@/features/ats-checker/tool").then((module) => ({ default: module.AtsCheckerTool })),
);
export const Route = createFileRoute("/_home/ats-checker")({
component: RouteComponent,
head: () => {
const origin = typeof window === "undefined" ? "https://rxresu.me" : window.location.origin;
const canonicalUrl = new URL("/ats-checker", origin).toString();
const imageUrl = new URL("/opengraph/ats-checker.png", origin).toString();
return {
meta: [
{ title: PAGE_TITLE },
{ name: "description", content: PAGE_DESCRIPTION },
{ property: "og:title", content: PAGE_TITLE },
{ property: "og:description", content: PAGE_DESCRIPTION },
{ property: "og:url", content: canonicalUrl },
{ property: "og:type", content: "website" },
{ property: "og:image", content: imageUrl },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:image", content: imageUrl },
],
links: [{ rel: "canonical", href: canonicalUrl }],
};
},
});
function RouteComponent() {
const { session } = Route.useRouteContext();
return (
// The marketing header is `fixed` and 65px tall. The homepage clears it with a full-height
// hero; this page has to make the room itself or its first row sits underneath.
<main id="main-content" className="relative pt-20 lg:pt-24">
<div className="container mx-auto px-4 sm:px-6 lg:px-12">
<div className="border-border border-x">
<Header />
<div className="grid border-border border-t lg:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]">
<section className="border-border p-4 md:p-8 lg:border-e">
<Suspense fallback={<ToolSkeleton />}>
<AtsCheckerTool
renderAiTier={({ result, jobDescription }) =>
session ? (
<AiReviewCard report={result.report} fullText={result.fullText} jobDescription={jobDescription} />
) : (
<LockedAiCard />
)
}
/>
</Suspense>
</section>
<aside className="space-y-8 p-4 md:p-8">
<HonestyPanel />
</aside>
</div>
<Footer />
</div>
</div>
</main>
);
}
function Header() {
return (
<section className="relative isolate overflow-hidden">
<Spotlight />
<div className="relative grid items-center gap-8 p-4 md:p-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,32rem)] lg:gap-12 xl:py-12">
<m.div
className="space-y-5 will-change-[transform,opacity]"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
<h1 className="font-semibold text-4xl tracking-tight md:text-5xl">
<Trans>ATS Checker</Trans>
</h1>
<p className="max-w-lg text-base text-muted-foreground leading-relaxed md:text-lg">
<Trans>
Upload a resume and see what an applicant tracking system actually reads from it. Everything runs in this
browser. Your file is never uploaded, and nothing is stored.
</Trans>
</p>
</m.div>
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, delay: 0.12, ease: "easeOut" }}
>
<ParsePreview />
</m.div>
</div>
</section>
);
}
function ToolSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-24 w-full" />
</div>
);
}
type FactGroupProps = {
icon: React.ReactNode;
title: React.ReactNode;
children: React.ReactNode;
};
function FactGroup({ icon, title, children }: FactGroupProps) {
return (
<div className="space-y-2.5">
<h2 className="flex items-center gap-2 font-semibold text-sm tracking-tight">
{icon}
{title}
</h2>
{children}
</div>
);
}
/**
* The honest framing, stated before the reader runs anything.
*
* Every claim here is one this tool can stand behind. The widely repeated "75% of resumes are
* rejected by robots" figure traces back to marketing copy rather than research, and this page says
* so instead of quietly trading on it.
*/
function HonestyPanel() {
return (
<>
<FactGroup
icon={<CheckCircleIcon className="size-4 shrink-0 text-emerald-600" />}
title={<Trans>What this checks</Trans>}
>
<ul className="space-y-1.5 text-muted-foreground text-sm leading-relaxed">
<li>
<Trans>Whether the file holds real text, or a picture of it.</Trans>
</li>
<li>
<Trans>Whether the text comes out in the order a person would read it.</Trans>
</li>
<li>
<Trans>Whether your name, email, phone and dates survive intact.</Trans>
</li>
<li>
<Trans>Whether the usual sections are there and can be told apart.</Trans>
</li>
<li>
<Trans>Optionally, which terms from a job posting already appear.</Trans>
</li>
</ul>
</FactGroup>
<FactGroup
icon={<ProhibitIcon className="size-4 shrink-0 text-rose-600" />}
title={<Trans>What this does not do</Trans>}
>
<ul className="space-y-1.5 text-muted-foreground text-sm leading-relaxed">
<li>
<Trans>No prediction of whether you will be rejected — no tool can do that.</Trans>
</li>
<li>
<Trans>No repeating the claim that most resumes are thrown out automatically.</Trans>
</li>
<li>
<Trans>No one-page rule, and no treating a font or a career gap as a fault.</Trans>
</li>
</ul>
</FactGroup>
<FactGroup
icon={<ShieldCheckIcon className="size-4 shrink-0 text-sky-600" />}
title={<Trans>Your file stays here</Trans>}
>
<p className="text-muted-foreground text-sm leading-relaxed">
<Trans>
The PDF is read by code running in this tab. Nothing is uploaded, nothing is stored, and no account is
needed. Open your browser's network tab and watch, if you like.
</Trans>
</p>
</FactGroup>
</>
);
}
@@ -1,4 +1,3 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@phosphor-icons/react";
@@ -12,6 +11,7 @@ import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { Combobox } from "@/components/ui/combobox";
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";
@@ -20,10 +20,6 @@ type NewThreadSetupProps = {
resumeId?: string;
};
function providerLabel(provider: { label: string; provider: AIProvider; model: string }) {
return `${provider.label} · ${provider.provider} · ${provider.model}`;
}
function isAgentConfigError(error: unknown) {
if (!error || typeof error !== "object") return false;
const message = (error as { message?: unknown }).message;
@@ -46,12 +42,6 @@ export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
const aiProviderId = aiProviderIdOverride ?? usableProviders[0]?.id ?? null;
const sourceResumeId = sourceResumeIdOverride ?? resumeId ?? null;
const providerOptions = usableProviders.map((provider) => ({
value: provider.id,
label: providerLabel(provider),
keywords: [provider.label, provider.provider, provider.model],
}));
const resumeOptions = [
{ value: "__scratch__", label: t`Create from scratch` },
...(resumes?.map((resume) => ({
@@ -105,14 +95,13 @@ export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
<Label>
<Trans>Select an agent model</Trans>
</Label>
<Combobox
<AiProviderPicker
value={aiProviderId}
options={providerOptions}
disabled={isLoadingProviders || providerOptions.length === 0}
placeholder={isLoadingProviders ? t`Loading providers…` : t`Select an AI provider`}
providers={usableProviders}
isLoading={isLoadingProviders}
onValueChange={setAiProviderIdOverride}
/>
{providerOptions.length === 0 && !isLoadingProviders ? (
{usableProviders.length === 0 && !isLoadingProviders ? (
<div className="flex flex-col gap-3 rounded-md border border-dashed p-3 text-sm lg:flex-row lg:items-center lg:justify-between">
<span className="text-muted-foreground">
<Trans>Set up an AI provider to get started.</Trans>
@@ -17,7 +17,6 @@ import { InformationSectionBuilder } from "./sections/information";
import { LayoutSectionBuilder } from "./sections/layout";
import { NotesSectionBuilder } from "./sections/notes";
import { PageSectionBuilder } from "./sections/page";
import { ResumeAnalysisSectionBuilder } from "./sections/resume-analysis";
import { SharingSectionBuilder } from "./sections/sharing";
import { StatisticsSectionBuilder } from "./sections/statistics";
import { TemplateSectionBuilder } from "./sections/template";
@@ -35,7 +34,6 @@ function getSectionComponent(type: RightSidebarSection) {
.with("sharing", () => <SharingSectionBuilder />)
.with("statistics", () => <StatisticsSectionBuilder />)
.with("ats", () => <AtsCheckSectionBuilder />)
.with("analysis", () => <ResumeAnalysisSectionBuilder />)
.with("export", () => <ExportSectionBuilder />)
.with("information", () => <InformationSectionBuilder />)
.exhaustive();
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import type { ExperienceItem, ResumeData } from "@reactive-resume/schema/resume/data";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
@@ -11,9 +11,47 @@ const resumeState = vi.hoisted(() => ({ data: undefined as ResumeData | undefine
const sidebarState = vi.hoisted(() => ({ toggleSidebar: vi.fn() }));
const sectionState = vi.hoisted(() => ({ setCollapsed: 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 };
type SectionStoreSelector = (state: { setCollapsed: typeof sectionState.setCollapsed }) => unknown;
// 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,
}));
@@ -75,16 +113,18 @@ const renderPanel = () =>
);
describe("AtsCheckSectionBuilder", () => {
it("renders nothing before the resume is ready", () => {
const { container } = renderPanel();
expect(container).toBeEmptyDOMElement();
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("22 of 22 checks passed")).toBeTruthy();
expect(screen.getByText("21 of 21 checks passed")).toBeTruthy();
expect(screen.getByText(/Every check passed/)).toBeTruthy();
});
@@ -96,7 +136,7 @@ describe("AtsCheckSectionBuilder", () => {
expect(screen.getByText("This email address will not be recognized.")).toBeTruthy();
expect(screen.getByText(/Use a plain address/)).toBeTruthy();
expect(screen.getByText("21 of 22 checks passed")).toBeTruthy();
expect(screen.getByText("20 of 21 checks passed")).toBeTruthy();
});
it("counts findings by severity", () => {
@@ -135,6 +175,59 @@ describe("AtsCheckSectionBuilder", () => {
});
});
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", () => {
const stubElement = (id: string) => {
const element = document.createElement("div");
@@ -1,14 +1,24 @@
import type { AtsFinding, AtsSeverity } from "@reactive-resume/resume/ats";
import type { AtsCheckResult } from "@/features/ats-checker/run-ats-check";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { ArrowRightIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { useCallback, useMemo } from "react";
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 { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import {
atsFindingItemElementId,
getAtsFindingLocation,
@@ -19,6 +29,8 @@ import { useSectionStore } from "../../../-store/section";
import { useBuilderSidebar } from "../../../-store/sidebar";
import { SectionBase } from "../shared/section-base";
const MAX_JOB_DESCRIPTION_CHARS = 20_000;
const SEVERITIES = ["error", "warning", "info"] as const;
function severityDotClass(severity: AtsSeverity) {
@@ -89,7 +101,7 @@ function AtsFindingRow({ finding, onJump }: AtsFindingRowProps) {
);
}
export function AtsCheckSectionBuilder() {
function LiveLintTier() {
const data = useResumeData();
const { toggleSidebar } = useBuilderSidebar();
const setCollapsed = useSectionStore((state) => state.setCollapsed);
@@ -116,55 +128,147 @@ export function AtsCheckSectionBuilder() {
const { counts, findings, passedRules, totalRules } = report;
return (
<SectionBase type="ats" className="space-y-4">
<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">
<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 ask whether a machine can read your resume,
not whether it reads well.
</Trans>
</p>
<div className="space-y-2">
<p className="font-medium text-sm leading-none">
<Trans>
These checks run as you type and never leave your browser. They ask whether a machine can read your
resume, not whether it reads well.
{passedRules} of {totalRules} checks passed
</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 rounded-full bg-primary transition-[width] duration-300"
style={{ width: `${Math.round((passedRules / totalRules) * 100)}%` }}
/>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width: `${Math.round((passedRules / totalRules) * 100)}%` }}
/>
</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-emerald-600" />
<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} />
{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]} />
))}
</ul>
</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-emerald-600" />
<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="destructive">
<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>
);
}
@@ -1,253 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { match } from "ts-pattern";
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 { toast } from "@reactive-resume/ui/components/toast";
import { useResume } from "@/features/resume/builder/draft";
import { orpc } from "@/libs/orpc/client";
import { SectionBase } from "../shared/section-base";
function impactCircleClass(impact: "high" | "medium" | "low") {
return match(impact)
.with("high", () => "bg-rose-600")
.with("medium", () => "bg-amber-600")
.with("low", () => "bg-emerald-600")
.exhaustive();
}
function impactLabel(impact: "high" | "medium" | "low") {
return match(impact)
.with("high", () =>
t({
comment: "Impact severity label in resume analysis suggestion card",
message: "High",
}),
)
.with("medium", () =>
t({
comment: "Impact severity label in resume analysis suggestion card",
message: "Medium",
}),
)
.with("low", () =>
t({
comment: "Impact severity label in resume analysis suggestion card",
message: "Low",
}),
)
.exhaustive();
}
export function ResumeAnalysisSectionBuilder() {
const queryClient = useQueryClient();
const resume = useResume();
const resumeId = resume?.id ?? "";
const { data: providers } = useQuery(orpc.aiProviders.list.queryOptions());
const aiEnabled = providers?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const { data: analysis, isFetched: analysisFetched } = useQuery({
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
enabled: !!resume,
});
const { mutate: analyzeResume, isPending } = useMutation({
...orpc.ai.analyzeResume.mutationOptions(),
onSuccess: (analysis) => {
queryClient.setQueryData(orpc.resume.analysis.getById.queryKey({ input: { id: resumeId } }), analysis);
toast.add({ type: "success", description: t`Resume analysis complete.` });
},
onError: (_error) => {
toast.add({ type: "error", description: t`Failed to analyze resume.` });
},
});
const score = analysis?.overallScore ?? null;
const updatedAt = analysis?.updatedAt ?? null;
// Derived during render (not via state+effect): the analysis comes from a client-fetched query,
// so the server render has no date and there's no hydration mismatch to defer around.
const updatedAtLabel = updatedAt ? new Date(updatedAt).toLocaleString() : null;
const analyzeLabel = isPending ? t`Analyzing…` : t`Analyze Resume`;
const scoreTone =
score == null ? "bg-muted" : score >= 80 ? "bg-emerald-600" : score >= 60 ? "bg-amber-600" : "bg-rose-600";
const onAnalyze = () => {
if (!resume) return;
analyzeResume({
resumeId: resume.id,
});
};
if (!resume) return null;
return (
<SectionBase type="analysis" className="space-y-4">
{!aiEnabled && <DisabledState />}
{aiEnabled && (
<div className="space-y-3">
<div className="space-y-4 rounded-md border bg-card p-3">
<div className="grid grid-cols-2 items-center gap-3">
<div>
<p className="text-muted-foreground text-xs">
<Trans>
Get a review of your resume with an overall score, strengths, and actionable suggestions.
</Trans>
</p>
</div>
<Button disabled={isPending} onClick={onAnalyze} className="ml-auto w-fit">
<SparkleIcon />
{analyzeLabel}
</Button>
</div>
<div className="grid grid-cols-[auto_1fr] items-center gap-3">
<div
className={`grid size-18 place-items-center rounded-full border-3 border-background font-bold text-lg text-white ${scoreTone}`}
>
{score ?? "--"}
</div>
<div className="space-y-3">
<p className="font-medium text-sm leading-none">
<Trans>Overall Score</Trans>
</p>
<div className="grid grid-cols-10 gap-1">
{Array.from({ length: 10 }).map((_, index) => {
const active = score != null && index < Math.round(score / 10);
return (
<div
key={`scorebar-${index}`}
className={`h-1.5 rounded-full transition-colors ${active ? "bg-primary" : "bg-muted"}`}
/>
);
})}
</div>
{updatedAtLabel ? (
<p className="text-muted-foreground text-xs leading-none">
<Trans>Last analyzed on {updatedAtLabel}</Trans>
</p>
) : null}
</div>
</div>
</div>
{analysisFetched && !analysis && !isPending && (
<div className="rounded-md border border-dashed p-3">
<p className="max-w-xs text-muted-foreground text-sm">
<Trans>Run your first analysis to get a scorecard, strengths, and prioritized suggestions.</Trans>
</p>
</div>
)}
{analysis && (
<div className="space-y-4">
<div className="space-y-3 rounded-md border p-3">
<h5 className="flex items-center gap-2 font-semibold text-sm">
<LightningIcon className="text-primary" />
<Trans>Scorecard</Trans>
</h5>
<div className="space-y-3">
{analysis.scorecard.map((item) => (
<div key={item.dimension} className="space-y-3 rounded-md border bg-card p-3">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-sm">{item.dimension}</div>
<Badge variant="secondary">{item.score}/100</Badge>
</div>
<p className="text-muted-foreground text-xs">{item.rationale}</p>
</div>
))}
</div>
</div>
{analysis.strengths.length > 0 && (
<div className="space-y-3 rounded-md border p-3">
<h5 className="font-semibold text-sm">
<Trans>Strengths</Trans>
</h5>
<ul className="list-outside list-disc pl-5 text-muted-foreground text-sm">
{analysis.strengths.map((strength) => (
<li key={strength} className="py-1.5">
{strength}
</li>
))}
</ul>
</div>
)}
{analysis.suggestions.length > 0 && (
<div className="space-y-4 rounded-md border p-3">
<h5 className="font-semibold text-sm">
<Trans>Suggestions</Trans>
</h5>
<div className="space-y-3">
{analysis.suggestions.map((suggestion) => (
<div key={suggestion.title} className="space-y-3 rounded-md border bg-card p-3">
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className={`size-2.5 shrink-0 rounded-full ring-1 ring-border ${impactCircleClass(suggestion.impact)}`}
title={impactLabel(suggestion.impact)}
/>
<span className="sr-only">{impactLabel(suggestion.impact)}</span>
<div className="font-semibold text-sm tracking-tight">{suggestion.title}</div>
</div>
<div className="text-muted-foreground text-xs">{suggestion.why}</div>
{suggestion.exampleRewrite && (
<div className="rounded bg-muted p-2 text-muted-foreground text-xs">
{suggestion.exampleRewrite}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
</SectionBase>
);
}
function DisabledState() {
return (
<Alert>
<InfoIcon />
<AlertDescription className="space-y-3">
<p>
<Trans>
Get an in-depth AI-powered review of your resume with an overall score, key strengths, and practical
suggestions. To activate this feature, please update your AI settings.
</Trans>
</p>
<Button
size="sm"
variant="outline"
nativeButton={false}
render={
<Link to="/dashboard/settings/integrations">
<Trans>Open Integrations Settings</Trans>
<ArrowRightIcon />
</Link>
}
/>
</AlertDescription>
</Alert>
);
}
@@ -10,6 +10,7 @@ import {
KeyIcon,
MagnifyingGlassIcon,
ReadCvLogoIcon,
SealCheckIcon,
ShieldCheckIcon,
UserCircleIcon,
UserGearIcon,
@@ -61,6 +62,11 @@ const appSidebarItems = [
label: msg`Agents`,
href: "/agent",
},
{
icon: <SealCheckIcon />,
label: msg`ATS Checker`,
href: "/ats-checker",
},
] as const satisfies SidebarItem[];
const settingsSidebarItems = [
+38
View File
@@ -4,6 +4,44 @@ description: "Release notes for Reactive Resume covering new features, resume bu
rss: true
---
<Update label="v5.2.9" description="27th August 2026">
## Highlights
- **A free ATS checker that never uploads your resume.** Drop a PDF on [rxresu.me/ats-checker](https://rxresu.me/ats-checker) and it opens the file the way a parser would, pulls the text out, and reports what survived: whether the file carries real text or a picture of one, whether a multi-column layout scrambles the reading order, and whether your name, email, phone, links and dates come through intact. Everything runs in your browser — the file is never uploaded, nothing is stored, and no account is needed. Paste a job description and it will also tell you which of the posting's terms already appear in your resume. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- **A score you can actually audit.** The report gives one number from 0 to 100 across readability, layout, sections, contact details and dates — and shows its working. Every finding cites the text that triggered it and the page it sits on, and the report states how many checks ran and how many were honestly skipped rather than quietly counting them as passes. Employment gaps, writing style and length are reported as unscored tips; they never move the score. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- **Resume analysis has been replaced by the ATS check.** The old AI-scored analysis is gone, along with its stored results, its `get_resume_analysis` MCP tool, and the `POST /ai/analyze-resume` endpoint. What replaces it is deterministic and evidence-backed, with an optional AI second opinion on your writing that deliberately returns no score of its own. Self-hosters: read the upgrade note below. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- **Bold text finally looks bold.** Most resumes pair Regular with SemiBold, which rendered `<strong>` almost indistinguishably from body text. Bold now resolves to the font family's true Bold face, so emphasis reads as emphasis — existing resumes will export with visibly stronger bold text. Thanks to [@santhiprakash](https://github.com/santhiprakash). [e065a1082](https://github.com/amruthpillai/reactive-resume/commit/e065a1082)
## Resume Builder
- The right sidebar's **ATS Check** section now runs live checks against your resume as you type — missing dates, malformed links, prose stranded in a narrow sidebar — before you export anything. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- **Run deep check** renders your current resume to a PDF in your browser and runs the full file check against those exact bytes, which is the same file a recruiter receives. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- Once a report exists, you can ask your own AI provider to review the writing — weak phrasing, bullets that describe duties rather than outcomes, and where a rewrite would land better. It sends only the extracted text, never the PDF. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- Section detection understands more of the headings people actually use, so conventional sections are recognized across a wider range of resumes. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- Toasts no longer slide underneath dialogs and sheets. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
## AI Agent
- Picking a provider when starting a thread now uses the same provider picker as the rest of the app, showing the label, provider and model consistently instead of one long concatenated line. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
## PDF Export & Fonts
- Bold text now uses the font family's true Bold face. Most resumes store their typography as Regular plus SemiBold, which rendered `<strong>` almost indistinguishably from body text on faces like Open Sans; bold now resolves to the family's 700 weight where it has one, and to its heaviest bold-class face where it does not. Existing resumes will export with visibly stronger bold text. Thanks to [@santhiprakash](https://github.com/santhiprakash). [e065a1082](https://github.com/amruthpillai/reactive-resume/commit/e065a1082)
- Long bullet-list lines no longer spill past the page margin. Thanks to [@santhiprakash](https://github.com/santhiprakash). [a416d0111](https://github.com/amruthpillai/reactive-resume/commit/a416d0111)
- Emoji render instead of dropping out as blank boxes. They are drawn as monochrome outlines, because the color emoji format cannot be embedded in a PDF, and the fallback font is only loaded when your resume actually contains emoji. Thanks to [@santhiprakash](https://github.com/santhiprakash). [b47f80532](https://github.com/amruthpillai/reactive-resume/commit/b47f80532)
- Vazirmatn is available in the font catalog again, so Persian and Arabic resumes that ask for it no longer fall back to a Latin serif and lose their glyphs on export. Thanks to [@santhiprakash](https://github.com/santhiprakash). [2761bd671](https://github.com/amruthpillai/reactive-resume/commit/2761bd671)
## Self-Hosting & Maintenance
- **A migration drops the `resume_analysis` table.** Any saved analyses are deleted when you upgrade; they have no equivalent in the new report, which is computed on demand rather than stored. No action is needed beyond the usual upgrade. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- **Two API surfaces were removed.** MCP clients calling `get_resume_analysis` and API clients calling `POST /ai/analyze-resume` will start failing; the replacement is `POST /ai/ats-review`, which reviews extracted resume text and returns qualitative feedback without a score. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- `/ats-checker` is listed in the sitemap and carries its own preview card, so the page is indexable and shares cleanly. [#3382](https://github.com/amruthpillai/reactive-resume/pull/3382)
- Routine dependency updates across the workspace, including the AI SDK providers, Better Auth, TipTap, TanStack Query, Hono and the build toolchain. All patch and minor releases; no breaking upgrades.
- Translations synced from Crowdin, with the new ATS checker strings covered across every supported language. [7fac6f29c](https://github.com/amruthpillai/reactive-resume/commit/7fac6f29c)
**Full Changelog**: [v5.2.8...v5.2.9](https://github.com/amruthpillai/reactive-resume/compare/v5.2.8...v5.2.9)
</Update>
<Update label="v5.2.8" description="24th August 2026">
## Highlights
@@ -1,9 +1,9 @@
---
title: "Reactive Resume vs Jobscan"
description: "Compare Reactive Resume and Jobscan by free PDF creation, ATS analysis, job matching, exports, open source, and automation."
description: "Compare Reactive Resume and Jobscan by free PDF creation, ATS parse checking, job matching, exports, open source, and automation."
---
Jobscan is a better fit when your workflow centers on its resume scanner and job-description analysis. Reactive Resume is a better fit when you want structured resume data, multiple export formats, open-source deployment, and API or MCP automation without a native scoring product.
Jobscan is a better fit when your workflow centers on its hosted resume scanner and its match-rate score against a job description. Reactive Resume is a better fit when you want structured resume data, multiple export formats, open-source deployment, and API or MCP automation — with a free ATS checker that measures how well your file parses rather than predicting an outcome.
## Quick comparison
@@ -11,16 +11,16 @@ Jobscan is a better fit when your workflow centers on its resume scanner and job
| --- | --- | --- |
| Resume creation | Structured editor with PDF, DOCX, Markdown, and JSON exports | Free builder with nine current templates and PDF download |
| Import | Supported resume imports | Existing-resume and LinkedIn import |
| Analysis | No native ATS or keyword-match score | Separate resume scanner and job-description analysis workflow |
| Analysis | Built-in ATS checker, free: a parse-quality score, a pass/warn/fail checklist, and optional job-description keyword coverage, all computed in your browser | Separate resume scanner producing a match rate against a job description |
| Automation | Authenticated API and MCP workflows | Separate scanner workflow |
Jobscan's builder documents a free PDF-creation workflow, LinkedIn import, and nine ATS-friendly templates. Its separate scanner describes resume scoring, formatting checks, and job-listing analysis as product features. Those features are vendor positioning and do not guarantee ATS handling or hiring outcomes.
Reactive Resume exports PDF, DOCX, Markdown, and JSON and supports authenticated automation through its API and MCP server. It does not provide a native ATS score or job-keyword scanner.
Reactive Resume exports PDF, DOCX, Markdown, and JSON and supports authenticated automation through its API and MCP server. Its [ATS checker](/guides/using-the-ats-checker) scores how faithfully software can extract your PDF and lists which of a posting's terms already appear in it. It deliberately does not report a match rate or predict a rejection, because neither is knowable from the file.
## Where Jobscan is a better fit
Choose Jobscan when you want its specialized scanner and job-description analysis alongside resume creation. The scanner's feature set is more direct when a documented score and its associated recommendations are central to your review process.
Choose Jobscan when you want a match-rate score against a specific posting, and its associated recommendations, as the center of your review process. Its scanner is a paid, hosted product built around that number.
## Where Reactive Resume is a better fit
@@ -28,22 +28,23 @@ Choose Reactive Resume when you want to own structured resume data, choose a tem
## Which should you choose?
Choose Jobscan when its scanner and job-analysis flow are your primary need. Choose Reactive Resume when the builder, data portability, self-hosting, and automation are primary and a specialized score is not required.
Choose Jobscan when a match-rate score against each posting is your primary need. Choose Reactive Resume when the builder, data portability, self-hosting, and automation are primary, and a parse-quality check that runs free and locally covers the rest.
## Reactive Resume limitations in this comparison
Reactive Resume does not include a native ATS score or job-keyword scanner. If you want Jobscan's specialized analysis in the same hosted workflow as the builder, it is the more direct fit.
Reactive Resume's ATS checker measures file properties: whether the text extracts, in what order, and whether your facts survive. It does not produce a match rate against a posting, and it does not claim to predict a rejection. If a documented match-rate score is what you want, Jobscan is the more direct fit.
## Sources
- [Jobscan resume builder](https://www.jobscan.co/resume-builder)
- [Jobscan resume scanner](https://www.jobscan.co/resume-scanner)
- [Reactive Resume: using the ATS checker](/guides/using-the-ats-checker)
- [Reactive Resume: exporting your resume](/guides/exporting-your-resume)
- [Reactive Resume: using the API](/guides/using-the-api)
- [Reactive Resume: using the MCP server](/guides/using-the-mcp-server)
Last checked: July 28, 2026
<Card title="Own the resume before adding specialized analysis" icon="arrow-right" href="https://rxresu.me">
Build, export, and automate structured resume data in Reactive Resume when an ATS score is not your primary need.
<Card title="Check that your resume parses, for free" icon="arrow-right" href="https://rxresu.me/ats-checker">
Upload a PDF and see what an applicant tracking system can read from it. Runs in your browser; no account needed.
</Card>
+1
View File
@@ -72,6 +72,7 @@
"guides/using-the-builder-dock",
"guides/undoing-changes-and-version-history",
"applying-custom-styles",
"guides/using-the-ats-checker",
"guides/using-ai-in-the-builder",
"guides/using-ai-agent",
"guides/using-private-notes",
+5 -30
View File
@@ -1,48 +1,23 @@
---
title: "Using AI in the builder"
description: "Run AI-powered resume analysis and chat with the built-in AI assistant in the builder to review proposed resume changes before applying them."
description: "Chat with the built-in AI assistant in the builder to review proposed resume changes before applying them, and add an optional AI review to the ATS check."
---
Reactive Resume includes two AI-assisted builder workflows:
- **Resume Analysis** gives you a score, strengths, and prioritized suggestions.
- The **AI assistant** opens as a side panel in the builder, scoped to the open resume, so you can chat and apply edits inline.
- The **ATS Check** section can add an optional AI review of your writing on top of its deterministic report.
<Info>
These features require a tested and enabled AI provider in **Dashboard -> Settings -> Integrations**. For setup, see
[Using Artificial Intelligence](/guides/using-ai).
</Info>
## Run a resume analysis
## Review the writing from the ATS check
Use resume analysis when you want a broad review before making changes.
The **ATS Check** section in the right sidebar runs without AI: its checks are deterministic and run in your browser. Once a deep check has produced a report, it offers an optional AI review of the writing — weak phrasing, bullets that describe duties rather than outcomes, and where a rewrite would land better.
<Steps>
<Step title="Open your resume in the builder">
Go to the dashboard and open the resume you want to review.
</Step>
<Step title="Open the Resume Analysis section">
In the right sidebar, select **Resume Analysis**.
<Frame caption="Resume Analysis section in the builder">
<img
src="/images/guides/using-ai-in-the-builder/screenshot-1.webp"
alt="Resume Analysis section showing the AI setup prompt in the builder sidebar"
/>
</Frame>
</Step>
<Step title="Click Analyze Resume">
Reactive Resume sends the current resume data to your configured AI provider.
</Step>
<Step title="Review the results">
Review the overall score, scorecard, strengths, and suggestions.
</Step>
</Steps>
Use the analysis as guidance, not as a final verdict. The best resume still depends on the job, industry, and audience.
The review sends the text already extracted from your rendered PDF to the provider you pick, and returns no score. See [Using the ATS checker](/guides/using-the-ats-checker).
## Open the AI assistant
+3 -3
View File
@@ -1,9 +1,9 @@
---
title: "Using Artificial Intelligence"
description: "Configure an OpenAI, Anthropic, Gemini, OpenRouter, or Ollama provider to power resume analysis, AI edits, agent drafts, and PDF imports."
description: "Configure an OpenAI, Anthropic, Gemini, OpenRouter, or Ollama provider to power AI edits, resume reviews, agent drafts, and PDF imports."
---
Reactive Resume uses AI providers for features such as resume analysis, AI-assisted resume changes, AI agent drafts, and PDF or Word imports.
Reactive Resume uses AI providers for features such as AI-assisted resume changes, the optional AI review in the ATS checker, AI agent drafts, and PDF or Word imports.
## Open AI provider settings
@@ -86,7 +86,7 @@ If provider management is unavailable, your self-hosted deployment may be missin
After a provider is tested and enabled, you can use AI in:
- **Resume Analysis** in the builder's right sidebar;
- the optional **AI review** in the ATS Check section of the builder's right sidebar;
- **AI-assisted resume changes** from the builder;
- **Agent** workflows that create isolated AI drafts;
- **PDF and Microsoft Word imports** from the dashboard import dialog.
+77
View File
@@ -0,0 +1,77 @@
---
title: "Using the ATS checker"
description: "Check whether an applicant tracking system can read your resume PDF, from the free public page or from inside the builder, without uploading the file anywhere."
---
The ATS checker answers one question: **can software read your resume?**
It opens your PDF the way a parser would, pulls the text out, and reports what survived and what did not. Everything runs in your browser. The file is never uploaded, nothing is stored, and no account is needed.
<Info>
Use it at [rxresu.me/ats-checker](https://rxresu.me/ats-checker), or from the **ATS Check** section in the builder's
right sidebar.
</Info>
## What it measures
- Whether the file carries real text, or is a picture of one.
- Whether the text extracts in the order a person reads it, which is where multi-column layouts usually fail.
- Whether your name, email, phone number, links, and dates survive extraction intact.
- Whether the conventional sections are present and can be told apart.
- Whether the file itself is readable: fonts, encryption, forms, size, page geometry.
Optionally, paste a job description and it will tell you which of the posting's terms already appear in your resume.
## What it does not measure
<Warning>
No tool can tell you whether an application will be rejected. That depends on the role, the screening questions, and
the person reading. Anything that claims otherwise is guessing.
</Warning>
The checker deliberately does not:
- repeat the widely quoted claim that most resumes are discarded automatically, because that figure comes from marketing copy rather than research;
- enforce a one-page rule;
- treat a font choice, a photo, or an employment gap as a defect.
Employment gaps, writing style, and length are reported as **unscored tips**. They never move the score.
## Reading the report
The report has three parts.
**A score from 0 to 100.** It is the weighted result of five categories: readability, layout, sections, contact details, and dates. A blocking problem puts a hard ceiling on it: a file with no text layer cannot score well because its margins are tidy.
**A category breakdown.** Each category expands into the checks that ran, what failed, and what to do about it. Every finding cites evidence from your file: the text that triggered it and the page it sits on.
**A denominator you can trust.** The report says "X of Y applicable checks passed" and states how many were skipped. A check is skipped when it cannot honestly run. Section detection only works on resumes written in English, for instance, and some checks need page contents that a very large file may not yield in time.
## Checking from the builder
The **ATS Check** section in the builder's right sidebar has two tiers:
<Steps>
<Step title="Live checks">
These run as you type, against your resume data rather than a file. They catch missing dates, malformed links,
prose in a narrow sidebar, and similar problems before you export anything.
</Step>
<Step title="Deep check">
Click **Run deep check** to render your current resume to a PDF in your browser and run the full file check against
those bytes, which is the same file a recruiter would receive.
</Step>
</Steps>
## Adding an AI review
After a report exists, you can optionally ask an AI provider to review the **writing**: weak phrasing, bullets that describe duties rather than outcomes, and where a rewrite would land better.
<Info>
The AI review needs your own tested AI provider in **Dashboard → Settings → Integrations**. See [Using Artificial
Intelligence](/guides/using-ai).
</Info>
It sends the text already extracted from your PDF, plus the job description if you pasted one, to the provider you pick. The PDF file itself is never uploaded.
The AI review returns no score. The deterministic report owns the only number in this feature, and an AI-adjusted score would make it mean less. Treat the review as a second opinion on your writing, not a verdict.
-1
View File
@@ -214,7 +214,6 @@ Tool names use canonical unprefixed `snake_case` names.
| `list_resumes` | List all resumes with IDs, names, tags, and status. Supports filtering by tags and sorting by last updated, creation date, or name |
| `list_resume_tags` | List every distinct tag in use across your resumes (sorted) |
| `read_resume` | Get the full data of a specific resume by ID |
| `get_resume_analysis` | Get the latest saved AI analysis for a resume (from the web app), if any |
| `download_resume_pdf` | Create a 10-minute authenticated PDF download URL. Defaults to the resume; set `target: "cover-letter"` for its visible cover letter |
| `create_resume` | Create a new, empty resume with a name and slug. Optionally pre-fill with sample data |
| `import_resume` | Create a resume from a full ResumeData JSON export (random name/slug). Large files may exceed client limits |
+121 -284
View File
@@ -1,7 +1,7 @@
{
"info": {
"title": "Reactive Resume",
"version": "5.2.6",
"version": "5.2.8",
"description": "Reactive Resume API",
"license": {
"name": "MIT",
@@ -3946,11 +3946,11 @@
}
}
},
"/ai/analyze-resume": {
"/ai/ats-review": {
"post": {
"operationId": "analyzeResume",
"summary": "Analyze resume and persist latest analysis",
"description": "Uses AI to analyze the current resume and returns a structured analysis with scorecard, strengths, and improvement suggestions. The latest analysis is persisted and can be fetched later. Requires authentication and AI credentials.",
"operationId": "atsReview",
"summary": "Review extracted resume text",
"description": "Reviews the plain text extracted from a resume PDF and returns qualitative feedback: a summary, rewrite suggestions, strengths, and — when a job description is supplied — how the candidate's experience lines up with the role. Deliberately returns no score: the deterministic ATS report owns the only number in this feature. Requires authentication and AI credentials.",
"tags": [
"AI"
],
@@ -3964,12 +3964,45 @@
"aiProviderId": {
"type": "string"
},
"resumeId": {
"type": "string"
"extractedText": {
"type": "string",
"minLength": 1,
"maxLength": 50000
},
"findings": {
"type": "array",
"maxItems": 120,
"items": {
"type": "object",
"properties": {
"code": {
"type": "string",
"maxLength": 64
},
"severity": {
"type": "string",
"maxLength": 16
},
"message": {
"type": "string",
"maxLength": 300
}
},
"required": [
"code",
"severity",
"message"
]
},
"default": []
},
"jobDescription": {
"type": "string",
"maxLength": 20000
}
},
"required": [
"resumeId"
"extractedText"
]
}
}
@@ -3977,128 +4010,41 @@
},
"responses": {
"200": {
"description": "Structured resume analysis returned and persisted successfully.",
"description": "Qualitative review returned successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"overallScore": {
"type": "integer",
"minimum": 0,
"maximum": 100
"summary": {
"type": "string"
},
"scorecard": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"dimension": {
"type": "string",
"minLength": 1
"suggestions": {},
"strengths": {},
"jdAlignment": {
"anyOf": [
{
"type": "object",
"properties": {
"verdict": {
"type": "string"
},
"missingConcepts": {},
"strengths": {}
},
"score": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"rationale": {
"type": "string",
"minLength": 1
}
"required": [
"verdict"
]
},
"required": [
"dimension",
"score",
"rationale"
]
}
},
"suggestions": {
"type": "array",
"maxItems": 10,
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"minLength": 1
},
"impact": {
"enum": [
"high",
"medium",
"low"
],
"type": "string"
},
"why": {
"type": "string",
"minLength": 1
},
"exampleRewrite": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"copyPrompt": {
"type": "string",
"minLength": 1
}
},
"required": [
"title",
"impact",
"why",
"exampleRewrite",
"copyPrompt"
]
}
},
"strengths": {
"type": "array",
"maxItems": 10,
"items": {
"type": "string",
"minLength": 1
}
},
"updatedAt": {
"type": "string",
"format": "date-time",
"x-native-type": "date"
},
"modelMeta": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"minLength": 1
},
"model": {
"type": "string",
"minLength": 1
{
"type": "null"
}
},
"required": [
"provider",
"model"
]
}
},
"required": [
"overallScore",
"scorecard",
"suggestions",
"strengths",
"updatedAt",
"modelMeta"
"summary",
"jdAlignment"
]
}
}
@@ -5305,6 +5251,58 @@
}
}
},
"patch": {
"operationId": "updateAgentThread",
"summary": "Update agent thread settings",
"tags": [
"Agent"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"reviewPatches": {
"type": "boolean"
}
},
"required": [
"reviewPatches"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"anyOf": [
{},
{
"not": {}
}
]
}
}
}
}
}
},
"delete": {
"operationId": "deleteAgentThread",
"summary": "Delete agent thread",
@@ -9648,14 +9646,15 @@
"schema": {
"type": "object",
"properties": {
"sourceUrl": {
"type": "string"
},
"jobDescription": {
"type": "string",
"minLength": 1,
"maxLength": 20000
}
}
},
"required": [
"jobDescription"
]
}
}
}
@@ -9679,17 +9678,13 @@
},
"salary": {
"type": "string"
},
"jobDescription": {
"type": "string"
}
},
"required": [
"company",
"role",
"location",
"salary",
"jobDescription"
"salary"
]
}
}
@@ -10138,164 +10133,6 @@
}
}
},
"/resumes/{id}/analysis": {
"get": {
"operationId": "getResumeAnalysis",
"summary": "Get latest resume analysis",
"description": "Returns the latest persisted AI analysis for the specified resume, if one exists. Requires authentication.",
"tags": [
"Resume Analysis"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The unique identifier of the resume."
}
}
],
"responses": {
"200": {
"description": "The latest persisted resume analysis, or null if no analysis has been saved yet.",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"overallScore": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"scorecard": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"dimension": {
"type": "string",
"minLength": 1
},
"score": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"rationale": {
"type": "string",
"minLength": 1
}
},
"required": [
"dimension",
"score",
"rationale"
]
}
},
"suggestions": {
"type": "array",
"maxItems": 10,
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"minLength": 1
},
"impact": {
"enum": [
"high",
"medium",
"low"
],
"type": "string"
},
"why": {
"type": "string",
"minLength": 1
},
"exampleRewrite": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"copyPrompt": {
"type": "string",
"minLength": 1
}
},
"required": [
"title",
"impact",
"why",
"exampleRewrite",
"copyPrompt"
]
}
},
"strengths": {
"type": "array",
"maxItems": 10,
"items": {
"type": "string",
"minLength": 1
}
},
"updatedAt": {
"type": "string",
"format": "date-time",
"x-native-type": "date"
},
"modelMeta": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"minLength": 1
},
"model": {
"type": "string",
"minLength": 1
}
},
"required": [
"provider",
"model"
]
}
},
"required": [
"overallScore",
"scorecard",
"suggestions",
"strengths",
"updatedAt",
"modelMeta"
]
},
{
"type": "null"
}
]
}
}
}
}
}
}
},
"/resumes/{id}/updates": {
"get": {
"operationId": "subscribeResumeUpdates",
+1 -1
View File
@@ -7,7 +7,7 @@ Reactive Resume can be used as an AI-assisted resume builder, but AI is optional
## What AI does in Reactive Resume
AI features can help with resume analysis, AI-assisted changes in the builder, AI agent drafts, and supported import workflows. The core resume builder still works without AI.
AI features can help with AI-assisted changes in the builder, an optional review of your writing in the ATS checker, AI agent drafts, and supported import workflows. The core resume builder — and the ATS checker itself — still works without AI.
Use these guides for the full setup:
@@ -0,0 +1 @@
DROP TABLE "resume_analysis";

Some files were not shown because too many files have changed in this diff Show More