Skip to content

feat(jd-match): semantic verdict UI, on-device opt-in, keyword fallback view (#204) - #866

Merged
Vaishnavi1709 merged 1 commit into
mainfrom
gh-204-jdmatch-semantic-ui
Aug 18, 2026
Merged

feat(jd-match): semantic verdict UI, on-device opt-in, keyword fallback view (#204)#866
Vaishnavi1709 merged 1 commit into
mainfrom
gh-204-jdmatch-semantic-ui

Conversation

@Vaishnavi1709

Copy link
Copy Markdown
Collaborator

Summary

JdMatch.tsx returned null for path: "semantic", so a finished on-device match rendered a blank panel, and semanticOptIn had no route to a user — PasteJdPanel called useJdMatch without it, hardwiring the default false. This closes both.

JdMatch becomes a router on the JdMatchResult discriminant; the pre-#204 body moves to KeywordMatch verbatim; SemanticMatch renders verdicts grouped Met → Partial → Missing; PasteJdPanel owns the opt-in boolean and renders semanticResult ?? keyword, so the keyword floor survives every other state. useJdMatch gains one field (capability) — with opt-in on and WebGPU absent, status settles on ready-with-keyword, indistinguishable from opt-in-off and from a degraded run, and the UI needs that difference to explain why ticking the box changed nothing. No orchestration moved into a component.

Three places where the June issue text no longer matches the repo, adapted rather than followed:

#804 assessment (not fixed here, deliberately): its stated scenario is stale — it cites job-search/sector.ts's classifySector, which has no production caller (useCompanyTargets uses classifySectorHeuristic), and /jobs/ has no other loadEngine caller at all. The reachable variant is self-inflicted: edit the JD while the weight download is in flight and the new run joins a load whose onProgress it cannot hear, so the bar reads 0% until the load resolves. Cosmetic, not functional — the keyword floor stays on screen throughout — and the fix is a progress fan-out inside web-llm.ts shared by every WebLLM surface, which is #804's scope. Recorded in SemanticAnalysisOptIn's docblock.

Closes #204

Review focus

  • src/components/features/PasteJdPanel.tsx:96semanticResult ?? jdMatch is the whole no-stale-verdict guarantee at the UI layer. Does every non-ready state really fall through to the keyword floor, including opt-out after a completed run?
  • src/components/features/SemanticAnalysisOptIn.tsx:126StatusLine checks capability before it switches on status.kind. Invert that order and the no-WebGPU case renders the "didn't return a verdict" degrade note instead. Is the ordering obvious enough to survive a later edit?
  • src/components/features/SemanticAnalysisOptIn.tsx:196 — the degrade note is suppressed on a cancelled run only because useJdMatch bumps its request id before aborting, so the abandoned run's keyword fallback fails the write guard. Is that reasoning right, or can a superseded run still land a ready-with-keyword slot?
  • src/design-system/shared/StatusBadge.tsx:47neutral is bg-surface-subtle text-content-muted. Muted-on-subtle is deliberately low-contrast; is it still legible enough for a badge that carries meaning, in both themes?

Test plan

  • npm run typecheck clean
  • npm run lint clean
  • npm run verify green (also enforced by the pre-push hook on this branch)
  • npm test full suite green — 367 files / 5960 tests, 0 failures
  • npx fallow audit --base origin/main — no issues in 13 changed files
  • Keyword parity proven, not asserted: golden strings in KeywordMatch.parity.test.tsx were captured by rendering the pre-JD-match: JdMatch.tsx semantic verdict UI + opt-in toggle + keyword fallback view #204 component, and the new render is byte-identical across three shapes (populated + footnote, both empty states, singular footnote fork)
  • Fail-before validation — 10 regressions injected and reverted, each confirmed to fail the test that claims to catch it: semantic route → null; unconditional detectWebGpu; previousController?.abort() removed; request-id guard removed; keyword copy changed; running copy → "Judging requirement 4 of 9…"; keyword floor blanked during load; evidence made hover-only; sr-only name qualifier dropped; row status word dropped
  • Bundle: /jobs/ +4.4 kB raw / +1.0 kB gzip. / shrank 1.8 kB — bisected across five builds to ModelLoadProgress hoisting out of main-*.js into the shared chunk now that both entries use it. run-llm-match and web-llm remain lazy chunks; zero WebLLM strings in the /jobs/ entry

…ck view (#204)

`JdMatch.tsx` returned `null` for `path: "semantic"`, so a finished on-device
match rendered a blank panel, and `semanticOptIn` had no route to a user —
`PasteJdPanel` called `useJdMatch` without it, hardwiring the default `false`.
This closes both.

`JdMatch` becomes a router on the `JdMatchResult` discriminant. The keyword
early-return leaves the semantic arm as the only remaining type, so the
fall-through type-checks because TypeScript proved it; a third arm would break
this file at compile time rather than fall into the semantic view.

The pre-#204 body moves to `KeywordMatch` verbatim — same elements, classes,
copy, empty states and `title` snippets. It is the default experience for every
user who never opts in AND the fallback floor for every user who does, so
`KeywordMatch.parity.test.tsx` pins its markup against golden strings captured
by rendering the OLD component, not by snapshotting the new one.

`SemanticMatch` groups verdicts Met → Partial → Missing in a fixed reading
order, omitting empty groups. Status is carried three ways and never by colour
alone: the group heading's word, a per-row `StatusBadge`, and the heading tint.
`StatusBadge` gains a `neutral` tone for `missing` — a requirement the résumé
doesn't evidence is ordinary information, and a `warning` pill would frame it
as a fault. Evidence is a native `<details>` with an accessible name qualified
by its requirement, so a screen reader's control list gets distinct names
rather than N repetitions of "Evidence"; the shared `Disclosure` is the section
primitive and would nest card chrome inside a card for a one-line snippet.

`PasteJdPanel` owns the opt-in boolean — one `useState`, read by the control
and by the hook. Default OFF: no `detectWebGpu`, so no `webllm_capability_detected`
enters the funnel for a keyword-only user; no engine, no download. The panel
renders `semanticResult ?? keyword`, so detecting, loading, running, degraded,
opted-out and errored all fall through to the keyword floor.

`SemanticAnalysisOptIn` puts the lifecycle line under the control that started
the work, matching `ResumeQualityPanel` / `ResumeRewrite` / `SectionRewrite`.
No WebGPU renders one muted line, not `WebGpuUnavailableNotice` — that fires
`webllm_notice_shown` and frames a capability gap as a warning. Running copy is
generic on purpose: `judgeEvidence` reports no per-requirement progress, so the
issue's "Judging requirement 4 of 9…" would be invented.

`useJdMatch` gains one field, `capability`. With opt-in on and WebGPU absent,
`status` settles on `ready`-with-keyword, indistinguishable from opt-in-off and
from a degraded run; the UI needs the difference to explain why ticking the box
changed nothing. No orchestration moved into a component.

Cancellation (#803) is unchanged and now exercised from the UI: opt-out mid-load
and mid-inference, JD edits, A → B → C, model change, unmount and StrictMode all
assert on the threaded signal, and a superseded run's keyword fallback is never
reported to the user as a failed analysis.

Closes #204
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying offlinecv with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4bd6782
Status: ✅  Deploy successful!
Preview URL: https://a4df304e.offlinecv.pages.dev
Branch Preview URL: https://gh-204-jdmatch-semantic-ui.offlinecv.pages.dev

View logs

@Vaishnavi1709
Vaishnavi1709 requested a review from s-annam August 18, 2026 18:59

@s-annam s-annam left a comment

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.

Stance: APPROVE. 0 Blocking findings → APPROVE per the verdict rule (0 Blocking → APPROVE regardless of Secondary/Nits; none of either survived here either).

Blocking

None.

Secondary

None.

Nits

None — see the one AC note below, which is disclosed reasoning rather than a defect.

AC checklist (issue #204)

All acceptance criteria met:

  • JdMatch.tsx is a router on result.pathkeyword<KeywordMatch> (verified byte-identical to the pre-#204 body: same element order, class strings, copy, empty states — confirmed by reading the diff directly, not just trusting the parity test), semantic<SemanticMatch> grouped Met → Partial → Missing, Card/StatusBadge reused. ✓
  • Opt-in toggle "Analyze with on-device AI", default OFF, on the JD panel. ✓ (SemanticAnalysisOptIn, useState(false) in PasteJdPanel)
  • Loading → ModelLoadProgress (same primitive other WebLLM surfaces use). ✓
  • Running → step indicator. The issue's literal example copy ("Judging requirement 4 of 9…") is not implemented — judgeEvidence has no per-requirement progress callback to source it from, so the PR ships generic copy instead and says so explicitly in the description's "adapted rather than followed" section. This is disclosed reasoning, not an omission, so it doesn't fail gate 3f — noting it here only so the record is explicit.
  • No WebGPU → silently degrades to keyword columns with one muted explanatory line, no error/flicker. ✓ (verified in SemanticAnalysisOptIn's StatusLine, and covered by the fail-before test list)
  • Constraints: semantic tokens only, Button/Card/StatusBadge primitives, no raw <button>, privacy disclaimer preserved. ✓ (grepped the diff for raw elements and hardcoded colors — the only hex-shaped matches were #204 issue references, false positives)

Description accuracy (gate 3f)

Accurate. Every checkable claim in the Summary and Review-focus sections round-trips to the diff:

  • semanticResult ?? jdMatch fallthrough in PasteJdPanel.tsx — confirmed every non-ready/non-semantic state (idle, loading, running, error, opted-out, degraded) falls through to the keyword floor.
  • The capability-before-status.kind ordering in SemanticAnalysisOptIn's StatusLine — confirmed the check order matches the description.
  • The cancelled-run guard claim — traced independently in useJdMatch.ts: requestIdRef.current is bumped (++requestIdRef.current) before previousController?.abort() is called, and setSlotIfCurrent checks requestIdRef.current !== myId before writing any state, so a superseded run's late write is dropped. Matches the PR body's reasoning exactly.
  • StatusBadge's new neutral tone reuses bg-surface-subtle/text-content-muted — the same pairing already used elsewhere in this file (the "alpha" badge), not a new token combination introduced by this PR.
  • Bundle-size deltas in the Test plan were not independently re-measured (would require a full build + diff) but are plausible given the change shape (hoisting ModelLoadProgress into a shared chunk now that both entries use it) and don't affect the verdict either way.

Gates run

  • 3a (fixture PII): skipped — no fixture files touched.
  • 3b (design-system reuse): pass. New feature files (KeywordMatch, SemanticMatch, SemanticAnalysisOptIn) are the router split the issue explicitly asked for, not a parallel surface; StatusBadge's new neutral tone correctly extends the shared primitive instead of a bespoke pill.
  • 3c (style tokens): pass. No hardcoded hex/palette classes/manual dark: variants (grep hits were all #NNN issue-number references).
  • 3d (fallow): pass. npx fallow audit --base origin/main → "No issues in 13 changed files"; the only duplication flagged is pre-existing and excluded as inherited.
  • 3e (command-level bugs): n/a — no skill/script files touched.
  • 3f (description accuracy): pass, see above.
  • npm run typecheck: clean. npm run lint: clean. npx vitest run on the 6 touched test files: 94/94 passing.

Note for the record: this run's background /code-review (generic correctness pass) did not return within a reasonable wait and was abandoned in favor of the manual pass documented above, which covered the full diff file-by-file including independent tracing of the two logic claims called out in "Review focus". No correctness issues found.


Reviewed by: Claude Sonnet 5 (high)

@s-annam s-annam left a comment

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.

Follow-up to the APPROVE above. The background /code-review pass I flagged as stalled came back after I'd already posted (three duplicate notifications, same 8 findings each time). I verified all 8 directly against source before adding them here. None change the verdict — 0 Blocking, so the PR stays APPROVE — but two are real user-facing defects worth fixing before or shortly after merge, and the rest are legitimate cleanup.

Secondary

1. Invisible "Missing" badge (correctness/visual). Anchored below at SemanticMatch.tsx:148. StatusBadge's new neutral tone (StatusBadge.tsx:47: bg-surface-subtle text-content-muted) is applied inside VerdictRow's <li>, which is itself bg-surface-subtle (SemanticMatch.tsx:143) — both resolve to the same --color-bg-subtle token (theme.css:38). Every "Missing" verdict's badge pill has no visible fill, just small-caps text with no chip boundary, while "Met"/"Partial" badges render normally colored. This undercuts the component's own docblock claim that status is carried "THREE ways, never by colour alone" — the shape/colour channel silently drops for exactly the status class most worth flagging. Suggest either a distinct neutral background (e.g. a step darker/lighter than the row) or a border, so the pill stays visible against a bg-surface-subtle row.

2. Tailor-button gated on keyword coverage only, even when the semantic view is on screen. PasteJdPanel.tsx (jdContext, ~line 111) builds the "Tailor résumé to this job" button's visibility and payload from jdMatch.coverage (keyword) unconditionally — documented deliberately in the added comment block just above it ("Built from the KEYWORD coverage regardless of which view is on screen... Wiring the semantic verdicts into rewrite steering is its own piece of work"). That's a reasonable scope cut, but the user-visible result is: opt into semantic analysis, see a real "Missing" verdict on screen, and the button that would let you act on it can be hidden or under-informed because keyword coverage says nothing is missing. Since this is explicitly scoped out rather than an oversight, I'd suggest filing a fast-follow issue rather than blocking here — flagging so it isn't lost.

3. capability never resets to null on opt-out (useJdMatch.ts:~301-313, pre-existing #203 effect, not touched by this diff's added lines). The gating effect's cleanup only sets a local cancelled flag; it never calls setCapability(null) when semanticOptIn flips back to false. This contradicts the field's own docblock, newly added by this PR: "Stays null for a keyword-only consumer, since the probe is gated on the opt-in" (useJdMatch.ts:217-223) — false after a user has ever opted in once. Currently harmless only because the sole consumer (SemanticAnalysisOptIn's StatusLine) checks checked before ever reading capability. Since capability is now public JdMatchController API, a future consumer that doesn't replicate that gate would show a stale probe result for an opted-out user. Cheap fix: if (!semanticOptIn) { setCapability(null); return; }.

4. SemanticAnalysisOptIn.tsx is 215 LOC, over CLAUDE.md's "~200 LOC, decompose past that" feature-component rule (confirmed via wc -l). StatusLine's ~70-line, 6-branch state machine is the natural extraction candidate — the same pattern this PR already applied when splitting JdMatch into KeywordMatch/SemanticMatch.

5. Duplicated <Card> header markup between KeywordMatch.tsx (moved verbatim from the old JdMatch.tsx) and SemanticMatch.tsx (SemanticMatch.tsx:91-97, byte-identical to KeywordMatch.tsx's header block). Nothing enforces the two staying in sync; a future copy tweak (e.g. renaming "alpha") edited in one file silently drifts from the other. A small shared header component would remove the duplication — the CLAUDE.md Golden Rule reasoning applies between the two feature files here, not just vs. a primitive.

Nits

6. Disclosure.tsx's reuse-analysis docblock is now stale. It enumerates the repo's hand-rolled <details> exceptions by name (five feature-code + ModelLoadProgress) as the canonical "known debt with an owner" registry — but Disclosure.tsx itself isn't touched by this diff, so SemanticMatch.tsx's new per-verdict Evidence <details> (which explicitly leans on that registry's reasoning to justify not using the shared primitive) was never added to it. A future batch-conversion effort driven off Disclosure.tsx's list would miss this instance. One-line docblock update, whenever convenient.

7. Dead defensive narrowingPasteJdPanel.tsx's const jdMatch = keyword?.path === "keyword" ? keyword : null; (unchanged by this diff) re-narrows a value that, per useJdMatch's own construction (keywordResult always builds {path:"keyword",...} or null), can never be anything else. The hook's keyword field is typed JdMatchResult | null rather than the narrower Extract<JdMatchResult,{path:"keyword"}> | null — the same Extract idiom this PR already uses in KeywordMatch.tsx/SemanticMatch.tsx. Tightening the hook's return type would let the compiler enforce the invariant instead of a runtime check papering over it. Not urgent — pre-existing from #203, this PR just didn't touch it.

8. StatusLine's if-chain isn't exhaustive over JdMatchStatus.kind. Anchored below at SemanticAnalysisOptIn.tsx:118. A future 6th JdMatchStatus variant falls through to the final return null with no compiler signal, unlike SemanticMatch.tsx's GROUP_ORDER/Record<VerdictStatus,...> pattern (whose own docblock notes it fails-closed on a new status by design). A switch with a never-typed default would close this gap.


Reviewed by: Claude Sonnet 5 (high) — this addendum incorporates a delayed /code-review pass, independently re-verified against source before posting.

{/* `StatusBadge` is `w-fit`, which stops it growing but not shrinking;
the wrapper is what keeps a long requirement from squeezing it. */}
<span className="shrink-0">
<StatusBadge tone={GROUP_BADGE_TONE[status]}>

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.

This StatusBadge tone={GROUP_BADGE_TONE[status]} renders with tone="neutral" for the missing group. neutral is bg-surface-subtle text-content-muted (StatusBadge.tsx:47), and the row this sits in (VerdictRow's <li>, line 143) is itself bg-surface-subtle — same token, so the pill has no visible fill against its own row. See the Secondary finding #1 in the review body for the full writeup and a suggested fix.

);
}

function StatusLine({

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.

StatusLine branches on status.kind via a chain of ifs rather than an exhaustive switch. A future JdMatchStatus variant added to the union (currently 5 members) would fall through to the final return null with no compiler warning. See Nit #8 in the review body — not urgent, just flagging for awareness.

@Vaishnavi1709
Vaishnavi1709 added this pull request to the merge queue Aug 18, 2026
Merged via the queue into main with commit 31bc867 Aug 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JD-match: JdMatch.tsx semantic verdict UI + opt-in toggle + keyword fallback view

2 participants