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.
)}