diff --git a/job-tracker-ui/.gitignore b/job-tracker-ui/.gitignore index f26ed9f..731038f 100644 --- a/job-tracker-ui/.gitignore +++ b/job-tracker-ui/.gitignore @@ -24,3 +24,4 @@ next-env.d.ts npm-debug.log* yarn-debug.log* yarn-error.log* +*.tsbuildinfo diff --git a/job-tracker-ui/package-lock.json b/job-tracker-ui/package-lock.json index 808e22f..51afced 100644 --- a/job-tracker-ui/package-lock.json +++ b/job-tracker-ui/package-lock.json @@ -27,6 +27,7 @@ "@types/react-dom": "^19.2.3", "axios": "^1.15.0", "date-fns": "^4.1.0", + "diff": "^9.0.0", "next": "^16.2.10", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -7926,6 +7927,15 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", @@ -17234,23 +17244,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/tapable": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json index 476007f..e6702af 100644 --- a/job-tracker-ui/package.json +++ b/job-tracker-ui/package.json @@ -22,6 +22,7 @@ "@types/react-dom": "^19.2.3", "axios": "^1.15.0", "date-fns": "^4.1.0", + "diff": "^9.0.0", "next": "^16.2.10", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/job-tracker-ui/src/components/TextDiff.tsx b/job-tracker-ui/src/components/TextDiff.tsx new file mode 100644 index 0000000..4b03225 --- /dev/null +++ b/job-tracker-ui/src/components/TextDiff.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Box } from "@mui/material"; +import { diffWords } from "diff"; + +// AI-mutation trust primitive (career-workspace-implementation-roadmap.md Phase F5): every AI +// rewrite should show what it actually changed before the user accepts it, instead of silently +// overwriting. Word-level diff keeps small edits readable; whole-paragraph rewrites still show +// as one big change, which is itself useful signal ("this replaced almost everything"). +export default function TextDiff({ before, after }: { before: string; after: string }) { + const parts = React.useMemo(() => diffWords(before ?? "", after ?? ""), [before, after]); + + return ( + + {parts.map((part, index) => ( + + {part.value} + + ))} + + ); +} diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index b143415..d87f958 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -270,6 +270,14 @@ test('profile page rewrite tools use selected template and saved job context', a expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument(); + const showChangesToggle = screen.getByText(/show changes/i); + fireEvent.click(showChangesToggle); + expect(screen.queryByText(/clearer, sharper positioning for backend platform roles/i)).not.toBeInTheDocument(); + expect(screen.getByText(/Clearer/i)).toBeInTheDocument(); + + fireEvent.click(showChangesToggle); + expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument(); + const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i }); fireEvent.click(buildCarouselButton); diff --git a/job-tracker-ui/src/text-diff.test.tsx b/job-tracker-ui/src/text-diff.test.tsx new file mode 100644 index 0000000..9695f8b --- /dev/null +++ b/job-tracker-ui/src/text-diff.test.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import TextDiff from "./components/TextDiff"; + +describe("TextDiff", () => { + it("renders unchanged text without strike-through or highlight styling", () => { + render(); + expect(screen.getByText("Backend engineer.")).toBeInTheDocument(); + }); + + it("marks removed words with a distinct style from unchanged text", () => { + render(); + const removed = screen.getByText("Assisted with", { selector: "span" }); + const unchanged = screen.getByText(/the migration\./, { selector: "span" }); + // jsdom doesn't resolve emotion's generated CSS cascade for getComputedStyle, so assert the + // component branched into a different (MUI-generated) class for removed vs. unchanged text + // rather than the literal computed decoration value. + expect(removed.className).not.toBe(unchanged.className); + }); + + it("treats an empty before as an entirely new addition", () => { + render(); + expect(screen.getByText(/Brand new summary\./)).toBeInTheDocument(); + }); +}); diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx index d018433..3da0ef6 100644 --- a/job-tracker-ui/src/views/ProfilePage.tsx +++ b/job-tracker-ui/src/views/ProfilePage.tsx @@ -13,6 +13,7 @@ import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import AuthStatusCard from "../components/AuthStatusCard"; import EmailProviderConnections from "../components/EmailProviderConnections"; import CropImageDialog from "../components/CropImageDialog"; +import TextDiff from "../components/TextDiff"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { @@ -256,6 +257,7 @@ export default function ProfilePage() { const [cvLanguage, setCvLanguage] = useState("English"); const [selectedRewriteJobId, setSelectedRewriteJobId] = useState(""); const [rewritePreview, setRewritePreview] = useState(null); + const [showRewriteDiff, setShowRewriteDiff] = useState(false); const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState(null); const [pdfCarousel, setPdfCarousel] = useState([]); const [activePdfIndex, setActivePdfIndex] = useState(0); @@ -372,6 +374,11 @@ export default function ProfilePage() { const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0]; const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null; const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim()); + // What the rewrite is replacing, so the preview can show a diff instead of silently swapping + // text out from under the user (career-workspace-implementation-roadmap.md Phase F5). + const rewriteBeforeText = rewritePreview?.sectionName + ? structuredCv.sections.find((section) => section.name === rewritePreview.sectionName)?.content ?? "" + : profileCvText; const activePdfItem = pdfCarousel[activePdfIndex] ?? null; const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => { @@ -1180,13 +1187,28 @@ export default function ProfilePage() { - + {rewritePreview?.sectionName || "Full rewritten CV text"} - {rewriteReady ? : null} + + {rewriteReady ? ( + setShowRewriteDiff((current) => !current)} + /> + ) : null} + {rewriteReady ? : null} + {rewriteReady ? ( - {rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText} + showRewriteDiff ? ( + + ) : ( + {rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText} + ) ) : ( Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it. )}