feat(web): rebuild the public ATS checker around fixing the file

Idle is a drop zone with "Check a sample file" and an optional posting; busy
shows three labelled steps; the result is a 440px column with the score ring,
the categories that could cost a match (and the posting's missing terms), and
"Fix these in the editor", beside the file shown two ways: the original page
and the text as software reads it. Phones get one column with the fix pinned.

"Fix these in the editor" imports the same file, read in the browser as the
check was, and opens it in Check. Visitors sign up first; the file waits in
IndexedDB and is imported when they're back.

The public checker's AI review is dropped (Check -> Writing covers it), with
its locked card, the marketing parse preview and the old uploader. The PDF
viewer also renders a given file, for the original page.
This commit is contained in:
Amruth Pillai
2026-09-29 00:50:20 +02:00
parent 6282bf77db
commit d9ed63f720
11 changed files with 520 additions and 815 deletions
@@ -1,161 +0,0 @@
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="secondary"
nativeButton={false}
render={
<Link to="/dashboard/settings/ai">
<Trans>Open Integrations Settings</Trans>
<ArrowRightIcon />
</Link>
}
/>
</AlertDescription>
</Alert>
);
}
@@ -1,133 +0,0 @@
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-danger")
.with("medium", () => "bg-warn")
.with("low", () => "bg-accent")
.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="neutral" 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. Treat it as a second opinion, not a verdict.
</Trans>
</p>
</div>
);
}
@@ -1,33 +0,0 @@
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="secondary" nativeButton={false} render={<Link to="/auth/login" />}>
<Trans>Sign in</Trans>
<ArrowRightIcon />
</Button>
</div>
);
}
@@ -0,0 +1,449 @@
import type { PdfAtsReport, PdfCategory } from "@reactive-resume/resume/ats-pdf";
import type { CSSProperties } from "react";
import type { ExtractProgress } from "./extract-client";
import type { AtsCheckResult } from "./run-ats-check";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { cn } from "@reactive-resume/utils/style";
import { ImportError, readResumeFile } from "@/features/resume/import/read-file";
import { PdfViewer } from "@/features/resume/public/pdf-viewer";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client } from "@/libs/orpc/client";
import { PdfPasswordRequiredError, PdfTooLargeError, PdfUnreadableError } from "./extract-client";
import { getPdfCategoryLabel, getPdfFindingMessage } from "./messages";
import { savePendingImport, takePendingImport } from "./pending-import";
import { runAtsCheck } from "./run-ats-check";
const MAX_POSTING = 20_000;
const SAMPLE = { url: "/templates/pdf/onyx.pdf", name: "Sample resume.pdf" };
type State =
| { name: "idle"; error?: string }
| { name: "busy"; file: File; step: number }
| { name: "done"; file: File; result: AtsCheckResult }
| { name: "importing" };
const stepOf = (progress: ExtractProgress) => (progress.phase === "loading" ? 0 : progress.phase === "text" ? 1 : 2);
function describeError(error: unknown) {
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. PDFs up to 25 MB work.`;
if (error instanceof PdfUnreadableError) return t`That file couldn't be read as a PDF.`;
return t`Something went wrong while reading that file. Try again.`;
}
type AtsCheckerProps = {
signedIn: boolean;
/** Back from signing up with a file waiting to be imported. */
importPending: boolean;
};
/**
* The public checker: drop a PDF, watch three steps, then see the score, what could cost a match, and the file as
* software reads it. "Fix these in the editor" imports the same file (after signing up, if needed) and opens Check.
*/
export function AtsChecker({ signedIn, importPending }: AtsCheckerProps) {
const navigate = useNavigate();
const [state, setState] = useState<State>({ name: "idle" });
const [posting, setPosting] = useState("");
const abort = useRef<AbortController | null>(null);
const check = async (file: File) => {
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
setState({ name: "busy", file, step: 0 });
try {
const result = await runAtsCheck(file, {
jobDescription: posting,
signal: controller.signal,
onProgress: (progress) => setState({ name: "busy", file, step: stepOf(progress) }),
});
if (controller.signal.aborted) return;
setState({ name: "done", file, result });
} catch (error) {
if (!controller.signal.aborted) setState({ name: "idle", error: describeError(error) });
}
};
const importAndOpen = async (file: File) => {
setState({ name: "importing" });
try {
// Read in the browser, the same way the check did, so every issue lines up in Check.
const data = await readResumeFile(file, "pdf", { aiAvailable: false });
const resumeId = await client.resume.import({ data });
await navigate({ to: "/builder/$resumeId", params: { resumeId }, search: { mode: "check" } });
} catch (error) {
setState({
name: "idle",
error:
error instanceof ImportError
? error.message
: getOrpcErrorMessage(error, { fallback: t`Couldn't import the file. Try again from Documents.` }),
});
}
};
const fix = async (file: File) => {
if (signedIn) return importAndOpen(file);
await savePendingImport(file);
await navigate({ to: "/auth/register", search: { callbackURL: "/ats-checker?import=1" } });
};
// Signed up (or in) with a file waiting: import it straight away.
const resumed = useRef(false);
useEffect(() => {
if (!signedIn || !importPending || resumed.current) return;
resumed.current = true;
void takePendingImport().then((file) => {
if (file) void importAndOpen(file);
else void navigate({ to: "/ats-checker", replace: true });
});
});
useEffect(() => () => abort.current?.abort(), []);
if (state.name === "importing")
return (
<p role="status" className="flex items-center justify-center gap-2 py-24 text-ink-2">
<Spinner decorative className="size-4" />
<Trans>Importing it into your new resume…</Trans>
</p>
);
if (state.name === "busy") return <Progress file={state.file} step={state.step} />;
if (state.name === "done")
return (
<Result
result={state.result}
file={state.file}
onFix={() => void fix(state.file)}
onReset={() => setState({ name: "idle" })}
/>
);
return <Idle error={state.error} posting={posting} onPosting={setPosting} onFile={(file) => void check(file)} />;
}
type IdleProps = {
error?: string | undefined;
posting: string;
onPosting: (value: string) => void;
onFile: (file: File) => void;
};
function Idle({ error, posting, onPosting, onFile }: IdleProps) {
const id = useId();
const input = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const sample = async () => {
const blob = await (await fetch(SAMPLE.url)).blob();
onFile(new File([blob], SAMPLE.name, { type: "application/pdf" }));
};
return (
<div className="mx-auto grid w-full max-w-[640px] gap-5.5 px-4 py-16 sm:py-18">
<div className="grid justify-items-center gap-2.5 text-center">
<h1 className="font-display font-medium text-[34px] leading-10 sm:text-[44px] sm:leading-[48px]">
<Trans>Can software read your resume?</Trans>
</h1>
<p className="max-w-[520px] text-base text-ink-2 leading-[25px]">
<Trans>
Upload a PDF and see the text an applicant tracking system extracts: what's missing, what's out of order,
and how to fix it. Free, no account needed.
</Trans>
</p>
</div>
{/* A drop zone, not a form: the card takes a dropped file; its buttons take the keyboard. */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: dropping is pointer-only; "choose a file" is the keyboard path. */}
<div
className={cn(
"grid justify-items-center gap-2.5 rounded-[14px] border-[1.5px] border-line-2 border-dashed bg-surface p-9 text-center transition-colors duration-quick",
dragging && "border-accent bg-accent-soft",
)}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
const file = event.dataTransfer.files[0];
if (file) onFile(file);
}}
>
<Icon name="upload_file" size={36} className="text-accent-text" />
<button
type="button"
className="font-semibold text-base underline-offset-2 hover:underline"
onClick={() => input.current?.click()}
>
<Trans>Drop a PDF here or choose a file</Trans>
</button>
<span className="text-ink-3 text-sm">
<Trans>Up to 25 MB · checked in your browser, never uploaded</Trans>
</span>
<Button className="mt-1.5" onClick={() => void sample()}>
<Trans>Check a sample file</Trans>
</Button>
<input
ref={input}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
tabIndex={-1}
aria-hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onFile(file);
event.target.value = "";
}}
/>
</div>
{error && (
<p role="alert" className="rounded-lg bg-danger-soft px-3 py-2.5 text-danger-text text-sm">
{error}
</p>
)}
<div className="grid gap-1.5">
<label htmlFor={id} className="font-medium text-[13px]">
<Trans>
Job posting <span className="font-normal text-ink-3">· optional, adds a keyword match</span>
</Trans>
</label>
<Textarea
id={id}
rows={3}
maxLength={MAX_POSTING}
value={posting}
placeholder={t`Paste the posting to see which of its terms your resume already has.`}
onChange={(event) => onPosting(event.target.value)}
/>
</div>
</div>
);
}
function Progress({ file, step }: { file: File; step: number }) {
const steps = [t`Opening the file`, t`Reading the text`, t`Checking layout and content`];
return (
<div className="grid place-items-center px-4 py-24">
<div role="status" className="grid w-full max-w-[420px] gap-3.5">
<b className="truncate font-semibold text-[15px]">
<Trans>Checking {file.name}</Trans>
</b>
{steps.map((label, index) => (
<div
key={label}
className={cn("flex items-center gap-2.5 text-sm", index > step ? "text-ink-3" : "text-ink")}
>
<span className="flex w-5.5 justify-center">
{index < step ? (
<Icon name="check" size={20} className="text-accent-text" />
) : index === step ? (
<Spinner decorative className="size-4" />
) : (
<span aria-hidden className="size-3 rounded-full border-[1.5px] border-current" />
)}
</span>
{label}
</div>
))}
</div>
</div>
);
}
const verdict = (score: number) =>
score >= 80 ? t`Reads cleanly` : score >= 50 ? t`Mostly readable` : t`Hard for software to read`;
type Row = { key: string; title: string; count: string; tone: "danger" | "warn" | "accent"; body: React.ReactNode };
/** One row per category with something to fix, worst first, plus the posting's terms when one was pasted. */
export function issueRows(report: PdfAtsReport): Row[] {
const byCategory = new Map<PdfCategory, PdfAtsReport["findings"]>();
for (const finding of report.findings)
byCategory.set(finding.category, [...(byCategory.get(finding.category) ?? []), finding]);
const rows: Row[] = [...byCategory.entries()]
.map(([category, findings]) => ({
key: category,
title: getPdfCategoryLabel(category),
count: String(findings.length),
tone: findings.some((finding) => finding.severity === "blocker") ? ("danger" as const) : ("warn" as const),
body: (
<ul className="grid gap-2">
{findings.map((finding) => {
const message = getPdfFindingMessage(finding.code);
return (
<li key={finding.code}>
<b className="font-medium text-ink">{message.title}</b> {message.action}
</li>
);
})}
</ul>
),
}))
.sort((a, b) => (a.tone === b.tone ? 0 : a.tone === "danger" ? -1 : 1));
if (report.jd)
rows.push({
key: "posting",
title: t`Terms from the posting`,
count: `${report.jd.matchedCount}/${report.jd.totalTerms}`,
tone: "accent",
body:
report.jd.missingTerms.length > 0 ? (
<Trans>Not found: {report.jd.missingTerms.join(", ")}</Trans>
) : (
<Trans>Every term the posting stresses is already there.</Trans>
),
});
return rows;
}
type ResultProps = { result: AtsCheckResult; file: File; onFix: () => void; onReset: () => void };
function Result({ result, file, onFix, onReset }: ResultProps) {
const { report } = result;
const rows = issueRows(report);
const [open, setOpen] = useState<string | null>(rows[0]?.key ?? null);
const [lens, setLens] = useState<"page" | "text">("page");
const issues = report.findings.length;
return (
<div className="grid min-h-[calc(100svh-5rem)] lg:grid-cols-[440px_minmax(0,1fr)]">
<aside className="grid content-start gap-4.5 border-line bg-surface p-6 max-lg:pb-28 lg:border-e">
<div className="flex items-center gap-4">
<div
role="img"
aria-label={t`Readability score: ${report.score} out of 100`}
className="score-ring grid size-[84px] shrink-0 place-items-center rounded-full"
style={
{
"--ring-value": `${report.score}%`,
"--ring-color": report.score >= 80 ? "var(--accent)" : "var(--warn)",
} as CSSProperties
}
>
<span className="grid size-[70px] place-items-center rounded-full bg-surface font-display font-medium text-[26px]">
{report.score}
</span>
</div>
<div className="grid gap-1">
<b className="font-semibold text-base">{verdict(report.score)}</b>
<span className="text-[13px] text-ink-2 leading-[19px]">
{issues > 0 ? (
<Plural value={issues} one="# issue could cost you a match." other="# issues could cost you a match." />
) : (
<Trans>Nothing here should cost you a match.</Trans>
)}{" "}
<Trans>The score reflects how reliably text is extracted, not your chances.</Trans>
</span>
</div>
</div>
{report.document.truncated && (
<p className="text-ink-3 text-xs">
<Trans>Only the first {report.document.pageCount} pages were checked.</Trans>
</p>
)}
{rows.length > 0 && (
<div className="overflow-hidden rounded-xl border border-line">
{rows.map((row, index) => {
const expanded = open === row.key;
return (
<div key={row.key} className={cn(index > 0 && "border-line border-t")}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setOpen(expanded ? null : row.key)}
className="flex h-12 w-full items-center gap-2.5 px-3.5 text-start font-medium text-sm transition-colors duration-quick hover:bg-hover"
>
<Icon
name={row.tone === "accent" ? "work" : row.tone === "danger" ? "error" : "warning"}
size={20}
className={cn(
row.tone === "danger" && "text-danger-text",
row.tone === "warn" && "text-warn-text",
row.tone === "accent" && "text-accent-text",
)}
/>
{row.title}
<span className="text-ink-3 text-xs">{row.count}</span>
<Icon
name="expand_more"
size={20}
className={cn("ms-auto text-ink-3 transition-transform duration-quick", expanded && "rotate-180")}
/>
</button>
{expanded && (
<div className="px-3.5 ps-11 pb-3.5 text-[13px] text-ink-2 leading-[19px]">{row.body}</div>
)}
</div>
);
})}
</div>
)}
<div className="grid gap-2.5 rounded-xl bg-accent-soft p-4 max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:z-20 max-lg:rounded-none max-lg:border-line max-lg:border-t max-lg:bg-surface max-lg:pb-[max(1rem,env(safe-area-inset-bottom))]">
<b className="font-semibold text-[15px] max-lg:hidden">
<Trans>Fix these in a few minutes</Trans>
</b>
<span className="text-[13px] text-ink-2 leading-[19px] max-lg:hidden">
<Trans>Import this file into a free Reactive Resume. Every issue is waiting for you in Check.</Trans>
</span>
<Button className="h-10" onClick={onFix}>
<Trans>Fix these in the editor</Trans>
</Button>
</div>
<button type="button" className="w-fit text-ink-2 text-sm underline underline-offset-2" onClick={onReset}>
<Trans>Check another file</Trans>
</button>
</aside>
<section aria-label={t`Your file`} className="grid content-start bg-sunken">
<div className="flex justify-center p-4">
<Tabs value={lens} onValueChange={(value) => setLens(value as "page" | "text")}>
<TabsList aria-label={t`How to show the file`}>
<TabsTrigger value="page">
<Trans>Original page</Trans>
</TabsTrigger>
<TabsTrigger value="text">
<Trans>As software reads it</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex justify-center px-4 pb-10 sm:px-10">
{lens === "page" ? (
<div className="w-full max-w-[612px] bg-white shadow-e2">
<PdfViewer file={file} className="block w-full" />
</div>
) : (
<pre className="w-full max-w-[612px] whitespace-pre-wrap rounded-[10px] border border-line bg-raised px-7 py-6 font-mono text-[13px] leading-[21px]">
{result.fullText || t`No text could be read from this file.`}
</pre>
)}
</div>
</section>
</div>
);
}
@@ -13,7 +13,7 @@ import { HARVEST_DEFAULTS, harvestPdfDocument } from "@reactive-resume/resume/at
*/
/** 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;
const MAX_UPLOAD_BYTES = 25_000_000;
export type ExtractProgress = HarvestProgress | { phase: "loading"; page: 0; pageCount: 0 };
@@ -1,100 +0,0 @@
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 single-column export keeps every fact. Columns and images are where facts 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,39 @@
/**
* "Fix these in the editor" survives signing up: the checked file waits in this browser's IndexedDB until the
* visitor comes back signed in, then it's imported and removed. It never leaves the browser before that.
*/
const DB = "reactive-resume";
const STORE = "pending-import";
const KEY = "ats-checker";
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function run<T>(mode: IDBTransactionMode, work: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
const db = await open();
try {
return await new Promise<T>((resolve, reject) => {
const request = work(db.transaction(STORE, mode).objectStore(STORE));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} finally {
db.close();
}
}
export const savePendingImport = (file: File) =>
run("readwrite", (store) => store.put(file, KEY)).then(() => undefined);
/** The waiting file, removed as it's read so it's imported once. */
export async function takePendingImport(): Promise<File | null> {
const file = await run<unknown>("readonly", (store) => store.get(KEY));
await run("readwrite", (store) => store.delete(KEY));
return file instanceof File ? file : null;
}
-135
View File
@@ -1,135 +0,0 @@
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="secondary" 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="error">
<WarningCircleIcon />
<AlertDescription>{errorMessage(error)}</AlertDescription>
</Alert>
)}
{result && (
<>
<AtsPdfReportView report={result.report} />
{renderAiTier?.({ result, jobDescription })}
</>
)}
</div>
);
}
@@ -1,77 +0,0 @@
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="secondary"
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>
);
}
@@ -14,13 +14,12 @@ GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.
type PdfViewerProps = {
className?: string;
data: ResumeData;
includeCoverLetterHeader?: boolean;
publicResume?: {
username: string;
slug: string;
};
};
} & ({ data: ResumeData; file?: never } | { file: Blob; data?: never });
type PdfViewerOptions = ConstructorParameters<typeof PDFViewer>[0] & {
abortSignal: AbortSignal;
@@ -73,7 +72,14 @@ function pdfViewerReducer(state: PdfViewerState, action: PdfViewerAction): PdfVi
}
}
export function PdfViewer({ className, data, publicResume, includeCoverLetterHeader }: PdfViewerProps) {
/** Renders a resume's PDF from its data, or an existing PDF file (the ATS checker's upload). */
export function PdfViewer({
className,
data,
file: givenFile,
publicResume,
includeCoverLetterHeader,
}: PdfViewerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<HTMLDivElement>(null);
@@ -89,7 +95,9 @@ export function PdfViewer({ className, data, publicResume, includeCoverLetterHea
fileRef.current = null;
dispatch({ type: "resetForData" });
const createPdf = () => {
const createPdf = (): Promise<Blob> => {
if (givenFile) return Promise.resolve(givenFile);
if (!data) return Promise.reject(new Error("PdfViewer needs data or a file."));
if (publicResume) return resolvePublicResumePdfBlob({ data, publicResume });
return includeCoverLetterHeader
? createResumePdfBlob(data, undefined, { includeCoverLetterHeader: true })
@@ -113,7 +121,7 @@ export function PdfViewer({ className, data, publicResume, includeCoverLetterHea
return () => {
isCancelled = true;
};
}, [data, publicResume, includeCoverLetterHeader]);
}, [data, givenFile, publicResume, includeCoverLetterHeader]);
useEffect(() => {
void fileVersion;
+18 -170
View File
@@ -1,27 +1,21 @@
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 { EASE_OUT_STRONG } from "@/libs/motion";
import { Footer } from "./-sections/footer";
import z from "zod";
import { Spinner } from "@reactive-resume/ui/components/spinner";
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 })),
// The checker pulls in PDF.js and the analysis engine, neither of which the page shell needs.
const AtsChecker = lazy(() =>
import("@/features/ats-checker/checker").then((module) => ({ default: module.AtsChecker })),
);
export const Route = createFileRoute("/_home/ats-checker")({
component: RouteComponent,
// Back from signing up with a checked file to import.
validateSearch: z.object({ import: z.coerce.boolean().optional().catch(undefined) }),
head: () => {
const origin = typeof window === "undefined" ? "https://rxresu.me" : window.location.origin;
const canonicalUrl = new URL("/ats-checker", origin).toString();
@@ -49,166 +43,20 @@ export const Route = createFileRoute("/_home/ats-checker")({
function RouteComponent() {
const { session } = Route.useRouteContext();
const { import: importPending } = Route.useSearch();
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>
// The marketing header is fixed and 65px tall, so the page makes room for it.
<main id="main-content" className="min-h-svh bg-bg pt-20">
<Suspense
fallback={
<div className="grid place-items-center py-24">
<Spinner className="size-5" />
</div>
<Footer />
</div>
</div>
}
>
<AtsChecker signedIn={Boolean(session)} importPending={importPending === true} />
</Suspense>
</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"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: EASE_OUT_STRONG }}
>
<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 reads from it. Everything runs in this browser.
Your file is never uploaded, and nothing is stored.
</Trans>
</p>
</m.div>
<m.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, delay: 0.12, ease: EASE_OUT_STRONG }}
>
<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-accent-text" />}
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-danger-text" />}
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-info-text" />}
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>
</>
);
}