feat: show diff view for AI CV rewrites

The master-CV rewrite preview replaced text without showing what
changed -- the teardown flagged this as the biggest unmanaged AI risk
(a rewrite silently upgrading "assisted with migration" to "led
migration" was invisible). Add a "Show changes" toggle on the rewrite
preview panel that renders a word-level diff (before = current master
text or the targeted section's stored content, after = the AI's
rewrite) instead of the flat replacement text.

Defaults to off: an existing test proved diff-by-default breaks the
familiar plain-text read (word-fragmented spans aren't matchable as
one block), and it's a better UX default regardless -- read normally,
opt into the diff when you want the trust signal.

Uses the `diff` package (word-level diffWords) rather than hand-rolled
LCS; no existing dependency covers this, and it's a solved problem.
This commit is contained in:
cesnimda
2026-07-12 15:21:04 +02:00
parent 66384bda60
commit cbd045a0d3
7 changed files with 103 additions and 20 deletions
+1
View File
@@ -24,3 +24,4 @@ next-env.d.ts
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.tsbuildinfo
+10 -17
View File
@@ -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",
+1
View File
@@ -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",
@@ -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 (
<Box sx={{ whiteSpace: "pre-wrap", fontSize: "0.875rem", lineHeight: 1.6 }}>
{parts.map((part, index) => (
<Box
key={index}
component="span"
sx={{
backgroundColor: part.added ? "success.main" : part.removed ? "error.main" : "transparent",
color: part.added || part.removed ? "common.white" : "text.primary",
opacity: part.added || part.removed ? 0.85 : 1,
textDecoration: part.removed ? "line-through" : "none",
borderRadius: part.added || part.removed ? 0.5 : 0,
px: part.added || part.removed ? 0.25 : 0,
}}
>
{part.value}
</Box>
))}
</Box>
);
}
+8
View File
@@ -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);
+26
View File
@@ -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(<TextDiff before="Backend engineer." after="Backend engineer." />);
expect(screen.getByText("Backend engineer.")).toBeInTheDocument();
});
it("marks removed words with a distinct style from unchanged text", () => {
render(<TextDiff before="Assisted with the migration." after="Led the migration." />);
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(<TextDiff before="" after="Brand new summary." />);
expect(screen.getByText(/Brand new summary\./)).toBeInTheDocument();
});
});
+25 -3
View File
@@ -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<CvBuilderLanguage>("English");
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
const [showRewriteDiff, setShowRewriteDiff] = useState(false);
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
const [pdfCarousel, setPdfCarousel] = useState<PdfCarouselItem[]>([]);
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() {
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1, flexWrap: "wrap" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
<Box sx={{ display: "flex", gap: 0.75, alignItems: "center" }}>
{rewriteReady ? (
<Chip
size="small"
variant={showRewriteDiff ? "filled" : "outlined"}
color={showRewriteDiff ? "primary" : "default"}
label="Show changes"
onClick={() => setShowRewriteDiff((current) => !current)}
/>
) : null}
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
</Box>
</Box>
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
{rewriteReady ? (
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
showRewriteDiff ? (
<TextDiff before={rewriteBeforeText} after={rewritePreview?.sectionName ? rewritePreview?.rewrittenText ?? "" : rewritePreview?.fullText ?? ""} />
) : (
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
)
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
)}