diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 57d39dcb..1e0e11ee 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -46,7 +46,7 @@
* privacy defect — sees a whole file the moment anything imports it, not just
* the one export it used. Routing `computeCoverageFromCorpus` through the barrel
* would drag a network primitive onto that graph for nothing. Measured: the
- * value-edge closure of the specifiers below is 27 modules and reaches no
+ * value-edge closure of the specifiers below is 28 modules and reaches no
* `fetch`/`WebSocket`/`XMLHttpRequest`/`EventSource` at all. That is a statement
* about **this entry's** value-edge closure, and not about the tarball's file
* list, which is larger — see "The tarball ships more files than the graph
@@ -75,7 +75,7 @@
* cannot arrive here by accident. Read `src/job-search.ts`'s own docblock for
* the full argument; the two facts that belong on this side of the seam are:
*
- * - **The two runtime closures are disjoint.** Measured: 27 modules from this
+ * - **The two runtime closures are disjoint.** Measured: 28 modules from this
* entry, 11 from `./job-search`, zero modules in common. Importing one
* cannot pull the other in, in either direction, which is what makes the
* network-free claim above survive the subpath's existence rather than merely
@@ -91,8 +91,8 @@
*
* `tsc` emits a `.js` for every file in the program, the ones reached only by
* `import type` included, and `files` ships all of them. So `npm pack` produces
- * 62 modules, of which 38 are reachable — 27 from this barrel and 11 from
- * `./job-search` — and two of the unreachable 24 read exactly like the thing
+ * 65 modules, of which 39 are reachable — 28 from this barrel and 11 from
+ * `./job-search` — and two of the unreachable 26 read exactly like the thing
* this file says is absent:
*
* - `dist/src/lib/analytics.js` — `import.meta.env`, `await import("posthog-js")`
@@ -111,7 +111,7 @@
* an egress path.
*
* The network-free claim above is therefore about **this entry's** RUNTIME GRAPH
- * rather than about the file list, and on that graph it holds exactly: 27
+ * rather than about the file list, and on that graph it holds exactly: 28
* modules, no network primitive, one bare import (`idb`). Which means grepping
* the tarball for `fetch(` is the wrong audit twice over — it finds the seven
* modules that legitimately fetch on the OTHER entry, plus prose mentions in
diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json
index 4709f180..7a50f446 100644
--- a/packages/core/tsconfig.build.json
+++ b/packages/core/tsconfig.build.json
@@ -24,12 +24,12 @@
"rewriteRelativeImportExtensions": true,
// Load-bearing for the privacy property the barrels' docblocks claim. The
- // program is 62 modules: 38 are value-reachable (27 from `src/index.ts`, 11
+ // program is 65 modules: 39 are value-reachable (28 from `src/index.ts`, 11
// from `src/job-search.ts`, and those two sets are disjoint), and the other
- // 24 are reachable from neither entry.
+ // 26 are reachable from neither entry.
//
- // `import type` edges are how those 24 get into the program, but NOT how all
- // of them got there directly — 13 of the 24 have an ordinary value edge into
+ // `import type` edges are how those 26 get into the program, but NOT how all
+ // of them got there directly — 15 of the 26 have an ordinary value edge into
// them from another module that is itself only type-reachable, so they enter
// BEHIND a type edge rather than through one. `job-search/sector.ts` is the
// clearest case: nothing imports it for a value, but its own
diff --git a/scripts/check-core-package.mjs b/scripts/check-core-package.mjs
index b40b8e92..f9539705 100644
--- a/scripts/check-core-package.mjs
+++ b/scripts/check-core-package.mjs
@@ -197,7 +197,7 @@ const EXPECTED_EXPORTS = {
* `EXPECTED_EXPORTS` above catches a provider SYMBOL moved onto `.`. It cannot
* catch a value EDGE, which is the cheaper mistake by far: one
* `import "…/fetch-jd.ts";` side-effect line in `src/index.ts` leaves the export
- * set byte-identical while taking that closure from 27 modules to 29, putting a
+ * set byte-identical while taking that closure from 28 modules to 30, putting a
* live `fetch(` on it, and making the two closures overlap. At that point every
* network-free claim in `src/index.ts`, `src/job-search.ts` and
* `tsconfig.build.json` is false — in a public repo — and the downstream
@@ -222,7 +222,7 @@ const EXPECTED_EXPORTS = {
* the assertion silently covering two of three surfaces.
*/
const ENTRY_CLOSURES = {
- ".": { modules: 27, networkBearingModules: 0 },
+ ".": { modules: 28, networkBearingModules: 0 },
"./job-search": { modules: 11, networkBearingModules: 7 },
};
@@ -233,7 +233,7 @@ const ENTRY_CLOSURES = {
* same reason `importSpecifiers` does — and here the difference is not
* theoretical but load-bearing on the very first run. `tsc` preserves docblocks
* into the emit verbatim, and the emitted `.` entry contains the sentence "the
- * value-edge closure of the specifiers below is 27 modules and reaches no
+ * value-edge closure of the specifiers below is 28 modules and reaches no
* `fetch`/`WebSocket`/…" — so the obvious `/\b(fetch|…)\s*\(/` sweep reports
* FOUR network primitives in the one file whose whole claim is that it has
* none. A comment is not a node; the parse simply does not see it.
diff --git a/src/components/features/FindJobsPanel.tsx b/src/components/features/FindJobsPanel.tsx
index 13e09224..b21cd3ce 100644
--- a/src/components/features/FindJobsPanel.tsx
+++ b/src/components/features/FindJobsPanel.tsx
@@ -24,7 +24,14 @@
* Everything was reachable and nothing was findable. The steps ARE the
* reading order of the work, and each rail entry states its own current
* value (`describeQuerySteps`), so a closed step is still legible.
- * 2. **Results**, owning the full width.
+ * 2. **The narrowing strip** (#809) — `JobResultRefineStrip`, rendered only
+ * once a search has loaded. The fold above is what made this necessary: it
+ * is right that a form worth the full page width while being filled in is
+ * worth none of it afterwards, but it also put every narrowing lever behind
+ * "Edit search" at the exact moment a user finally has results to react to.
+ * The strip is not a second query surface — it edits this same `query`
+ * through this same `setQuery`.
+ * 3. **Results**, owning the full width.
*
* The whole query folds to a one-line `JobQuerySummary` + Search again on
* submit — the rail included, since a form worth the full page width while
@@ -88,6 +95,7 @@ import type { HeuristicParsedResume } from "../../lib/heuristics/types.ts";
import { JobSearchResults } from "./JobSearchResults.tsx";
import { JobQueryEditor } from "./JobQueryEditor.tsx";
import { JobQuerySummary } from "./JobQuerySummary.tsx";
+import { JobResultRefineStrip } from "./JobResultRefineStrip.tsx";
import { PasteJdPanel } from "./PasteJdPanel.tsx";
import { PendingCompaniesNotice } from "./PendingCompaniesNotice.tsx";
import { useCompanyTargets } from "../../hooks/useCompanyTargets.ts";
@@ -239,6 +247,16 @@ export function FindJobsPanel({
/>
)}
+ {/* The narrowing controls, WITH the results (#809). Only over a real
+ * result set: before the first search there is nothing to narrow, and
+ * over the loading skeleton or the error state the strip would be a
+ * control with no subject. Mounted OUTSIDE the fold on purpose — the
+ * fold is what hid these levers from the three respondents who reported
+ * the search "returns everything". */}
+ {phase.kind === "loaded" && (
+
+ )}
+
diff --git a/src/components/features/JobQueryEditor.tsx b/src/components/features/JobQueryEditor.tsx
index 7a589697..8240a6fb 100644
--- a/src/components/features/JobQueryEditor.tsx
+++ b/src/components/features/JobQueryEditor.tsx
@@ -49,6 +49,8 @@ import { ROLE_HINT } from "../../lib/job-search/query-steps.ts";
import {
canonicalSkillLabels,
parseSeniorityLabel,
+ withExcludeTerm,
+ withoutExcludeTerm,
} from "../../lib/job-search/query-builder.ts";
import { promoteSkill, promoteTitle } from "../../lib/job-search/search-plan.ts";
import type { RoleFamily } from "../../lib/job-search/role-keywords.ts";
@@ -131,12 +133,9 @@ export function JobQueryEditor({
onChange((q) => withSkills(q, q.skills.filter((s) => s !== skill)));
const addExcludeTerm = (term: string) =>
- onChange((q) => ({ ...q, excludeTerms: [...(q.excludeTerms ?? []), term] }));
+ onChange((q) => withExcludeTerm(q, term));
const removeExcludeTerm = (term: string) =>
- onChange((q) => ({
- ...q,
- excludeTerms: (q.excludeTerms ?? []).filter((t) => t !== term),
- }));
+ onChange((q) => withoutExcludeTerm(q, term));
// Role families (#568): REMOVAL only — see RoleFamilyChips' doc for why
// there's no free-text add. Narrowing to an empty list is safe: readers
diff --git a/src/components/features/JobResultRefineStrip.test.tsx b/src/components/features/JobResultRefineStrip.test.tsx
new file mode 100644
index 00000000..2a7d0539
--- /dev/null
+++ b/src/components/features/JobResultRefineStrip.test.tsx
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+// @vitest-environment jsdom
+
+/**
+ * Render + interaction coverage for `JobResultRefineStrip` (#809).
+ *
+ * The three assertions that matter are the three #809 acceptance criteria this
+ * component is responsible for: the local-only toggle exists and writes
+ * `locationOnly`; the level control is present for a query that derived NO
+ * seniority (the fresher case the form's `AddPill` gate hides); and every edit
+ * goes through the caller's single `onChange` — the strip owns no query state
+ * of its own, which is what keeps it from becoming a second query surface.
+ *
+ * Raw createRoot + act, matching `JobSearchResults.test.tsx`.
+ */
+
+import { describe, it, expect, afterEach } from "vitest";
+import { createElement } from "react";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { JobResultRefineStrip } from "./JobResultRefineStrip.tsx";
+import type { JobQuery } from "../../lib/job-search/query-builder.ts";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
+ true;
+
+let container: HTMLDivElement;
+let root: Root;
+
+/** Renders the strip and returns the container plus every query the component
+ * asked for. `onChange` takes an updater, so applying it here is what the real
+ * `FindJobsPanel` `setQuery` does. */
+function render(query: JobQuery) {
+ const seen: JobQuery[] = [];
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ act(() => {
+ root.render(
+ createElement(JobResultRefineStrip, {
+ query,
+ onChange: (next: (q: JobQuery) => JobQuery) => seen.push(next(query)),
+ }),
+ );
+ });
+ return { el: container, seen };
+}
+
+afterEach(() => {
+ act(() => root?.unmount());
+ container?.remove();
+});
+
+/** The checkbox whose label mentions locality — found by label text, the way a
+ * user finds it, rather than by DOM position. */
+function localOnlyBox(el: HTMLElement): HTMLInputElement {
+ const label = [...el.querySelectorAll("label")].find((l) =>
+ /only jobs near/i.test(l.textContent ?? ""),
+ );
+ const input = label?.querySelector("input[type=checkbox]");
+ if (!input) throw new Error("local-only checkbox not found");
+ return input as HTMLInputElement;
+}
+
+function levelButton(el: HTMLElement, label: string): HTMLButtonElement {
+ const button = [...el.querySelectorAll("button[role=radio]")].find(
+ (b) => b.textContent?.trim() === label,
+ );
+ if (!button) throw new Error(`level "${label}" not found`);
+ return button as HTMLButtonElement;
+}
+
+const baseQuery: JobQuery = { titles: ["Frontend Engineer"], skills: ["React"] };
+
+describe("JobResultRefineStrip (issue 809)", () => {
+ it("names the user's own location in the toggle, so it can be checked", () => {
+ const { el } = render({ ...baseQuery, location: "Austin, TX" });
+ expect(el.textContent).toContain("Only jobs near Austin, TX");
+ });
+
+ it("turning the toggle on sets locationOnly through the caller's onChange", () => {
+ const { el, seen } = render({ ...baseQuery, location: "Austin, TX" });
+ act(() => {
+ localOnlyBox(el).click();
+ });
+ expect(seen).toHaveLength(1);
+ expect(seen[0].locationOnly).toBe(true);
+ // The whole rest of the query is carried through untouched — the strip
+ // replaces the query wholesale, same contract as `JobQueryEditor`.
+ expect(seen[0].titles).toEqual(["Frontend Engineer"]);
+ expect(seen[0].location).toBe("Austin, TX");
+ });
+
+ it("turning it back off clears the flag rather than storing false", () => {
+ const { el, seen } = render({
+ ...baseQuery,
+ location: "Austin, TX",
+ locationOnly: true,
+ });
+ act(() => {
+ localOnlyBox(el).click();
+ });
+ expect(seen[0].locationOnly).toBeUndefined();
+ });
+
+ it("disables the toggle until a location is set, and says why", () => {
+ const { el } = render(baseQuery);
+ expect(localOnlyBox(el).disabled).toBe(true);
+ expect(el.textContent).toContain("Add a location above to turn this on.");
+ });
+
+ it("offers the level control to a query that derived no seniority (the fresher case)", () => {
+ const { el, seen } = render(baseQuery);
+ expect(baseQuery.seniority).toBeUndefined();
+ act(() => {
+ levelButton(el, "Junior").click();
+ });
+ expect(seen[0].seniority).toBe("Junior");
+ });
+
+ it("offers the entry-level rungs, not just the ones a title can derive", () => {
+ const { el } = render(baseQuery);
+ for (const level of ["Intern", "Junior", "Mid"]) {
+ expect(levelButton(el, level)).toBeTruthy();
+ }
+ });
+
+ it("adds an exclude term through the same onChange", () => {
+ const { el, seen } = render({ ...baseQuery, excludeTerms: ["Sales"] });
+ const input = el.querySelector(
+ 'input[aria-label="Add exclude term"]',
+ );
+ if (!input) throw new Error("exclude input not found");
+ // React tracks the DOM node's value, so assigning `.value` directly is
+ // swallowed as a no-op change — go through the prototype setter, same as
+ // `JobQueryEditor.test.tsx`'s `setNativeValue`.
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )!.set!;
+ act(() => {
+ setter.call(input, "Manager");
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const add = [...el.querySelectorAll("button")].find(
+ (b) => b.textContent === "Add",
+ );
+ if (!add) throw new Error("Add button not found");
+ act(() => add.click());
+ expect(seen.at(-1)?.excludeTerms).toEqual(["Sales", "Manager"]);
+ });
+
+ it("removes an exclude term through the same onChange", () => {
+ const { el, seen } = render({ ...baseQuery, excludeTerms: ["Sales"] });
+ const remove = [...el.querySelectorAll("button")].find((b) =>
+ /remove/i.test(b.getAttribute("aria-label") ?? ""),
+ );
+ if (!remove) throw new Error("remove control not found");
+ act(() => remove.click());
+ expect(seen.at(-1)?.excludeTerms).toEqual([]);
+ });
+});
diff --git a/src/components/features/JobResultRefineStrip.tsx b/src/components/features/JobResultRefineStrip.tsx
new file mode 100644
index 00000000..8e0ff8fe
--- /dev/null
+++ b/src/components/features/JobResultRefineStrip.tsx
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+/**
+ * JobResultRefineStrip — the narrowing controls, rendered WITH the results
+ * instead of only inside the folded query form (#809).
+ *
+ * WHY IT EXISTS. Three respondents in the Aug 2026 round reported the same
+ * thing: the search returns postings they did not ask for and nothing they can
+ * reach makes it stop. The controls that would have stopped it already
+ * existed — role chips, exclude terms and target level all live in
+ * `JobQueryEditor`'s steps — but the moment Search is clicked `FindJobsPanel`
+ * folds the whole form to a one-line summary, so at the exact moment a user has
+ * a result set to react to, every narrowing lever is behind an "Edit search"
+ * button, inside a four-step walk, on a step they have to pick. Nobody found
+ * them. This strip puts the three highest-value levers one interaction from the
+ * results view, which is the #809 acceptance criterion stated literally.
+ *
+ * NOT A SECOND SURFACE. It edits the SAME `JobQuery` the form does, through the
+ * same `onChange`, and reuses the same controls (`LevelSelect`,
+ * `ChipListEditor`, `EditableField`) — a chip removed here is gone from the
+ * form's Narrow step too, because there is one query. Every edit re-ranks
+ * through `refineSearchResult` via `useJobSearch`'s live-re-rank effect, so
+ * nothing here fetches and nothing here egresses: `providers/keywords.ts` stays
+ * the sole resume-derived egress helper, untouched by this file.
+ *
+ * WHY THESE THREE. Level answers the fresher case (#809 case 3) — a candidate
+ * with no prior title has nothing for `SENIORITY_PATTERNS` to derive from, and
+ * in the form the level control is hidden behind an `AddPill` that only appears
+ * once a level WAS derived, i.e. never for them. Here it is always visible.
+ * Local-only answers the near-locality case (case 2). Exclude answers the
+ * off-role case (case 1) with the bluntest instrument the lane has. Comp floor
+ * and the company boards are deliberately absent: the floor is soft by design
+ * (#564) so it belongs with the query, and adding a board needs a fetch, which
+ * this strip must never trigger.
+ */
+
+import { Card, Checkbox, EditableField } from "@design-system";
+import {
+ withExcludeTerm,
+ withoutExcludeTerm,
+ type JobQuery,
+} from "../../lib/job-search/query-builder.ts";
+import { ChipListEditor } from "./ChipListEditor.tsx";
+import { LevelSelect } from "./LevelSelect.tsx";
+import { QueryStepSection } from "./QueryStepSection.tsx";
+
+/** The toggle's label, which must name the place it filters on so the user can
+ * check it against what they typed. Kept beside the strip's other copy for the
+ * same reason `query-steps.ts` centralises its own: consequence only. */
+function localOnlyLabel(location: string | undefined): string {
+ return location ? `Only jobs near ${location}` : "Only jobs near me";
+}
+
+const LOCAL_ONLY_HINT =
+ "Remote postings always stay — this hides the ones tied to somewhere else.";
+
+const NO_LOCATION_HINT =
+ "Add a location above to turn this on.";
+
+export function JobResultRefineStrip({
+ query,
+ onChange,
+}: {
+ query: JobQuery;
+ /** Same whole-query replacement contract as `JobQueryEditor` — the panel owns
+ * the state, and both editors write through this one setter. */
+ onChange: (next: (q: JobQuery) => JobQuery) => void;
+}) {
+ const hasLocation = (query.location ?? "").trim().length > 0;
+
+ const addExcludeTerm = (term: string) =>
+ onChange((q) => withExcludeTerm(q, term));
+ const removeExcludeTerm = (term: string) =>
+ onChange((q) => withoutExcludeTerm(q, term));
+
+ return (
+
+
+
+ Location
+ onChange((q) => ({ ...q, location: v || undefined }))}
+ />
+
+ {/* Disabled rather than hidden while no location is set: a control that
+ * vanishes teaches nothing, and the hint names the field to fill in.
+ * `locationOnly` is left as-is when disabled — a user who clears their
+ * location and retypes it gets their toggle back rather than a silent
+ * reset. `refineSearchResult` ignores the flag without a location, so
+ * the retained state cannot filter anything in the meantime. */}
+
+ onChange((q) => ({ ...q, locationOnly: checked || undefined }))
+ }
+ label={localOnlyLabel(query.location)}
+ hint={hasLocation ? LOCAL_ONLY_HINT : NO_LOCATION_HINT}
+ disabled={!hasLocation}
+ />
+
+
+ {/* Always shown, unlike the form's `AddPill`-gated copy — see the
+ * docblock: the gate's condition is exactly the fresher it excludes. */}
+
+ onChange((q) => ({ ...q, seniority }))}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/features/JobSearchResults.test.tsx b/src/components/features/JobSearchResults.test.tsx
index 74194bba..43394e1a 100644
--- a/src/components/features/JobSearchResults.test.tsx
+++ b/src/components/features/JobSearchResults.test.tsx
@@ -66,6 +66,8 @@ function loaded(
providerCount = 3,
excludeSuppressed = false,
roleSuppressed = false,
+ locationSuppressed = false,
+ locationFilteredOut = 0,
): JobSearchResult {
const jobs = rankPostings(
parsed,
@@ -77,6 +79,8 @@ function loaded(
providerCount,
excludeSuppressed,
roleSuppressed,
+ locationSuppressed,
+ locationFilteredOut,
rawPostings: [],
};
}
@@ -231,6 +235,8 @@ describe("JobSearchResults", () => {
providerCount: 1,
excludeSuppressed: false,
roleSuppressed: false,
+ locationSuppressed: false,
+ locationFilteredOut: 0,
rawPostings: [],
};
const el = render({ kind: "loaded", result });
@@ -265,6 +271,8 @@ describe("JobSearchResults", () => {
providerCount: 1,
excludeSuppressed: false,
roleSuppressed: false,
+ locationSuppressed: false,
+ locationFilteredOut: 0,
rawPostings: [],
};
const el = render({ kind: "loaded", result });
@@ -280,3 +288,35 @@ describe("JobSearchResults", () => {
expect(toggle.textContent).toContain("Hide weak matches (2)");
});
});
+
+describe("JobSearchResults local-only notices (issue 809)", () => {
+ it("states how many postings the local-only filter hid, and how to get them back", () => {
+ const el = render({
+ kind: "loaded",
+ result: loaded(2, [], 3, false, false, false, 4),
+ });
+ expect(el.textContent).toContain("4 postings hidden as too far away");
+ expect(el.textContent).toContain("untick");
+ });
+
+ it("says posting, singular, for one", () => {
+ const el = render({
+ kind: "loaded",
+ result: loaded(2, [], 3, false, false, false, 1),
+ });
+ expect(el.textContent).toContain("1 posting hidden as too far away");
+ });
+
+ it("says nothing at all when the filter removed nothing", () => {
+ const el = render({ kind: "loaded", result: loaded(2) });
+ expect(el.textContent).not.toContain("hidden as too far away");
+ });
+
+ it("explains a suppressed local-only filter rather than showing an empty page", () => {
+ const el = render({
+ kind: "loaded",
+ result: loaded(2, [], 3, false, false, true, 0),
+ });
+ expect(el.textContent).toContain("None of these postings say where they are");
+ });
+});
diff --git a/src/components/features/JobSearchResults.tsx b/src/components/features/JobSearchResults.tsx
index f96dd15b..8fead548 100644
--- a/src/components/features/JobSearchResults.tsx
+++ b/src/components/features/JobSearchResults.tsx
@@ -108,7 +108,15 @@ function Loaded({
onRetry: () => void;
onTailor?: (jdContext: string) => void;
}) {
- const { jobs, degradedProviders, providerCount, excludeSuppressed, roleSuppressed } = result;
+ const {
+ jobs,
+ degradedProviders,
+ providerCount,
+ excludeSuppressed,
+ roleSuppressed,
+ locationSuppressed,
+ locationFilteredOut,
+ } = result;
const [page, setPage] = useState(1);
// Anchor for the scroll-to-top on a page change. A numbered jump replaces the
// whole list under a scroll position that was meaningful for the old page, so
@@ -200,6 +208,20 @@ function Loaded({
and apply role filtering again.
)}
+ {locationFilteredOut > 0 && (
+
+ {locationFilteredOut} posting{locationFilteredOut === 1 ? "" : "s"}{" "}
+ hidden as too far away — untick “only jobs near me” to
+ see {locationFilteredOut === 1 ? "it" : "them"} again.
+
+ )}
+ {locationSuppressed && (
+
+ None of these postings say where they are, so we kept them all
+ rather than showing you an empty page — turn off “only jobs
+ near me” to stop asking, or try a broader location.
+
+ )}
{strong.length > 0 && (
diff --git a/src/hooks/useJobSearch.ts b/src/hooks/useJobSearch.ts
index 26fc1430..cb41b37a 100644
--- a/src/hooks/useJobSearch.ts
+++ b/src/hooks/useJobSearch.ts
@@ -20,7 +20,8 @@
* fetch — that's what lets `FindJobsPanel` re-rank live without breaking
* responsibility 1's invariant. Scoped to exactly the controls #568
* wires (role families, target level, exclude terms, comp floor,
- * location); a titles/skills edit still requires a fresh Search, since
+ * location) plus #809's local-only toggle; a titles/skills edit still
+ * requires a fresh Search, since
* `matchesQuery` already ran against the OLD titles/skills when the
* snapshot was taken.
* 3. The company selection, which is deliberately ASYMMETRIC because the two
@@ -257,7 +258,17 @@ export function useJobSearch(
// this array is deliberately scoped to the five refinement knobs #568
// wires, not titles/skills (see the file docblock) and not `parsed`
// (stable per panel mount — the résumé isn't edited from here).
- }, [query.families, query.excludeTerms, query.seniority, query.compFloor, query.location]);
+ // #809 adds `locationOnly` — a HARD filter rather than an axis, but the
+ // same class of knob: it changes `refineSearchResult`'s output over an
+ // unchanged snapshot, so it re-ranks live with no fetch like the other five.
+ }, [
+ query.families,
+ query.excludeTerms,
+ query.seniority,
+ query.compFloor,
+ query.location,
+ query.locationOnly,
+ ]);
return {
phase,
diff --git a/src/lib/job-search/CLAUDE.md b/src/lib/job-search/CLAUDE.md
index b73413ba..21dc2434 100644
--- a/src/lib/job-search/CLAUDE.md
+++ b/src/lib/job-search/CLAUDE.md
@@ -23,6 +23,27 @@ adds only the lane-specific rules that are silent to break.
- Before adding any `fetch()` here, confirm what leaves. A new adapter that sends more
than its slug breaks epic #528's privacy posture and the root-`CLAUDE.md` custody claim.
+## Soft axes rank; three hard filters remove
+
+Only `refineSearchResult` (`refine.ts`) removes a posting, and only through three
+user-armed filters: role families (#568), exclude terms (#563), and local-only (#809).
+Everything else that sounds like narrowing — target level, comp floor, and location's
+DEFAULT behavior — is a bounded soft axis inside `rankPostings` that reorders and drops
+nothing. That was a deliberate correction (#570 de-boosted location from a sort key,
+#716 bounded the axes), and #809 re-litigating "search returns everything" does **not**
+reopen it: the answer is an explicit lever the user can see, never a re-inflated implicit
+boost. Do not add a fourth remover without a visible control that arms it.
+
+All three share the **never-fail-closed** floor: when a filter would reduce a non-empty
+set to empty, it is skipped, the input is kept, and a `*Suppressed` flag goes back for the
+panel's notice. A blank panel the user cannot diagnose is worse than an unfiltered one.
+
+`location-match.ts` owns the ONE location predicate. `rank.ts` reads it for the soft
+axis, `refine.ts` for the hard filter — so the local-only toggle can never hide a posting
+whose own card shows a location match. It is a string comparison, not geography: no
+radius, no geocoding, because a distance model needs a geocoder and that is a network
+call this app does not make.
+
## Per-vendor adapters duplicate on purpose
Each provider in `providers/` is its own factory with its own inline `mapJob`/post-filter
diff --git a/src/lib/job-search/location-match.test.ts b/src/lib/job-search/location-match.test.ts
new file mode 100644
index 00000000..ebdeafcb
--- /dev/null
+++ b/src/lib/job-search/location-match.test.ts
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+import { describe, it, expect } from "vitest";
+import {
+ filterPostingsByLocation,
+ isRemotePosting,
+ locationMatches,
+} from "./location-match.ts";
+
+/** Minimal structural stub — the filter reads `location` and nothing else. */
+function at(id: string, location: string) {
+ return { id, location };
+}
+
+describe("locationMatches", () => {
+ it("matches on the leading city token, so a feed's longer form still counts", () => {
+ expect(locationMatches("Austin, TX", "Austin, TX, USA")).toBe(true);
+ });
+
+ it("matches either-direction substrings for postings that aren't 'City, ST'", () => {
+ expect(locationMatches("Berlin", "Berlin Office")).toBe(true);
+ expect(locationMatches("Greater Boston", "Boston")).toBe(true);
+ });
+
+ it("rejects a different city", () => {
+ expect(locationMatches("Austin, TX", "Seattle, WA")).toBe(false);
+ });
+
+ it("counts every remote spelling as a match for any query location", () => {
+ for (const remote of ["Remote", "Worldwide", "Anywhere", "WFH"]) {
+ expect(isRemotePosting(remote)).toBe(true);
+ expect(locationMatches("Austin, TX", remote)).toBe(true);
+ }
+ });
+
+ it("treats an unstated posting location as no evidence, not as a match", () => {
+ expect(locationMatches("Austin, TX", "")).toBe(false);
+ expect(locationMatches("Austin, TX", " ")).toBe(false);
+ });
+});
+
+describe("filterPostingsByLocation (issue 809)", () => {
+ it("keeps the whole set when no location is given — the toggle is inert", () => {
+ const postings = [at("a", "Austin, TX"), at("b", "Seattle, WA")];
+ expect(filterPostingsByLocation(postings, undefined)).toEqual({
+ postings,
+ suppressed: false,
+ });
+ expect(filterPostingsByLocation(postings, " ")).toEqual({
+ postings,
+ suppressed: false,
+ });
+ });
+
+ it("drops postings elsewhere and keeps local + remote ones", () => {
+ const result = filterPostingsByLocation(
+ [
+ at("local", "Austin, TX, USA"),
+ at("far", "Seattle, WA"),
+ at("remote", "Remote"),
+ ],
+ "Austin, TX",
+ );
+ expect(result.postings.map((p) => p.id)).toEqual(["local", "remote"]);
+ expect(result.suppressed).toBe(false);
+ });
+
+ it("never fails closed: a set it would empty is kept whole and flagged", () => {
+ const postings = [at("far", "Seattle, WA"), at("unstated", "")];
+ const result = filterPostingsByLocation(postings, "Austin, TX");
+ expect(result.postings.map((p) => p.id)).toEqual(["far", "unstated"]);
+ expect(result.suppressed).toBe(true);
+ });
+
+ it("does not flag suppression for an already-empty input", () => {
+ expect(filterPostingsByLocation([], "Austin, TX")).toEqual({
+ postings: [],
+ suppressed: false,
+ });
+ });
+});
diff --git a/src/lib/job-search/location-match.ts b/src/lib/job-search/location-match.ts
new file mode 100644
index 00000000..efff355c
--- /dev/null
+++ b/src/lib/job-search/location-match.ts
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+/**
+ * The ONE "does this posting sit where the candidate asked" predicate (#809).
+ *
+ * It used to be two private helpers inside `rank.ts`, where location was only
+ * ever a bounded soft axis — a flag feeding the star rating, never a reason to
+ * drop a posting. #809 adds an explicit user-set `locationOnly` mode that HARD
+ * filters on the same question, and a hard filter that disagreed with the soft
+ * axis would be indefensible on screen: a posting the card renders with a
+ * location tick would vanish when the toggle flips, or survive it while the
+ * card says the location doesn't match. So the predicate moved here and both
+ * readers import it — `rank.ts` for `RatingInput.locationMatch`, `refine.ts`
+ * for the filter. Neither owns a second definition.
+ *
+ * The MODEL is a string comparison, not geography: there is no radius, no
+ * geocoding, no distance. "Near me" in the #809 feedback is served by "the
+ * posting names my city, my region, or is remote" — which is what a feed's
+ * free-text `location` field can actually support. Anything finer needs a
+ * geocoder, which is a network call this app does not get to make.
+ *
+ * Zero-dep and pure, so it stays out of the dynamic-import tiers: `refine.ts`
+ * can filter before it has paid for `rank.ts`.
+ */
+
+const REMOTE_PATTERN = /\b(remote|worldwide|anywhere|wfh)\b/i;
+
+/** True for a posting location that reads as remote/location-agnostic — a remote
+ * posting fits any candidate location, so it always counts as a match. */
+export function isRemotePosting(location: string): boolean {
+ return REMOTE_PATTERN.test(location);
+}
+
+/**
+ * True when `postingLocation` should count as a match for `queryLocation`.
+ * Compares the leading city/region token (text before the first comma) so
+ * "Austin, TX" matches a feed's "Austin, TX, USA" without requiring an exact
+ * string match, and falls back to a loose substring check either direction for
+ * postings that don't follow the "City, ST" shape.
+ *
+ * An EMPTY posting location returns false — a feed that told us nothing about
+ * where the job is has not told us it is near you. That is the conservative
+ * read for the rating axis (no evidence, no credit) and, since #809, the reason
+ * `locationOnly` needs its never-fail-closed floor: a feed whose postings all
+ * carry an empty location would otherwise be filtered to nothing.
+ */
+export function locationMatches(queryLocation: string, postingLocation: string): boolean {
+ if (isRemotePosting(postingLocation)) return true;
+ const posting = postingLocation.trim().toLowerCase();
+ const query = queryLocation.trim().toLowerCase();
+ if (!posting || !query) return false;
+ const postingCity = posting.split(",")[0].trim();
+ const queryCity = query.split(",")[0].trim();
+ return postingCity === queryCity || posting.includes(query) || query.includes(posting);
+}
+
+/**
+ * Keep only the postings that sit at `queryLocation` (or are remote) — the hard
+ * arm of the location axis, applied ONLY when the user turns on `locationOnly`
+ * (#809). Returns the input untouched when there is no location to filter on,
+ * so an unset location is byte-identical to pre-#809 behavior.
+ *
+ * NEVER FAIL CLOSED, the same floor `filterPostingsByExcludeTerms` and the
+ * #566 role filter already apply: when the filter would reduce a NON-EMPTY set
+ * to EMPTY, the input is kept and `suppressed` is set for the panel's notice.
+ * The keyless aggregator feeds skew remote and are inconsistent about filling
+ * `location` at all, so "local only" over a set that named no locations is a
+ * blank screen the user cannot diagnose — the notice points them back at the
+ * toggle instead.
+ */
+export function filterPostingsByLocation(
+ postings: readonly T[],
+ queryLocation: string | undefined,
+): { postings: T[]; suppressed: boolean } {
+ const query = queryLocation?.trim();
+ if (!query) return { postings: [...postings], suppressed: false };
+ const kept = postings.filter((posting) => locationMatches(query, posting.location));
+ if (kept.length === 0 && postings.length > 0) {
+ return { postings: [...postings], suppressed: true };
+ }
+ return { postings: kept, suppressed: false };
+}
diff --git a/src/lib/job-search/query-builder.ts b/src/lib/job-search/query-builder.ts
index af05a3b6..41cdd34f 100644
--- a/src/lib/job-search/query-builder.ts
+++ b/src/lib/job-search/query-builder.ts
@@ -93,6 +93,24 @@ export interface JobQuery {
* to union together. Undefined when the parse has no location and the
* user hasn't typed one. */
location?: string;
+ /** Hard "only jobs at `location`" mode (#809). Off/undefined (the default,
+ * and byte-identical to pre-#809 behavior) leaves location exactly where
+ * #545/#570 put it: a bounded soft axis that edges the ranking and drops
+ * nothing. ON makes `refineSearchResult` DROP every posting that
+ * `locationMatches` rejects — the one place in the lane where location can
+ * remove a result, and only ever because the user armed it.
+ *
+ * Three respondents in the Aug 2026 round asked for this: the ranker's soft
+ * axes meant a stated city returned the whole feed, reordered, with nothing
+ * reachable that made it stop. Deliberately a BOOLEAN, not a radius — the
+ * predicate is a string comparison over a feed's free-text location field
+ * (`location-match.ts`), and a distance model would need a geocoder, which
+ * is a network call this app does not make.
+ *
+ * Inert while `location` is unset: a filter with nothing to filter on keeps
+ * the whole set. Remote postings always pass (they fit any location), so
+ * this narrows to "near me OR anywhere", never to "on-site only". */
+ locationOnly?: boolean;
/** Title-only exclude terms (#563) — a posting is dropped when its TITLE
* (never its description) contains one of these as a case-insensitive
* substring. User-editable chips, same interaction as `titles`/`skills`.
@@ -171,6 +189,29 @@ export interface JobQuery {
titleNoise?: string[];
}
+/**
+ * Add / remove one exclude-term chip, as pure whole-query transforms.
+ *
+ * They live here rather than inline in a component because TWO editors now
+ * write the same field — `JobQueryEditor`'s Narrow step and #809's
+ * `JobResultRefineStrip` beside the results — and an `excludeTerms` handler
+ * copied into the second one is how the `undefined`-means-`[]` contract above
+ * gets half-remembered in one of them. One definition, both callers.
+ *
+ * Neither dedups or trims: `ChipListEditor` already does both before it calls
+ * `onAdd`, and duplicating that here would put the rule in two places too.
+ */
+export function withExcludeTerm(query: JobQuery, term: string): JobQuery {
+ return { ...query, excludeTerms: [...(query.excludeTerms ?? []), term] };
+}
+
+export function withoutExcludeTerm(query: JobQuery, term: string): JobQuery {
+ return {
+ ...query,
+ excludeTerms: (query.excludeTerms ?? []).filter((t) => t !== term),
+ };
+}
+
/**
* Structural subset of `ParsedResume` this module actually reads. The live
* caller (`ResultDetail`) holds a `HeuristicParsedResume`
diff --git a/src/lib/job-search/rank.ts b/src/lib/job-search/rank.ts
index ab29857e..e570db7c 100644
--- a/src/lib/job-search/rank.ts
+++ b/src/lib/job-search/rank.ts
@@ -36,9 +36,13 @@
* specificity factor (#561) discounts a high score resting on few extracted
* terms: a thin vague JD fully covered (100% over 6 terms) yields a smaller
* base than a well-specified JD covered 30/45, so it cannot outrank it.
- * - location (#545) — a MATCH flag, remote always matching. Feeds a bounded
- * minor axis in `rateJobs`; a non-local strong fit is never dropped, only
- * edged by an equal-fit local one. No longer a flat sort-key boost (#570).
+ * - location (#545) — a MATCH flag, remote always matching, read from the
+ * shared `location-match.ts` predicate. Feeds a bounded minor axis in
+ * `rateJobs`; a non-local strong fit is never dropped HERE, only edged by
+ * an equal-fit local one. No longer a flat sort-key boost (#570). Dropping
+ * is #809's separate, explicitly user-armed `locationOnly` filter in
+ * `refine.ts` — it reads the SAME predicate, so the toggle can never hide a
+ * posting whose card shows a location match.
* - seniority (#562) — the ladder-rung DISTANCE between the query's derived
* level and the posting title's level, or null when there is no comparison
* (no query seniority, or an unrecognized title level). Feeds a minor axis;
@@ -64,6 +68,7 @@ import type { JobQuery } from "./query-builder.ts";
import { parseSeniorityLabel } from "./query-builder.ts";
import { seniorityRung } from "./seniority.ts";
import { extractCompensation, isBelowFloor, annualizedTop } from "./compensation.ts";
+import { locationMatches } from "./location-match.ts";
import { rateJobs, type JobRating, type RatingInput } from "./rating.ts";
/** The keyword arm of `JdMatchResult` — the only shape produced here. */
@@ -118,31 +123,6 @@ function specificityConfidence(termCount: number): number {
return termCount / (termCount + SPECIFICITY_HALF_SATURATION);
}
-const REMOTE_PATTERN = /\b(remote|worldwide|anywhere|wfh)\b/i;
-
-/** True for a posting location that reads as remote/location-agnostic — a remote
- * posting fits any candidate location, so it always counts as a match. */
-function isRemotePosting(location: string): boolean {
- return REMOTE_PATTERN.test(location);
-}
-
-/**
- * True when `postingLocation` should count as a match for `queryLocation`.
- * Compares the leading city/region token (text before the first comma) so
- * "Austin, TX" matches a feed's "Austin, TX, USA" without requiring an exact
- * string match, and falls back to a loose substring check either direction for
- * postings that don't follow the "City, ST" shape.
- */
-function locationMatches(queryLocation: string, postingLocation: string): boolean {
- if (isRemotePosting(postingLocation)) return true;
- const posting = postingLocation.trim().toLowerCase();
- const query = queryLocation.trim().toLowerCase();
- if (!posting || !query) return false;
- const postingCity = posting.split(",")[0].trim();
- const queryCity = query.split(",")[0].trim();
- return postingCity === queryCity || posting.includes(query) || query.includes(posting);
-}
-
/**
* The ladder-rung DISTANCE between the query's seniority rung and the level
* parsed out of a posting title (#562), or null when there is no comparison to
diff --git a/src/lib/job-search/refine.test.ts b/src/lib/job-search/refine.test.ts
index 2b092540..90cd84e7 100644
--- a/src/lib/job-search/refine.test.ts
+++ b/src/lib/job-search/refine.test.ts
@@ -108,3 +108,104 @@ describe("refineSearchResult (issue 568)", () => {
expect(result.providerCount).toBe(2);
});
});
+
+describe("refineSearchResult — local-only (issue 809)", () => {
+ const raw = [
+ posting({ id: "local", title: "Frontend Engineer", location: "Austin, TX, USA" }),
+ posting({ id: "far", title: "Frontend Engineer", location: "Seattle, WA" }),
+ posting({ id: "remote", title: "Frontend Engineer", location: "Remote" }),
+ ];
+
+ it("changes nothing while the toggle is off — location stays a soft axis", async () => {
+ const result = await refineSearchResult(
+ raw,
+ parsed,
+ { ...query, location: "Austin, TX" },
+ [],
+ 1,
+ );
+ expect(result.jobs.map((j) => j.posting.id).sort()).toEqual([
+ "far",
+ "local",
+ "remote",
+ ]);
+ expect(result.locationSuppressed).toBe(false);
+ expect(result.locationFilteredOut).toBe(0);
+ });
+
+ it("drops non-local postings once the user turns it on, keeping remote", async () => {
+ const result = await refineSearchResult(
+ raw,
+ parsed,
+ { ...query, location: "Austin, TX", locationOnly: true },
+ [],
+ 1,
+ );
+ expect(result.jobs.map((j) => j.posting.id).sort()).toEqual(["local", "remote"]);
+ expect(result.locationFilteredOut).toBe(1);
+ expect(result.locationSuppressed).toBe(false);
+ });
+
+ it("is inert with no location set, however the toggle reads", async () => {
+ const result = await refineSearchResult(
+ raw,
+ parsed,
+ { ...query, locationOnly: true },
+ [],
+ 1,
+ );
+ expect(result.jobs).toHaveLength(3);
+ expect(result.locationFilteredOut).toBe(0);
+ });
+
+ it("never fails closed: a set it would empty is kept whole and flagged", async () => {
+ const elsewhere = [
+ posting({ id: "far", location: "Seattle, WA" }),
+ posting({ id: "unstated", location: "" }),
+ ];
+ const result = await refineSearchResult(
+ elsewhere,
+ parsed,
+ { ...query, location: "Austin, TX", locationOnly: true },
+ [],
+ 1,
+ );
+ expect(result.jobs).toHaveLength(2);
+ expect(result.locationSuppressed).toBe(true);
+ expect(result.locationFilteredOut).toBe(0);
+ });
+
+ it("counts only what IT removed, not what the exclude filter already took", async () => {
+ const mixed = [
+ posting({ id: "local", location: "Austin, TX" }),
+ posting({ id: "far", location: "Seattle, WA" }),
+ posting({ id: "excluded", title: "Sales Engineer", location: "Austin, TX" }),
+ ];
+ const result = await refineSearchResult(
+ mixed,
+ parsed,
+ {
+ ...query,
+ location: "Austin, TX",
+ locationOnly: true,
+ excludeTerms: ["Sales"],
+ },
+ [],
+ 1,
+ );
+ expect(result.jobs.map((j) => j.posting.id)).toEqual(["local"]);
+ expect(result.locationFilteredOut).toBe(1);
+ });
+
+ it("still egresses nothing — the filter is a pure local set operation", async () => {
+ const before = raw.map((p) => ({ ...p }));
+ await refineSearchResult(
+ raw,
+ parsed,
+ { ...query, location: "Austin, TX", locationOnly: true },
+ [],
+ 1,
+ );
+ expect(raw).toEqual(before);
+ });
+});
diff --git a/src/lib/job-search/refine.ts b/src/lib/job-search/refine.ts
index ba4defe7..afbd66aa 100644
--- a/src/lib/job-search/refine.ts
+++ b/src/lib/job-search/refine.ts
@@ -3,8 +3,16 @@
/**
* refineSearchResult — apply the query's LOCAL refinement knobs (role
- * families #568, exclude terms #563) and rank (#545/#561/#562/#564) over an
- * already-fetched, already-deduped posting set.
+ * families #568, exclude terms #563, local-only #809) and rank
+ * (#545/#561/#562/#564) over an already-fetched, already-deduped posting set.
+ *
+ * The three hard filters here are the ONLY things in the lane that remove a
+ * posting, and all three are user-armed: chips the user can see and clear.
+ * Everything else about narrowing — level, comp floor, and location's default
+ * behavior — is a bounded soft axis inside `rankPostings` that reorders and
+ * drops nothing (#570/#716). Keep it that way: #809's fix for "the search
+ * returns everything" is giving the user an explicit lever, not re-inflating an
+ * implicit boost.
*
* Pulled out of `searchJobs` (`search.ts`) so `FindJobsPanel` can re-run the
* SAME pipeline on every edit to a refinement control (role family, target
@@ -30,6 +38,7 @@ import {
filterPostingsByExcludeTerms,
roleFilterForFamilies,
} from "./role-keywords.ts";
+import { filterPostingsByLocation } from "./location-match.ts";
import type { JobSearchResult } from "./search.ts";
export async function refineSearchResult(
@@ -75,15 +84,29 @@ export async function refineSearchResult(
roleFiltered = [...rawPostings];
}
- const { postings: filtered, suppressed: excludeSuppressed } =
+ const { postings: excludeFiltered, suppressed: excludeSuppressed } =
filterPostingsByExcludeTerms(roleFiltered, query.excludeTerms);
+ // Local-only (#809): the hard arm of the location axis, applied LAST so its
+ // never-fail-closed check reads the set the user will actually see — running
+ // it before the role/exclude filters could keep a location that those then
+ // empty anyway, and the notice would name the wrong control. Skipped entirely
+ // unless the user turned the toggle on AND a location is set; the soft axis
+ // in `rankPostings` is unchanged either way.
+ const { postings: filtered, suppressed: locationSuppressed } =
+ filterPostingsByLocation(
+ excludeFiltered,
+ query.locationOnly ? query.location : undefined,
+ );
+
return {
jobs: rankPostings(parsed, filtered, query),
degradedProviders: [...degradedProviders],
providerCount,
excludeSuppressed,
roleSuppressed,
+ locationSuppressed,
+ locationFilteredOut: excludeFiltered.length - filtered.length,
rawPostings: [...rawPostings],
};
}
diff --git a/src/lib/job-search/search.ts b/src/lib/job-search/search.ts
index 4a587162..32add878 100644
--- a/src/lib/job-search/search.ts
+++ b/src/lib/job-search/search.ts
@@ -109,6 +109,26 @@ export interface JobSearchResult {
* panel surfaces this as a notice pointing at the Role chips rather than
* showing a misleading empty state. */
roleSuppressed: boolean;
+ /** True when `locationOnly` (#809) would have emptied the WHOLE filtered
+ * result set — the local-only filter was skipped (never-fail-closed, the
+ * same floor as `excludeSuppressed`/`roleSuppressed`) and every posting
+ * below is un-location-filtered. The keyless aggregator feeds are
+ * inconsistent about populating a posting's `location` at all, so a set that
+ * named no locations would otherwise blank the panel with no way to tell
+ * "nothing near you" from "the feed didn't say". The panel surfaces this as
+ * a notice pointing at the local-only toggle. Always false when the toggle
+ * is off or no location is set. */
+ locationSuppressed: boolean;
+ /** How many postings the local-only filter (#809) actually removed. Zero
+ * whenever the toggle is off, no location is set, or the filter was
+ * suppressed. Distinct from `locationSuppressed`, which reports the filter
+ * DECLINING to run: this is the count when it did run and dropped things.
+ * Rendered as a line beside the match count — #809 requires that whatever is
+ * hidden is stated as a count and recoverable, and unticking the toggle is
+ * the recovery. The role/exclude filters state no such count; they predate
+ * the requirement, and adding it for them is a separate change to their own
+ * copy, not something to smuggle in here. */
+ locationFilteredOut: number;
/** The deduped, `matchesQuery`-filtered postings BEFORE role/exclude
* filtering and ranking (#568) — everything `refineSearchResult` needs to
* redo that local work. `FindJobsPanel` keeps this snapshot from the last