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
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,12 @@ production`) so maintainer/staging/teammate traffic can be excluded from
user-facing metrics; this app supplies the properties, the filter itself is
configured in the PostHog project rather than here.

The one exception is the optional feedback panel (`feedback_submitted`): it
The one exception is the optional feedback dialog (`feedback_submitted`): it
carries a 1–5 `rating` plus, **only when the user chooses to fill them**, a
`category`, free-text `feedback_text`, and an `email`. That email is the single
piece of user-supplied PII any event can carry — it is opt-in (the field is
blank by default and is never sent as an empty string; see `buildFeedbackProps`),
attached only as a property on that single event, and the whole panel is
attached only as a property on that single event, and the whole dialog is
hidden in builds where `VITE_POSTHOG_KEY` is unset. It is never promoted to a
PostHog person profile: this app never calls `identify()` or
`setPersonProperties` anywhere. (`register()` is used, but only for the
Expand All @@ -167,9 +167,8 @@ scoped to your browser:

| Key | Purpose |
|---|---|
| `ocv_feedback_seen` | counts how many times the feedback ask has rendered; after 2 the panel switches from the full card to a quiet compact star strip |
| `ocv_feedback_submitted` | set after a successful feedback submit so the panel never re-asks in that browser |
| `ocv_star_cta_seen` | one-time flag so the post-feedback GitHub-star prompt shows only once per browser |
| `ocv_feedback_dialog_seen` | counts how many times the feedback dialog has been opened; once it is non-zero the dialog no longer opens itself after an export — you can still open it from the `★ Feedback` button |
| `ocv_feedback_submitted` | set after a successful feedback submit so the dialog never opens itself again in that browser |
| `ocv_gh_stars_cache` | caches the fetched star count (~1h TTL) to avoid re-hitting the GitHub API on every parse |
| `ocv_internal` | marks this browser as internal so team traffic can be filtered out of analytics; set via `?ocv_internal=1`, cleared via `?ocv_internal=0` — only written in builds where `VITE_POSTHOG_KEY` is set |

Expand Down
32 changes: 31 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ import { ResumeLibrary } from "./components/features/ResumeLibrary.tsx";
import { ShareWithExtensionBar } from "./components/features/ShareWithExtensionBar.tsx";
import { ExportDialog } from "./components/features/ExportDialog.tsx";
import { ResumeChooserDialog } from "./components/features/ResumeChooserDialog.tsx";
import { FeedbackDialog } from "./components/features/FeedbackDialog.tsx";
import { useAnalyzedResume } from "./hooks/useAnalyzedResume.ts";
import { useResumeLibrary } from "./hooks/useResumeLibrary.ts";
import { useReplaceResumeOnDrop } from "./hooks/useReplaceResumeOnDrop.ts";
import { useAutoRestoreResume } from "./hooks/useAutoRestoreResume.ts";
import { useAutosaveResume } from "./hooks/useAutosaveResume.ts";
import { useLlmRecovery } from "./hooks/useLlmRecovery.ts";
import { useFeedbackDialog } from "./hooks/useFeedbackDialog.ts";
import {
departToJobs,
departToJobsAndNavigate,
Expand Down Expand Up @@ -122,6 +124,13 @@ export default function App() {
// committed frame in between for that to be visible in.
if (recovery === null && exportOpen) setExportOpen(false);

// The multi-step feedback interstitial (#900) — one controller for both
// ways in: the ambient `[★ Feedback]` button (threaded through `Result` to
// `ParsedHeader`) and the automatic trigger, earned below from the export
// dialog's résumé-only callback and flushed when that dialog closes. Owned
// here, next to `exportOpen`, since both dialogs are page-level siblings.
const feedback = useFeedbackDialog();

// Local-first resume library (#322) — save/reload parsed resumes without
// re-uploading. Loading hydrates the "done" state from the cached parse.
const library = useResumeLibrary();
Expand Down Expand Up @@ -455,6 +464,7 @@ export default function App() {
badge="alpha"
onSavedJobsNavigate={goToSavedJobs}
journey={{ state: journeyState, onSelect: onJourneySelect }}
onOpenFeedback={feedback.openDialog}
>
{(state.phase === "idle" ||
state.phase === "parsing" ||
Expand Down Expand Up @@ -650,6 +660,8 @@ export default function App() {
// it IS the Tailor stage, done. `ResultDetail` owns the pairing;
// the key it is recorded under is only knowable here.
onTailorApplied={() => progress.mark("tailor")}
// #900 — ambient feedback button trigger.
onOpenFeedback={feedback.openDialog}
/>
{/* Hand the parse to the capture extension (#620) — self-hides when
no extension answers a probe, so it costs nothing on the visit
Expand Down Expand Up @@ -721,17 +733,35 @@ export default function App() {
// artifact matches what the page shows.
<ExportDialog
open={exportOpen}
onClose={() => setExportOpen(false)}
// #900 — closing is also when a pending feedback milestone flushes.
// The export dialog stays open after a download on purpose (#421),
// so opening the interstitial any earlier would stack a second
// native modal over the findings the download just produced.
onClose={() => {
setExportOpen(false);
feedback.notifyExportClosed();
}}
result={recovery.activeResult}
score={recovery.activeScore}
contactOverrides={edit.contactOverrides}
// #826 — any of the three artifacts reaching the user completes the
// Download stage, the audit report included: the ledger records that
// the user went through here, and the report is downloaded from it.
onExported={() => progress.mark("download")}
// #900 — the résumé-only subset of the same success point feeds the
// feedback dialog's automatic first-export trigger.
onResumeExported={feedback.notifyResumeExported}
/>
)}

{/* #900 — the multi-step feedback interstitial. Page level, beside the
export dialog whose résumé-only callback can open it automatically. */}
<FeedbackDialog
open={feedback.open}
onClose={feedback.close}
onSubmitted={feedback.markSubmitted}
/>

{/* #826 — which saved résumé did you mean? Opened only by a rail click
that arrived with nothing on the page and two or more saved, and it
FINISHES that click rather than merely loading (see the file). Page
Expand Down
15 changes: 9 additions & 6 deletions src/components/Result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import type { CascadeResult } from "../lib/heuristics/types.ts";
import type { EditableParse } from "../hooks/useEditableParse.ts";
import { Card, StatusBadge, Button, ErrorState } from "@design-system";
import { FeedbackPanel } from "./features/FeedbackPanel.tsx";
import { AtsScoreReadout } from "./features/AtsScoreReadout.tsx";
import { isScoreRevealed } from "../lib/contact.ts";
import { useResumeAnalysisLlm } from "../hooks/useResumeAnalysisLlm.ts";
Expand Down Expand Up @@ -57,6 +56,10 @@ interface ResultProps {
/** A JD-steered whole-résumé rewrite was applied (#826) — see `ResultDetail`,
* which pairs the rewrite event with the steering it owns. */
onTailorApplied?: () => void;
/** Opens `FeedbackDialog` (#900) — threaded down to `ParsedHeader`'s
* ambient `[★ Feedback]` trigger. `App` owns the dialog itself, since the
* automatic export-milestone trigger fires from page level too. */
onOpenFeedback?: () => void;
}

export function Result({
Expand All @@ -70,6 +73,7 @@ export function Result({
autosave,
onJdContextChange,
onTailorApplied,
onOpenFeedback,
}: ResultProps) {
const isFontsUnmappable = result.triggers.includes("fonts_unmappable");
if (isFontsUnmappable) {
Expand All @@ -90,6 +94,7 @@ export function Result({
autosave={autosave}
onJdContextChange={onJdContextChange}
onTailorApplied={onTailorApplied}
onOpenFeedback={onOpenFeedback}
/>
);
}
Expand All @@ -107,6 +112,7 @@ function ParsedCard({
autosave,
onJdContextChange,
onTailorApplied,
onOpenFeedback,
}: {
result: CascadeResult;
bytes?: ArrayBuffer;
Expand All @@ -118,6 +124,7 @@ function ParsedCard({
autosave: AutosaveResume;
onJdContextChange?: (jdContext: string | null) => void;
onTailorApplied?: () => void;
onOpenFeedback?: () => void;
}) {
const triggerCount = result.triggers.length;
const { activeResult, activeScore, parseIdentity, isLlmRecovered } = recovery;
Expand Down Expand Up @@ -182,6 +189,7 @@ function ParsedCard({
onReset={onReset}
saveState={autosave.state}
onSave={autosave.save}
onOpenFeedback={onOpenFeedback}
/>

{isTwoColumn && (
Expand All @@ -199,11 +207,6 @@ function ParsedCard({
role are filled in below.
</p>
)}
{/* Star-rating feedback (#51). The "Report a parsing gap" affordance
lives in the "What an ATS misses" bottom section of the "Local AI
feedback" disclosure (#273), next to the disagreements it
characterizes. */}
<FeedbackPanel />
</Card>

<ResultDetail
Expand Down
13 changes: 13 additions & 0 deletions src/components/features/ExportDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ function render(
overrides: ContactOverrides = {},
onClose = () => {},
onExported?: () => void,
onResumeExported?: () => void,
): HTMLElement {
container = document.createElement("div");
document.body.appendChild(container);
Expand All @@ -139,6 +140,7 @@ function render(
score: SCORE,
contactOverrides: overrides,
onExported,
onResumeExported,
}),
);
});
Expand Down Expand Up @@ -432,6 +434,17 @@ describe("ExportDialog", () => {
expect(onExported).toHaveBeenCalledTimes(3);
});

it("fires the résumé-only feedback milestone (#900) for PDF and Markdown, never the report", () => {
const onResumeExported = vi.fn();
render(exportable(), {}, () => {}, undefined, onResumeExported);
captured.report?.();
expect(onResumeExported).not.toHaveBeenCalled();
captured.pdf?.();
expect(onResumeExported).toHaveBeenCalledTimes(1);
captured.markdown?.();
expect(onResumeExported).toHaveBeenCalledTimes(2);
});

// #621 — the export reports what it could not render cleanly, on the row that
// produced the file. Advisory: the user already has the PDF.
describe("export findings", () => {
Expand Down
21 changes: 19 additions & 2 deletions src/components/features/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ interface ExportDialogProps {
* every row can fail into an inline error without the dialog closing.
*/
onExported?: () => void;
/**
* Fired only when the résumé itself — PDF or Markdown — downloads, never
* the audit report (#900). Feeds `FeedbackDialog`'s automatic milestone
* trigger, which cares about "you got your résumé" rather than every
* artifact this dialog can produce.
*/
onResumeExported?: () => void;
}

export function ExportDialog({
Expand All @@ -107,6 +114,7 @@ export function ExportDialog({
score,
contactOverrides,
onExported,
onResumeExported,
}: ExportDialogProps) {
const [body, setBody] = useState<"formats" | "gate">("formats");
// Focus for the half of the swap `ExportGateBody` cannot own. It focuses
Expand All @@ -125,8 +133,17 @@ export function ExportDialog({
const [includeIdentity, setIncludeIdentity] = useState(false);
const formatName = useId();

const pdf = useDownloadPdf(result, score, onExported);
const markdown = useDownloadMarkdown(result, score, onExported);
// pdf/markdown fire BOTH callbacks — the shared Download-stage mark (#826)
// and the résumé-only feedback milestone (#900). `report` fires only the
// former: an audit report is not "you got your résumé".
const pdf = useDownloadPdf(result, score, () => {
onExported?.();
onResumeExported?.();
});
const markdown = useDownloadMarkdown(result, score, () => {
onExported?.();
onResumeExported?.();
});
const report = useDownloadReport(result, score, onExported);

// Re-derived every render, so the checklist reflects the edit the user just
Expand Down
105 changes: 105 additions & 0 deletions src/components/features/FeedbackConstructiveStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The offlinecv Authors

/**
* FeedbackConstructiveStep — Step 2B of `FeedbackDialog` (#900), reached
* after a 1-3★ rating. Category pills route the report to a rough area
* without forcing a taxonomy pick; the rest — description, opt-in follow-up
* contact — mirrors the retired inline panel's own fields.
*
* Split out of `FeedbackDialog` so that file stays a thin step router — this
* one owns its own form state, and `FeedbackPositiveStep` (Step 2A) mirrors
* the same split for the opposite sentiment.
*/

import { useState } from "react";
import { Button, EmailOptIn, TextAreaField } from "@design-system";
import type { FeedbackArgs } from "../../lib/analytics.ts";

const CATEGORIES = ["Parsing", "Scoring", "UI / Editor", "Export", "Other"] as const;
type Category = (typeof CATEGORIES)[number];

interface FeedbackConstructiveStepProps {
onSubmit: (fields: Omit<FeedbackArgs, "rating">) => void;
/** Returns to Step 1 without losing the star rating — the only way a
* keyboard user who overshot into 1-3★ (native radio: arrow keys select on
* the same keystroke that moves focus) can reach 4-5★ instead. */
onBack: () => void;
onClose: () => void;
}

export function FeedbackConstructiveStep({
onSubmit,
onBack,
onClose,
}: FeedbackConstructiveStepProps) {
const [category, setCategory] = useState<Category | "">("");
const [feedbackText, setFeedbackText] = useState("");
const [wantsContact, setWantsContact] = useState(false);
const [email, setEmail] = useState("");

function handleSubmit() {
onSubmit({
category: category || undefined,
feedbackText: feedbackText || undefined,
wantsContact,
// Email is PII — only forwarded when the user opted into follow-up.
email: wantsContact ? email : undefined,
});
}

return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-content-secondary">
What area needs improvement? (optional)
</span>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((c) => {
const selected = category === c;
return (
<Button
key={c}
type="button"
variant={selected ? "primary" : "ghost"}
aria-pressed={selected}
onClick={() => setCategory(selected ? "" : c)}
className="rounded-full border border-border-light px-3 py-1"
>
{c}
</Button>
);
})}
</div>
</div>

<TextAreaField
value={feedbackText}
onChange={setFeedbackText}
label="Tell us what went wrong or what you'd change (optional)"
placeholder="Tell us what went wrong or what you'd change…"
rows={3}
/>

<EmailOptIn
checkboxLabel="I'd like the team to follow up on this"
onChange={(next, nextEmail) => {
setWantsContact(next);
setEmail(nextEmail);
}}
/>

<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onBack}>
Back
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel / Skip
</Button>
<Button variant="primary" size="sm" onClick={handleSubmit}>
Submit Feedback
</Button>
</div>
</div>
);
}
Loading
Loading