Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/components/features/JdMatchHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The offlinecv Authors

/**
* JdMatchHeader — the shared heading of the JD-match card (#866 review).
*
* `KeywordMatch` and `SemanticMatch` opened with a byte-identical `<header>` +
* title row: the `JD match` `<h2>` and the `alpha` pill. #204 created that
* duplicate honestly — the keyword body was moved VERBATIM out of the old
* `JdMatch.tsx` and the semantic view was written to match it — but nothing
* held the two together afterwards, so renaming `alpha` (or retiring it, which
* is the likeliest edit) in one file would silently leave the other behind.
* The two views are peers rendered by the same router; a user toggling between
* them would see the panel rename itself.
*
* ## What it deliberately does NOT absorb
*
* The `<Card>` wrapper, the arm-specific headline and disclaimer paragraphs,
* and everything below the header stay in the views. The duplication worth
* removing is the COPY — the strings that must not drift — not the layout: a
* reader of `KeywordMatch` should still see its card chrome, its two-column
* grid and its own disclaimer without following an import. So this takes the
* arm's own header lines as `children` and adds only the title row above them,
* which keeps each view's `<Card className="… gap-4 shadow-xs">` visible at
* its own call site.
*
* Feature-area, not `@design-system`: one heading shared by two siblings in the
* same lane is not a design-system concern, and promoting it would mint a
* primitive with two callers and a hardcoded product string in it.
*/

import type { ReactNode } from "react";

export function JdMatchHeader({ children }: { children: ReactNode }) {
return (
<header className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-content-muted">
JD match
</h2>
<span className="rounded bg-surface-subtle px-1.5 py-0.5 text-4xs font-semibold uppercase tracking-wider text-content-secondary">
alpha
</span>
</div>
{children}
</header>
);
}
21 changes: 5 additions & 16 deletions src/components/features/KeywordMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,18 @@
*/

import type { ExtractedTerm } from "../../lib/jd-match/extract-jd-terms.ts";
import type { JdMatchResult } from "../../lib/jd-match";
import type { KeywordJdMatchResult } from "../../lib/jd-match";
import { JdMatchHeader } from "./JdMatchHeader.tsx";
import { Card } from "@design-system";

/** The keyword arm of the union — same `Extract<…>` idiom `rank.ts` uses for
* `KeywordJdMatch`, so the two narrowings cannot drift. */
type KeywordResult = Extract<JdMatchResult, { path: "keyword" }>;

