Skip to content
Merged
53 changes: 52 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The resumelint Authors

import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Chip } from "./components/ui/Chip.tsx";
import { DropZone } from "./components/DropZone";
import { Result } from "./components/Result";
import { JdMatch } from "./components/features/JdMatch.tsx";
import { Card } from "./components/shared/Card.tsx";
import { runCascade } from "./lib/heuristics";
import type { CascadeResult } from "./lib/heuristics/types.ts";
import {
Expand All @@ -16,6 +18,7 @@ import {
trackParseCompleted,
trackParseFailed,
} from "./lib/analytics.ts";
import { extractJdTerms, computeCoverage } from "./lib/jd-match";

type ParseState =
| { phase: "idle" }
Expand All @@ -38,6 +41,17 @@ function formatBytes(n: number): string {

export default function App() {
const [state, setState] = useState<ParseState>({ phase: "idle" });
const [jdText, setJdText] = useState("");

const jdMatch = useMemo(() => {
const trimmed = jdText.trim();
if (trimmed.length === 0) return null;
if (state.phase !== "done") return null;
const extracted = extractJdTerms(trimmed);
if (extracted.all.length === 0) return null;
const coverage = computeCoverage(state.result.parsed, extracted.all);
return { extracted, coverage };
}, [jdText, state]);

const handleFile = useCallback(async (file: File) => {
trackFileAccepted(file.size);
Expand Down Expand Up @@ -146,6 +160,43 @@ export default function App() {
/>
)}

<Card className="flex flex-col gap-3 shadow-sm">
<div className="flex flex-col gap-1">
<h2
id="jd-input-label"
className="text-xs font-semibold uppercase tracking-wider text-content-muted"
>
Paste a job description
</h2>
<p className="max-w-prose text-xs text-content-tertiary">
We'll lint your resume against the JD's skills and key phrases.
Diagnostic, not tailoring — your JD text stays in this browser
tab.
</p>
</div>
<textarea

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit]: The textarea has a placeholder and a sibling <h2>, but no programmatic label — a screen reader announces only the placeholder text. Consider aria-label="Job description" (or wire the <h2> via id + aria-labelledby).

value={jdText}
onChange={(e) => setJdText(e.target.value)}
placeholder="Paste the job description here…"
aria-labelledby="jd-input-label"
className="min-h-[160px] resize-y rounded-lg border border-border-light bg-surface-subtle p-3 text-sm leading-relaxed text-content-primary placeholder:text-content-muted focus:border-border focus:outline-none"
/>
{jdText.trim().length > 0 && state.phase !== "done" && (
<p className="text-xs text-content-muted">
Drop a resume above to see what the JD asks for that's not in
your resume.
</p>
)}
</Card>

{jdMatch && (
<JdMatch
coverage={jdMatch.coverage}
terms={jdMatch.extracted.all}
nounsDropped={jdMatch.extracted.nounsDropped}
/>
)}

<footer className="mt-auto flex flex-col gap-2 border-t border-neutral-200 pt-6 text-xs text-neutral-600 dark:border-neutral-800 dark:text-neutral-400">
<p>Your PDF stays in this browser tab.</p>
<div className="flex flex-wrap gap-x-4 gap-y-1">
Expand Down
9 changes: 5 additions & 4 deletions src/components/Result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ScoreRing } from "./features/ScoreRing.tsx";
import { VerdictHeader } from "./features/VerdictHeader.tsx";
import type { VerdictDimension } from "./features/VerdictHeader.tsx";
import { ContactCard } from "./features/ContactCard.tsx";
import { Card } from "./shared/Card.tsx";
import { FeedbackControl } from "./features/FeedbackControl.tsx";
import {
scoreBandTextClass,
Expand Down Expand Up @@ -76,7 +77,7 @@ function ParsedCard({
onReset: () => void;
}) {
return (
<section className="flex flex-col gap-6 rounded-xl border border-border-light bg-surface-card p-5 shadow-sm">
<Card className="flex flex-col gap-6 shadow-sm">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusPill tone="ok">Parsed</StatusPill>
Expand Down Expand Up @@ -129,7 +130,7 @@ function ParsedCard({
</div>
</div>
</section>
</section>
</Card>
);
}

Expand Down Expand Up @@ -493,7 +494,7 @@ function LimitedParsingCard({
const uniqueUrls = Array.from(new Set(links.map((l) => l.url)));

return (
<section className="flex flex-col gap-5 rounded-xl border border-border-light bg-surface-card p-5 shadow-sm">
<Card className="flex flex-col gap-5 shadow-sm">
<header className="flex items-center justify-between">
<StatusPill tone="limited">Limited parsing</StatusPill>
<button
Expand Down Expand Up @@ -552,6 +553,6 @@ function LimitedParsingCard({
{LAYOUT_TRIGGER_BLURBS.fonts_unmappable}
</p>
</section>
</section>
</Card>
);
}
8 changes: 3 additions & 5 deletions src/components/features/ContactCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import type { CascadeResult } from "../../lib/heuristics/types.ts";
import { buildContactFields } from "../../lib/contact.ts";
import { Chip } from "../ui/Chip.tsx";
import { Card } from "../shared/Card.tsx";

interface ContactCardProps {
result: CascadeResult;
Expand All @@ -22,10 +23,7 @@ export function ContactCard({ result }: ContactCardProps) {
const detectedCount = fields.filter((f) => !f.gated).length;

return (
<section
id="contact"
className="scroll-mt-6 rounded-xl border border-border-light bg-surface-card p-5"
>
<Card id="contact" className="scroll-mt-6">
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-content-muted">
Contact — {detectedCount} of 5 detected
</h2>
Expand All @@ -43,6 +41,6 @@ export function ContactCard({ result }: ContactCardProps) {
),
)}
</div>
</section>
</Card>
);
}
109 changes: 109 additions & 0 deletions src/components/features/JdMatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The resumelint Authors

import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { JdMatch } from "./JdMatch.tsx";
import type { ExtractedTerm } from "../../lib/jd-match/extract-jd-terms.ts";
import type { CoverageResult } from "../../lib/jd-match/coverage.ts";

function term(
id: string,
display: string,
source: ExtractedTerm["source"],
): ExtractedTerm {
return { id, display, source, snippet: `…snippet for ${display}…` };
}

describe("JdMatch", () => {
it("renders an N-of-M headline rather than a percent-match label", () => {
const covered = [term("react", "react", "skill")];
const missing = [
term("kubernetes", "kubernetes", "skill"),
term("Distributed Systems", "Distributed Systems", "noun"),
];
const terms = [...covered, ...missing];
const coverage: CoverageResult = {
covered,
missing,
score: 25,
weights: { skill: 1, noun: 0.5 },
};
const html = renderToStaticMarkup(createElement(JdMatch, { coverage, terms }));
expect(html).toContain("Your resume mentions 1 of 3 terms from this JD.");
expect(html).not.toMatch(/\d+%\s*match/i);
});

it("flags the diagnostic framing, not 'will pass ATS' framing", () => {
const coverage: CoverageResult = {
covered: [],
missing: [],
score: 0,
weights: { skill: 1, noun: 0.5 },
};
const html = renderToStaticMarkup(createElement(JdMatch, { coverage, terms: [] }));
expect(html.toLowerCase()).toContain("diagnostic, not a verdict");
expect(html.toLowerCase()).not.toMatch(/will\s+(pass|fail)/);
expect(html.toLowerCase()).not.toContain("ats");
});

it("renders covered and missing terms with their display strings", () => {
const covered = [term("react", "react", "skill")];
const missing = [term("kubernetes", "kubernetes", "skill")];
const terms = [...covered, ...missing];
const coverage: CoverageResult = {
covered,
missing,
score: 50,
weights: { skill: 1, noun: 0.5 },
};
const html = renderToStaticMarkup(createElement(JdMatch, { coverage, terms }));
expect(html).toContain("Covered (1)");
expect(html).toContain("Missing (1)");
expect(html).toContain(">react<");
expect(html).toContain(">kubernetes<");
});

it("surfaces the '+N more' footnote when noun-pass cap silences hits", () => {
const coverage: CoverageResult = {
covered: [],
missing: [],
score: 0,
weights: { skill: 1, noun: 0.5 },
};
const html = renderToStaticMarkup(
createElement(JdMatch, { coverage, terms: [], nounsDropped: 7 }),
);
expect(html).toContain("+7 more capitalized phrases");
});

it("omits the footnote when no hits were silenced", () => {
const coverage: CoverageResult = {
covered: [],
missing: [],
score: 0,
weights: { skill: 1, noun: 0.5 },
};
const html = renderToStaticMarkup(
createElement(JdMatch, { coverage, terms: [], nounsDropped: 0 }),
);
expect(html).not.toContain("not surfaced");
expect(html).not.toContain("not shown");
expect(html).not.toMatch(/\+\d+ more/);
});

it("emits the snippet on the term row as a hover tooltip (title attribute)", () => {
const t = term("react", "react", "skill");
const coverage: CoverageResult = {
covered: [t],
missing: [],
score: 100,
weights: { skill: 1, noun: 0.5 },
};
const html = renderToStaticMarkup(
createElement(JdMatch, { coverage, terms: [t] }),
);
expect(html).toContain(`title="${t.snippet}"`);
});
});
Loading