Skip to content
Merged
48 changes: 47 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// 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 { runCascade } from "./lib/heuristics";
import type { CascadeResult } from "./lib/heuristics/types.ts";
import {
Expand All @@ -16,6 +17,7 @@ import {
trackParseCompleted,
trackParseFailed,
} from "./lib/analytics.ts";
import { extractJdTerms, computeCoverage } from "./lib/jd-match";

type ParseState =
| { phase: "idle" }
Expand All @@ -38,6 +40,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 +159,39 @@ export default function App() {
/>
)}

<section className="flex flex-col gap-3 rounded-xl border border-border-light bg-surface-card p-5 shadow-sm">

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.

[Suggestion]: This card chrome (rounded-xl border border-border-light bg-surface-card p-5 shadow-sm) is now duplicated 5× across Result.tsx (×2), ContactCard.tsx, JdMatch.tsx, and here. There's no src/components/shared/ yet, so this matches existing convention rather than introducing new drift — but per the 3-tier arch in CLAUDE.md a shared/Card would let all five share one definition. Pre-existing debt; flagging since this PR adds two more copies.

<div className="flex flex-col gap-1">
<h2 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…"
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>
)}
</section>

{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
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}"`);
});
});
136 changes: 136 additions & 0 deletions src/components/features/JdMatch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The resumelint Authors

/**
* JdMatch — diagnostic JD-coverage panel.
*
* Renders the covered/missing lists from `computeCoverage` against the
* extracted JD terms. Framing is diagnostic ("the JD asks for these; here's
* what we found"), not prescriptive ("add this to your resume"). The score
* is shown as N-of-M skill coverage, not as a percentage match label.
*/

import type { ExtractedTerm } from "../../lib/jd-match/extract-jd-terms.ts";
import type { CoverageResult } from "../../lib/jd-match/coverage.ts";

interface JdMatchProps {
coverage: CoverageResult;
terms: readonly ExtractedTerm[];
/** How many noun-pass terms the extractor silenced past its cap. When > 0,
* the UI surfaces a footnote so the user knows the panel isn't exhaustive. */
nounsDropped?: number;
}

export function JdMatch({ coverage, terms, nounsDropped = 0 }: JdMatchProps) {
const total = terms.length;
const covered = coverage.covered.length;

return (
<section className="flex flex-col gap-4 rounded-xl border border-border-light bg-surface-card p-5 shadow-sm">
<header className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-content-muted">
JD match
</h2>
<span className="rounded bg-surface-subtle px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-content-secondary">
alpha
</span>
</div>
<p className="text-base font-semibold text-content-primary">
Your resume mentions {covered} of {total} terms from this JD.
</p>
<p className="text-xs text-content-tertiary">
Weighted coverage:{" "}
<span className="font-mono text-content-secondary">
{coverage.score}/100
</span>{" "}
— skill {coverage.weights.skill.toFixed(1)}, phrase{" "}
{coverage.weights.noun.toFixed(1)}.
</p>
<p className="max-w-prose text-xs text-content-tertiary">
Diagnostic, not a verdict. We look for skills and phrases by name —
we don't read context. Your JD text stays in this browser tab.
</p>
</header>

<div className="grid gap-4 md:grid-cols-2">
<TermColumn
heading={`Covered (${coverage.covered.length})`}
tone="covered"
terms={coverage.covered}
emptyCopy="None of the JD terms we extracted show up in the resume text."
/>
<TermColumn
heading={`Missing (${coverage.missing.length})`}
tone="missing"
terms={coverage.missing}
emptyCopy="Every term we extracted shows up somewhere in the resume."
/>
</div>

{nounsDropped > 0 && (
<p className="text-[11px] text-content-muted">
+{nounsDropped} more capitalized phrase{nounsDropped === 1 ? "" : "s"}{" "}
in this JD weren't surfaced — the noun-phrase pass keeps the first
ones it finds and drops the rest to keep the panel readable.
</p>
)}
</section>
);
}

function TermColumn({
heading,
tone,
terms,
emptyCopy,
}: {
heading: string;
tone: "covered" | "missing";
terms: readonly ExtractedTerm[];
emptyCopy: string;
}) {
return (
<section className="flex flex-col gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-content-muted">
{heading}
</h3>
{terms.length === 0 ? (
<p className="text-xs text-content-tertiary">{emptyCopy}</p>
) : (
<ul className="flex flex-col gap-1">
{terms.map((term) => (
<TermRow key={`${term.source}:${term.id}`} term={term} tone={tone} />
))}
</ul>
)}
</section>
);
}

function TermRow({
term,
tone,
}: {
term: ExtractedTerm;
tone: "covered" | "missing";
}) {
const marker = tone === "covered" ? "✓" : "•";
const markerCls =
tone === "covered"
? "text-feedback-success-text"
: "text-content-muted";
const sourceLabel = term.source === "skill" ? "skill" : "phrase";
return (
<li
className="flex items-baseline gap-2 rounded border border-border-light px-2 py-1.5"
title={term.snippet}
>
<span className={`text-sm font-semibold ${markerCls}`}>{marker}</span>
<span className="text-sm text-content-primary">{term.display}</span>
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-content-muted">
{sourceLabel}
</span>
</li>
);
}
Loading