export function KeywordMatch({ result }: { result: KeywordResult }) {
export function KeywordMatch({ result }: { result: KeywordJdMatchResult }) {
const { coverage, terms, nounsDropped } = result;
const total = terms.length;
const covered = coverage.covered.length;

return (
<Card className="flex flex-col gap-4 shadow-xs">
<header className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-content-muted">
JD match
</h2>
<span className="rounded bg-surface-subtle px-1.5 py-0.5 text-4xs font-semibold uppercase tracking-wider text-content-secondary">
alpha
</span>
</div>
<JdMatchHeader>
<p className="text-base font-semibold text-content-primary">
Your resume mentions {covered} of {total} terms from this JD.
</p>
Expand All @@ -66,7 +55,7 @@ export function KeywordMatch({ result }: { result: KeywordResult }) {
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>
</JdMatchHeader>

<div className="grid gap-4 md:grid-cols-2">
<TermColumn
Expand Down
3 changes: 1 addition & 2 deletions src/components/features/PasteJdPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,11 @@ export function PasteJdPanel({ parsed, onTailor }: PasteJdPanelProps) {
// occupied by `loading`/`running` for the whole engine load while keyword
// coverage is already available. Reading `keyword` means this panel keeps
// showing coverage through that window instead of blanking.
const { status, keyword, capability } = useJdMatch({
const { status, keyword: jdMatch, capability } = useJdMatch({
parsed,
jdText,
semanticOptIn,
});
const jdMatch = keyword?.path === "keyword" ? keyword : null;

// What the card renders. Semantic verdicts REPLACE the keyword columns, but
// only once a semantic run has actually finished — every other state
Expand Down
154 changes: 14 additions & 140 deletions src/components/features/SemanticAnalysisOptIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,41 +40,16 @@
* panel, no `localStorage` — because #204 asks for an opt-in toggle and
* nothing in the issue or the repo asks a JD panel to remember it.
*
* ## No-WebGPU is not an error
*
* `WebGpuUnavailableNotice` is the repo's other answer to "no WebGPU", and it
* is the wrong one here: it renders a warning-toned strip with a how-to-enable
* dialog and fires `webllm_notice_shown`. #204 asks for the opposite — the
* keyword columns keep rendering, with at most one muted line saying why the
* box did nothing. A user whose browser can't run this still has the whole
* panel they came for, so nothing is in a failed state.
*
* ## Known limitation: the progress bar can sit at 0% (#804)
*
* `loadEngine`'s "already pending" fast path returns the shared promise
* without registering the new caller's `onProgress`, so only the FIRST caller
* for a model id ever receives progress. The reachable case here is
* self-inflicted rather than the cross-consumer one #804 describes — that one
* cites `job-search/sector.ts`'s `classifySector`, which has no production
* caller, and `/jobs/` has no other `loadEngine` caller at all. What IS
* reachable: opt in, then edit the JD while the weight download is still in
* flight. The superseded run owns `initProgressCallback`, its writes are
* dropped by the controller's id guard, and the new run joined a load it can't
* hear — so the bar reads 0% until the load resolves, then moves on to
* running → ready normally.
*
* Cosmetic, not functional, and deliberately NOT worked around here: the fix
* is a progress fan-out inside `web-llm.ts`, shared by every WebLLM surface in
* the repo, which is #804's scope and not this component's. The reason it is
* tolerable meanwhile is the keyword floor — the result card below keeps
* showing full coverage throughout, so a stalled bar costs the user a progress
* readout, never the answer they came for.
* The lifecycle line itself — every state it can be in, why the capability
* check has to precede the status switch, and the #804 progress-fidelity
* limitation — lives in `SemanticAnalysisStatus`, extracted in the #866 review
* follow-up so this file stays inside CLAUDE.md's ~200 LOC guideline.
*/

import type { ReactNode } from "react";
import { Checkbox, ModelLoadProgress } from "@design-system";
import { Checkbox } from "@design-system";
import type { JdMatchStatus } from "../../hooks/useJdMatch.ts";
import type { WebGpuCapability } from "../../lib/webllm/types.ts";
import { SemanticAnalysisStatus } from "./SemanticAnalysisStatus.tsx";

interface SemanticAnalysisOptInProps {
/** Controlled opt-in state; owned by `PasteJdPanel`. */
Expand All @@ -101,115 +76,14 @@ export function SemanticAnalysisOptIn({
label="Analyze with on-device AI"
hint="Judges each requirement against your résumé instead of matching terms by name. One-time model download; your JD text still never leaves this tab."
/>
<StatusLine checked={checked} status={status} capability={capability} />
{/* The `checked` gate stays HERE rather than moving into the status
component: this file owns `checked`, and passing it down only to
have the child early-return would be prop plumbing for nothing.
Unticked therefore renders no line at all, so the default panel is
unchanged from its pre-#204 self down to the DOM. */}
{checked && (
<SemanticAnalysisStatus status={status} capability={capability} />
)}
</div>
);
}

/** Muted one-liner — the tone for "this is information, not a problem". */
function Note({ children }: { children: ReactNode }) {
return (
<p role="status" className="text-sm text-content-tertiary">
{children}
</p>
);
}

function StatusLine({
checked,
status,
capability,
}: {
checked: boolean;
status: JdMatchStatus;
capability: WebGpuCapability | null;
}) {
// Unticked: render nothing at all, so the default panel is unchanged from
// its pre-#204 self down to the DOM.
if (!checked) return null;
// No JD yet (or one that extracted no terms) — there is nothing to analyze,
// so a progress line would be describing work that isn't happening.
if (status.kind === "idle") return null;

// Capability first: with the probe unresolved OR resolved-unavailable, the
// hook's `status` is `ready` holding the KEYWORD result, which is
// indistinguishable from a semantic run that degraded. Only `capability`
// separates them, which is why the controller exposes it.
if (capability === null) {
return <Note>Checking whether this browser can run on-device analysis…</Note>;
}
if (capability !== "available") {
return (
<Note>
This browser can't run on-device analysis (it needs WebGPU) — the
keyword coverage below is unaffected.
</Note>
);
}

if (status.kind === "loading") {
return (
<ModelLoadProgress
progress={status.progress.progress}
text={status.progress.text}
label="Loading the on-device model (one-time download)"
// No `showExplainer`: the checkbox hint directly above already states
// that the model downloads and that the text stays in the tab, and
// `ModelLoadProgress`'s own docblock makes the explainer opt-in so
// that a caller with its own context doesn't double up.
/>
);
}

if (status.kind === "running") {
// Generic on purpose. #204's example copy ("Judging requirement 4 of 9…")
// has nothing behind it: `judgeEvidence` takes no progress callback and
// `runLlmMatch` reports only engine load + a single `onInferenceStart`, so
// a count here would be invented. Adding a batch-progress API to the LLM
// layer to satisfy one string is not warranted; truthful copy is.
return (
<p role="status" className="text-sm text-content-secondary">
Reading this JD and checking it against your résumé…
</p>
);
}

if (status.kind === "error") {
// Not reachable from today's controller — a semantic run only starts when
// a keyword result already exists, and the hook's catch degrades to that
// rather than to `error` (see its state-machine docblock). Rendered anyway
// because the state is public API, and a UI that dropped it would blank
// the panel the day a semantic-only consumer reaches it. So the copy
// promises no keyword fallback: in that consumer there wouldn't be one.
//
// The controller's `message` is deliberately NOT rendered: its realistic
// source is a chunk-loader failure after a deploy, whose text is a hashed
// asset URL rather than anything a user can act on. The retry it offers is
// real — the hook clears an `error` slot on the way out of the semantic
// path, so re-ticking starts a fresh run.
return (
<p role="alert" className="text-sm text-feedback-warning-text">
On-device analysis couldn't start. Untick the box and tick it again to
retry.
</p>
);
}

// `ready` on the semantic path with a KEYWORD result: the run completed and
// `runLlmMatch` degraded internally (engine load failure, unparseable
// extraction, or a JD it found no requirements in). Note that a CANCELLED
// run never lands here — `useJdMatch` bumps its request id before aborting,
// so the abandoned run's keyword fallback fails the write guard and is never
// shown. That is what keeps this line off the screen on every opt-out, JD
// edit and model change (#803).
if (status.result.path === "keyword") {
return (
<Note>
On-device analysis didn't return a verdict for this JD — showing keyword
coverage instead.
</Note>
);
}
// Semantic verdicts are on screen in the card below; nothing to add.
return null;
}
Loading
Loading