",
+ text: "Responsible for various design tasks",
+};
+
+const suggestion = (patch: Partial[0][number]>) => ({
+ section: "Experience",
+ passageId: "p1",
+ issue: "Names a duty, not an outcome.",
+ rewrite: "Produced packaging and print work for local retail clients",
+ impact: "high" as const,
+ ...patch,
+});
+
+describe("mapWritingReview", () => {
+ it("turns a rewrite of a passage it was sent into a proposal for that passage", () => {
+ const { proposals, notes } = mapWritingReview([suggestion({})], [passage]);
+
+ expect(notes).toEqual([]);
+ expect(proposals).toEqual([
+ {
+ id: "w1",
+ target: passage.target,
+ location: passage.location,
+ before: "
Responsible for various design tasks
",
+ after: "
Produced packaging and print work for local retail clients
",
+ why: "Names a duty, not an outcome.",
+ status: "pending",
+ source: "check",
+ },
+ ]);
+ });
+
+ it("keeps advice, unchanged rewrites and unknown passages as notes", () => {
+ const { proposals, notes } = mapWritingReview(
+ [
+ suggestion({ rewrite: null }),
+ suggestion({ rewrite: " Responsible for various design tasks " }),
+ suggestion({ passageId: "p9", section: "Summary", impact: "low" }),
+ ],
+ [passage],
+ );
+
+ expect(proposals).toEqual([]);
+ expect(notes.map((note) => [note.location, note.quote, note.target])).toEqual([
+ [passage.location, passage.text, { kind: "item", sectionId: "experience", itemId: "kettle" }],
+ [passage.location, passage.text, { kind: "item", sectionId: "experience", itemId: "kettle" }],
+ ["Summary", "", null],
+ ]);
+ });
+});
diff --git a/apps/web/src/features/resume/editor/check/review.ts b/apps/web/src/features/resume/editor/check/review.ts
new file mode 100644
index 000000000..9d21b3b6e
--- /dev/null
+++ b/apps/web/src/features/resume/editor/check/review.ts
@@ -0,0 +1,57 @@
+import type { PageMapTarget } from "@reactive-resume/pdf/page-map";
+import type { Passage } from "../proposals/passages";
+import type { Proposal } from "../proposals/proposals";
+import type { WritingNote } from "../store";
+import { replaceBlockText } from "../proposals/proposals";
+
+type ReviewSuggestion = {
+ section: string | null;
+ passageId: string | null;
+ issue: string;
+ rewrite: string | null;
+ impact: WritingNote["impact"];
+};
+
+const toPageTarget = (target: Proposal["target"]): PageMapTarget =>
+ target.itemId
+ ? { kind: "item", sectionId: target.sectionId, itemId: target.itemId }
+ : { kind: "section", sectionId: target.sectionId };
+
+/**
+ * Sorts a writing review's suggestions: a rewrite of a passage that was sent becomes a proposal to accept or
+ * reject; anything else (advice, or a rewrite of text it wasn't given) stays a note.
+ */
+export function mapWritingReview(suggestions: readonly ReviewSuggestion[], passages: readonly Passage[]) {
+ const byId = new Map(passages.map((passage) => [passage.id, passage]));
+ const proposals: Proposal[] = [];
+ const notes: WritingNote[] = [];
+
+ for (const suggestion of suggestions) {
+ const passage = suggestion.passageId ? byId.get(suggestion.passageId) : undefined;
+ const rewrite = suggestion.rewrite?.trim();
+
+ if (passage && rewrite && rewrite !== passage.text) {
+ proposals.push({
+ id: `w${proposals.length + 1}`,
+ target: passage.target,
+ location: passage.location,
+ before: passage.html,
+ after: replaceBlockText(passage.html, rewrite),
+ why: suggestion.issue,
+ status: "pending",
+ source: "check",
+ });
+ continue;
+ }
+
+ notes.push({
+ location: passage?.location ?? suggestion.section ?? "",
+ impact: suggestion.impact,
+ quote: passage?.text ?? "",
+ note: suggestion.issue,
+ target: passage ? toPageTarget(passage.target) : null,
+ });
+ }
+
+ return { proposals, notes };
+}
diff --git a/apps/web/src/features/resume/editor/check/use-check.ts b/apps/web/src/features/resume/editor/check/use-check.ts
new file mode 100644
index 000000000..af48e26ed
--- /dev/null
+++ b/apps/web/src/features/resume/editor/check/use-check.ts
@@ -0,0 +1,26 @@
+import type { AtsReport } from "@reactive-resume/resume/ats";
+import type { ResumeData } from "@reactive-resume/schema/resume/data";
+import type { CheckIssue } from "./issues";
+import { useDeferredValue, useMemo } from "react";
+import { lintResumeForAts } from "@reactive-resume/resume/ats";
+import { useResumeData } from "@/features/resume/builder/draft";
+import { buildIssues } from "./issues";
+
+export type CheckResult = { data: ResumeData; report: AtsReport; issues: CheckIssue[] };
+
+/** The live checks on the resume as it is now (deferred, so typing stays smooth), with numbered issues. */
+export function useCheck(): CheckResult | null {
+ const data = useDeferredValue(useResumeData());
+
+ return useMemo(() => {
+ if (!data) return null;
+ const report = lintResumeForAts(data);
+ return { data, report, issues: buildIssues(report, data) };
+ }, [data]);
+}
+
+/** Open issues, for the badge on the Check tab. Ignored ones don't count. */
+export function useOpenIssueCount(): number {
+ const data = useDeferredValue(useResumeData());
+ return useMemo(() => (data ? lintResumeForAts(data).findings.length : 0), [data]);
+}
diff --git a/apps/web/src/features/resume/editor/check/writing-tab.tsx b/apps/web/src/features/resume/editor/check/writing-tab.tsx
new file mode 100644
index 000000000..574c20a2b
--- /dev/null
+++ b/apps/web/src/features/resume/editor/check/writing-tab.tsx
@@ -0,0 +1,277 @@
+import type { ResumeData } from "@reactive-resume/schema/resume/data";
+import type { WritingNote } from "../store";
+import type { CheckIssue } from "./issues";
+import { t } from "@lingui/core/macro";
+import { Plural, Trans } from "@lingui/react/macro";
+import { useMutation } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { useMemo, useState } from "react";
+import { buildMarkdown } from "@reactive-resume/resume/markdown";
+import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
+import { Icon } from "@reactive-resume/ui/components/icon";
+import { Spinner } from "@reactive-resume/ui/components/spinner";
+import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
+import { cn } from "@reactive-resume/utils/style";
+import { AiProviderPicker } from "@/features/settings/integrations/components/ai-provider-picker";
+import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
+import { getOrpcErrorMessage } from "@/libs/error-message";
+import { orpc } from "@/libs/orpc/client";
+import { collectPassages } from "../proposals/passages";
+import { ProposalList } from "../proposals/proposal-list";
+import { useEditorStore } from "../store";
+import { getSectionName } from "./issues";
+import { mapWritingReview } from "./review";
+
+/** The endpoint's caps; the text and lists are trimmed here so a long resume is never refused. */
+const MAX_TEXT_CHARS = 50_000;
+const MAX_FINDINGS = 120;
+const MAX_PASSAGES = 120;
+const MAX_PASSAGE_CHARS = 1_000;
+
+type WritingTabProps = { data: ResumeData; issues: readonly CheckIssue[] };
+
+/**
+ * Writing: an opt-in AI review of the wording. It says what it sends before it runs, returns rewrites of bullets
+ * and paragraphs as proposals to accept or reject, and is never part of the score. Its errors stay in this tab.
+ */
+export function WritingTab({ data, issues }: WritingTabProps) {
+ const { usableProviders, hasUsableProvider, isLoading } = useHasUsableAiProvider();
+ const [providerOverride, setProviderOverride] = useState(null);
+ const [choosing, setChoosing] = useState(false);
+ const review = useEditorStore((state) => state.writingReview);
+ const proposals = useEditorStore((state) => state.proposals);
+ const setWritingReview = useEditorStore((state) => state.setWritingReview);
+ const setProposals = useEditorStore((state) => state.setProposals);
+
+ const provider = usableProviders.find((entry) => entry.id === providerOverride) ?? usableProviders[0];
+ const passages = useMemo(
+ () =>
+ collectPassages(data, {
+ summary: getSectionName(data, "summary"),
+ sectionTitle: (sectionId) => getSectionName(data, sectionId),
+ bullet: (n) => t`bullet ${n}`,
+ paragraph: (n) => t`paragraph ${n}`,
+ }).slice(0, MAX_PASSAGES),
+ [data],
+ );
+
+ const { mutate, isPending, error, reset } = useMutation({
+ ...orpc.ai.atsReview.mutationOptions(),
+ onSuccess: (result) => {
+ const { proposals: next, notes } = mapWritingReview(result.suggestions, passages);
+ setProposals(next);
+ setWritingReview({ summary: result.summary, strengths: result.strengths, notes });
+ },
+ });
+
+ const run = () => {
+ reset();
+ mutate({
+ ...(provider ? { aiProviderId: provider.id } : {}),
+ extractedText: buildMarkdown(data).slice(0, MAX_TEXT_CHARS),
+ findings: issues.slice(0, MAX_FINDINGS).map((issue) => ({
+ code: issue.finding.code,
+ severity: issue.finding.severity,
+ message: issue.title.slice(0, 300),
+ })),
+ passages: passages.map((passage) => ({
+ id: passage.id,
+ where: passage.location.slice(0, 200),
+ text: passage.text.slice(0, MAX_PASSAGE_CHARS),
+ })),
+ });
+ };
+
+ if (isLoading) return null;
+
+ if (!hasUsableProvider) {
+ return (
+
+
+ A second opinion on your wording
+
+
+
+ Connect your own AI provider to have a language model read your bullets and suggest rewrites. Issues and Job
+ match need no provider.
+
+
+
+ Open AI settings
+
+
+ );
+ }
+
+ if (isPending) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ {getOrpcErrorMessage(error, {
+ byCode: {
+ BAD_GATEWAY: t`Couldn't reach your AI provider. Its key may have expired.`,
+ BAD_REQUEST: t`The provider returned a review that couldn't be read. Try again.`,
+ PRECONDITION_FAILED: t`AI providers are unavailable until ENCRYPTION_SECRET is configured.`,
+ },
+ fallback: t`The review couldn't finish. Try again.`,
+ })}
+
+
+
+
+
+ Open AI settings
+
+
+
+ Issues and Job match still work without AI.
+
+
+ );
+ }
+
+ if (!review) {
+ return (
+
+
+ A second opinion on your wording
+
+
+
+ The checks are mechanical. This asks a language model how a reader might react to your bullets and suggests
+ rewrites. It isn't part of the score.
+
+
+
+
+
+
+ Sends your resume's text only to {provider?.label} · {provider?.model}, with your key.
+
+
+
+
+ {note.target && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/resume/editor/proposals/passages.ts b/apps/web/src/features/resume/editor/proposals/passages.ts
new file mode 100644
index 000000000..3b7ae50c6
--- /dev/null
+++ b/apps/web/src/features/resume/editor/proposals/passages.ts
@@ -0,0 +1,109 @@
+import type { ResumeData } from "@reactive-resume/schema/resume/data";
+import type { ProposalTarget } from "./proposals";
+import { stripHtml } from "@reactive-resume/utils/string";
+import { describeEntry, getEntries, resolveSection } from "../write/model";
+
+/** A bullet or paragraph a reviewer can suggest rewriting, with where it sits. */
+export type Passage = {
+ id: string;
+ target: ProposalTarget;
+ /** "Experience · Studio Kettle · bullet 1". */
+ location: string;
+ /** The whole block (`
…
` or `
…
`) exactly as stored, so a proposal can find it again. */
+ html: string;
+ text: string;
+};
+
+// A paragraph or list item with no paragraph or list item inside it: `
…
` yields the `
`.
+const LEAF_BLOCK = /<(p|li)(?:\s[^>]*)?>((?:(?!<\/?(?:p|li)[\s>])[\s\S])*?)<\/\1>/gi;
+
+const ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
+
+const decodeEntities = (text: string) =>
+ text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity, name: string) => {
+ if (name.startsWith("#x") || name.startsWith("#X")) return String.fromCodePoint(Number.parseInt(name.slice(2), 16));
+ if (name.startsWith("#")) return String.fromCodePoint(Number.parseInt(name.slice(1), 10));
+ return ENTITIES[name.toLowerCase()] ?? entity;
+ });
+
+const toText = (html: string) => decodeEntities(stripHtml(html)).replace(/\s+/g, " ").trim();
+
+type Block = { html: string; text: string; bullet: boolean };
+
+/** The leaf paragraphs and list items of rich text, in order, skipping empty ones. */
+export function splitBlocks(html: string): Block[] {
+ const blocks: Block[] = [];
+
+ for (const match of html.matchAll(LEAF_BLOCK)) {
+ const text = toText(match[2] ?? "");
+ if (!text) continue;
+
+ // Inside a list when more
have opened than closed before this block.
+ const preceding = html.slice(0, match.index);
+ const opened = preceding.match(/
',
+ );
+ });
+});
diff --git a/apps/web/src/features/resume/editor/proposals/proposals.ts b/apps/web/src/features/resume/editor/proposals/proposals.ts
new file mode 100644
index 000000000..773eb8529
--- /dev/null
+++ b/apps/web/src/features/resume/editor/proposals/proposals.ts
@@ -0,0 +1,137 @@
+import type { ResumeData } from "@reactive-resume/schema/resume/data";
+import { produce } from "immer";
+import { findEntry } from "../write/model";
+
+/** A field that holds rich text: the summary's content, or an entry's description. */
+export type ProposalTarget = { sectionId: string; itemId?: string; field: string };
+
+type ProposalSource = "check" | "assistant" | "improve";
+
+/**
+ * A suggested edit: replace one passage of a field (a bullet or a paragraph) with another. It is never applied
+ * until accepted. Out of date is not stored: it's whenever the passage is no longer in the field.
+ */
+export type Proposal = {
+ id: string;
+ target: ProposalTarget;
+ /** Where it lands, for people: "Experience · Studio Kettle · bullet 1". */
+ location: string;
+ /** The passage as it is now: a whole `
` or `
` block, exactly as stored in the field. */
+ before: string;
+ /** The block to put in its place. */
+ after: string;
+ /** One line on why. */
+ why: string;
+ status: "pending" | "accepted" | "rejected";
+ source: ProposalSource;
+};
+
+export type ProposalState = Proposal["status"] | "stale";
+
+export function readTarget(data: ResumeData, target: ProposalTarget): string | undefined {
+ if (!target.itemId)
+ return target.sectionId === "summary" && target.field === "content" ? data.summary.content : undefined;
+
+ const value = (findEntry(data, target.sectionId, target.itemId) as Record | undefined)?.[
+ target.field
+ ];
+ return typeof value === "string" ? value : undefined;
+}
+
+function writeTarget(draft: ResumeData, target: ProposalTarget, value: string) {
+ if (!target.itemId) {
+ draft.summary.content = value;
+ return;
+ }
+
+ const entry = findEntry(draft, target.sectionId, target.itemId) as Record | undefined;
+ if (entry) entry[target.field] = value;
+}
+
+/** A pending proposal can be applied only while its passage is still in the field, word for word. */
+export const canApply = (data: ResumeData, proposal: Proposal) =>
+ readTarget(data, proposal.target)?.includes(proposal.before) ?? false;
+
+/**
+ * What a proposal shows as. Pending ones whose passage has changed are out of date; accepted ones whose edit was
+ * undone (the passage is back, the new text gone) are pending again.
+ */
+export function getProposalState(data: ResumeData, proposal: Proposal): ProposalState {
+ if (proposal.status === "rejected") return "rejected";
+
+ const value = readTarget(data, proposal.target) ?? "";
+ if (proposal.status === "accepted")
+ return value.includes(proposal.before) && !value.includes(proposal.after) ? "pending" : "accepted";
+ return value.includes(proposal.before) ? "pending" : "stale";
+}
+
+/** Puts `after` in place of `before`. Returns false (changing nothing) when the proposal is out of date. */
+export function applyProposal(draft: ResumeData, proposal: Proposal): boolean {
+ const value = readTarget(draft, proposal.target);
+ if (value === undefined || !value.includes(proposal.before)) return false;
+
+ // A replacer function, so "$&" or "$1" in the new text stay literal.
+ writeTarget(
+ draft,
+ proposal.target,
+ value.replace(proposal.before, () => proposal.after),
+ );
+ return true;
+}
+
+// Colours of the page marks (README §5.9), as the PDF needs them: old text struck through in grey, new text on
+// a pale accent highlight.
+const OLD_TEXT_COLOR = "#7d7b73";
+const NEW_TEXT_BACKGROUND = "#d4efd9";
+
+const BLOCK = /^(<(p|li)(?:\s[^>]*)?>)([\s\S]*)(<\/\2>)$/i;
+
+/** A block's opening tag, contents and closing tag; `undefined` for anything else. */
+function splitBlock(html: string) {
+ const match = BLOCK.exec(html);
+ return match ? { open: match[1] ?? "", inner: match[3] ?? "", close: match[4] ?? "" } : undefined;
+}
+
+/** The old text struck through, then the new text highlighted, inside the passage's own block. */
+function markChange(before: string, after: string) {
+ const old = splitBlock(before);
+ const next = splitBlock(after);
+ const struck = (html: string) => `${html}`;
+ const highlighted = (html: string) => `${html}`;
+
+ if (!old || !next) return `${struck(before)} ${highlighted(after)}`;
+ return `${old.open}${struck(old.inner)} ${highlighted(next.inner)}${old.close}`;
+}
+
+/**
+ * The page as it would read with the proposals: each passage struck through, followed by its replacement,
+ * highlighted. For the preview only; it's never saved.
+ */
+export function markProposals(data: ResumeData, proposals: readonly Proposal[]): ResumeData {
+ const pending = proposals.filter((proposal) => getProposalState(data, proposal) === "pending");
+ if (pending.length === 0) return data;
+
+ return produce(data, (draft) => {
+ for (const proposal of pending) {
+ const value = readTarget(draft, proposal.target);
+ if (value === undefined || !value.includes(proposal.before)) continue;
+
+ const marked = markChange(proposal.before, proposal.after);
+ writeTarget(
+ draft,
+ proposal.target,
+ value.replace(proposal.before, () => marked),
+ );
+ }
+ });
+}
+
+const escapeHtml = (text: string) =>
+ text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+
+/** The block with its text replaced by plain text (a model's rewrite), keeping the tag and its attributes. */
+export function replaceBlockText(block: string, text: string): string {
+ const html = escapeHtml(text.trim().replace(/\s+/g, " "));
+ const parts = splitBlock(block);
+ return parts ? `${parts.open}${html}${parts.close}` : html;
+}
diff --git a/apps/web/src/features/resume/editor/store.ts b/apps/web/src/features/resume/editor/store.ts
index 27e4348a2..003b4e95a 100644
--- a/apps/web/src/features/resume/editor/store.ts
+++ b/apps/web/src/features/resume/editor/store.ts
@@ -1,5 +1,6 @@
import type { PageMap, PageMapTarget } from "@reactive-resume/pdf/page-map";
import type { Template } from "@reactive-resume/schema/templates";
+import type { Proposal } from "./proposals/proposals";
import { create } from "zustand/react";
export const EDITOR_MODES = ["write", "design", "check"] as const;
@@ -8,6 +9,23 @@ export type EditorMode = (typeof EDITOR_MODES)[number];
/** The Share & export sheet's tabs; each entry point opens its own. */
export type ShareTab = "link" | "download" | "history";
+export type CheckTab = "issues" | "match" | "writing";
+
+/** Phones show one view at a time: the page, or one mode's panel. */
+export type MobileView = "page" | EditorMode;
+
+/** A writing note that isn't a rewrite: where it is, how much it matters, the words and what to consider. */
+export type WritingNote = {
+ location: string;
+ impact: "high" | "medium" | "low";
+ quote: string;
+ note: string;
+ target: PageMapTarget | null;
+};
+
+/** Check → Writing: the model's review. Its rewrites are the proposals. */
+type WritingReview = { summary: string; strengths: readonly string[]; notes: readonly WritingNote[] };
+
/** What's selected in the editor: shared by the panel and the page, so each can outline the other. */
export type EditorSelection = PageMapTarget;
@@ -40,8 +58,25 @@ type EditorStore = {
basicsOpen: boolean;
/** Design: a template shown on the page while its thumbnail is hovered or focused, not yet applied. */
previewTemplate: Template | null;
- /** The render on screen: physical pages and the page map. `version` counts renders, so Fit can wait for one. */
- rendered: { pageCount: number; pageMap: PageMap | undefined; version: number };
+ /**
+ * The render on screen: physical pages, the page map and the PDF (which Check's parser view reads). `version`
+ * counts renders, so Fit can wait for one.
+ */
+ rendered: { pageCount: number; pageMap: PageMap | undefined; file: Blob | undefined; version: number };
+ /** Phones: the view on screen; null until one is picked, which shows the current mode. */
+ mobileView: MobileView | null;
+ checkTab: CheckTab;
+ /** Check: the issue picked on its card or pin (its key). Both are outlined. */
+ checkIssue: string | null;
+ /** Check: the page as a person sees it, or the text a parser reads from it. */
+ pageView: "page" | "parser";
+ /** Check → Job match: a term whose entries are tinted on the page. */
+ highlightTerm: string | null;
+ /** Check → Job match: a posting pasted for this visit, for a resume with no application linked. */
+ pastedPosting: string;
+ /** Edits suggested for the page (Check → Writing), shown on it until accepted or rejected. */
+ proposals: readonly Proposal[];
+ writingReview: WritingReview | null;
select: (selection: EditorSelection | null) => void;
setZoom: (zoom: number | "fit") => void;
setDrawerOpen: (open: boolean) => void;
@@ -55,7 +90,16 @@ type EditorStore = {
setFocusEntry: (entryId: string | null) => void;
setBasicsOpen: (open: boolean) => void;
setPreviewTemplate: (template: Template | null) => void;
- setRendered: (render: { pageCount: number; pageMap: PageMap | undefined }) => void;
+ setRendered: (render: { pageCount: number; pageMap: PageMap | undefined; file: Blob }) => void;
+ setMobileView: (view: MobileView) => void;
+ setCheckTab: (tab: CheckTab) => void;
+ setCheckIssue: (key: string | null) => void;
+ setPageView: (view: "page" | "parser") => void;
+ setHighlightTerm: (term: string | null) => void;
+ setPastedPosting: (posting: string) => void;
+ setProposals: (proposals: readonly Proposal[]) => void;
+ setProposalStatus: (ids: readonly string[], status: Proposal["status"]) => void;
+ setWritingReview: (review: WritingReview | null) => void;
reset: () => void;
};
@@ -84,7 +128,15 @@ const initialState = {
focusEntryId: null,
basicsOpen: true,
previewTemplate: null,
- rendered: { pageCount: 0, pageMap: undefined, version: 0 },
+ rendered: { pageCount: 0, pageMap: undefined, file: undefined, version: 0 },
+ mobileView: null,
+ checkTab: "issues",
+ checkIssue: null,
+ pageView: "page",
+ highlightTerm: null,
+ pastedPosting: "",
+ proposals: [],
+ writingReview: null,
} as const;
export const useEditorStore = create()((set) => ({
@@ -112,5 +164,17 @@ export const useEditorStore = create()((set) => ({
setBasicsOpen: (basicsOpen) => set({ basicsOpen }),
setPreviewTemplate: (previewTemplate) => set({ previewTemplate }),
setRendered: (render) => set((state) => ({ rendered: { ...render, version: state.rendered.version + 1 } })),
+ setMobileView: (mobileView) => set({ mobileView }),
+ setCheckTab: (checkTab) => set({ checkTab }),
+ setCheckIssue: (checkIssue) => set({ checkIssue }),
+ setPageView: (pageView) => set({ pageView }),
+ setHighlightTerm: (highlightTerm) => set({ highlightTerm }),
+ setPastedPosting: (pastedPosting) => set({ pastedPosting }),
+ setProposals: (proposals) => set({ proposals }),
+ setProposalStatus: (ids, status) =>
+ set((state) => ({
+ proposals: state.proposals.map((proposal) => (ids.includes(proposal.id) ? { ...proposal, status } : proposal)),
+ })),
+ setWritingReview: (writingReview) => set({ writingReview }),
reset: () => set(initialState),
}));
diff --git a/apps/web/src/routes/builder/$resumeId/-components/use-editor-mode.test.ts b/apps/web/src/features/resume/editor/use-editor-mode.test.ts
similarity index 100%
rename from apps/web/src/routes/builder/$resumeId/-components/use-editor-mode.test.ts
rename to apps/web/src/features/resume/editor/use-editor-mode.test.ts
diff --git a/apps/web/src/routes/builder/$resumeId/-components/use-editor-mode.ts b/apps/web/src/features/resume/editor/use-editor-mode.ts
similarity index 100%
rename from apps/web/src/routes/builder/$resumeId/-components/use-editor-mode.ts
rename to apps/web/src/features/resume/editor/use-editor-mode.ts
diff --git a/apps/web/src/features/resume/editor/use-open-issue-count.ts b/apps/web/src/features/resume/editor/use-open-issue-count.ts
deleted file mode 100644
index 48a65370e..000000000
--- a/apps/web/src/features/resume/editor/use-open-issue-count.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useDeferredValue, useMemo } from "react";
-import { lintResumeForAts } from "@reactive-resume/resume/ats";
-import { useResumeData } from "@/features/resume/builder/draft";
-
-/** Open Check issues (errors and warnings from the live checks) for the badge on the Check tab. */
-export function useOpenIssueCount(): number {
- const data = useDeferredValue(useResumeData());
-
- return useMemo(() => {
- if (!data) return 0;
- const { counts } = lintResumeForAts(data);
- return counts.error + counts.warning;
- }, [data]);
-}
diff --git a/apps/web/src/features/resume/editor/write/entry-card.tsx b/apps/web/src/features/resume/editor/write/entry-card.tsx
index db82c1409..31bd050b3 100644
--- a/apps/web/src/features/resume/editor/write/entry-card.tsx
+++ b/apps/web/src/features/resume/editor/write/entry-card.tsx
@@ -27,12 +27,12 @@ import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { orpc } from "@/libs/orpc/client";
-import { atsFindingItemElementId } from "@/libs/resume/ats";
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
import { useEditorStore } from "../store";
import { EntryFields } from "./entries";
import { useEntry, useEntryWriter } from "./fields";
import { createEntry, describeEntry, getEntries, getPrimaryField, isDraftEntry } from "./model";
+import { entryElementId } from "./reveal";
import { useSectionTitle } from "./section-row";
const DRAFT_HINTS: Record = {
@@ -99,7 +99,7 @@ export function EntryCard({ section, entryId, index, count, page, locked, onMove
return (
`resume-item-${entryId}`;
+
/** Smooth scrolling unless the user asked for reduced motion. */
export const getScrollBehavior = (): ScrollBehavior =>
window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth";
const SECTION_ANCHOR = "sidebar-";
-const ITEM_ANCHOR = atsFindingItemElementId("");
+const ITEM_ANCHOR = entryElementId("");
/**
* The block an element in the Write panel edits, read from the section and entry anchors around it, so focusing
@@ -39,7 +41,7 @@ export function revealSelectionInPanel(selection: EditorSelection) {
const section = document.getElementById(
selection.kind === "header" ? "sidebar-basics" : `sidebar-${selection.sectionId}`,
);
- const entry = selection.kind === "item" ? document.getElementById(atsFindingItemElementId(selection.itemId)) : null;
+ const entry = selection.kind === "item" ? document.getElementById(entryElementId(selection.itemId)) : null;
(entry ?? section)?.scrollIntoView({ block: "start", behavior: getScrollBehavior() });
});
}
diff --git a/apps/web/src/features/resume/preview/preview.browser.tsx b/apps/web/src/features/resume/preview/preview.browser.tsx
index a263fb5b1..5a18be70e 100644
--- a/apps/web/src/features/resume/preview/preview.browser.tsx
+++ b/apps/web/src/features/resume/preview/preview.browser.tsx
@@ -182,9 +182,11 @@ export function ResumePreviewClient({
const activeLayer = getActivePreviewLayer(previewLayers);
const activePageCount = activeLayer?.numPages ?? 0;
const activePageMap = activeLayer?.pageMap;
+ const activeFile = activeLayer?.file;
useEffect(() => {
- if (activePageCount > 0) onRender?.({ pageCount: activePageCount, pageMap: activePageMap });
- }, [activePageCount, activePageMap, onRender]);
+ if (activePageCount > 0 && activeFile)
+ onRender?.({ pageCount: activePageCount, pageMap: activePageMap, file: activeFile });
+ }, [activePageCount, activePageMap, activeFile, onRender]);
if (!resumeData) return null;
diff --git a/apps/web/src/features/resume/preview/preview.shared.tsx b/apps/web/src/features/resume/preview/preview.shared.tsx
index 278cb6c4b..0dd4e31be 100644
--- a/apps/web/src/features/resume/preview/preview.shared.tsx
+++ b/apps/web/src/features/resume/preview/preview.shared.tsx
@@ -18,7 +18,8 @@ export type ResumePreviewProps = {
/** Drawn over each page, in page-relative coordinates; receives the page map of the render on screen. */
renderPageOverlay?: (page: { pageIndex: number; pageMap: PageMap | undefined }) => ReactNode;
/** Called whenever the render on screen changes, with its physical page count and page map. */
- onRender?: (render: { pageCount: number; pageMap: PageMap | undefined }) => void;
+ /** Each render on screen: its page count, page map and the PDF itself. */
+ onRender?: (render: { pageCount: number; pageMap: PageMap | undefined; file: Blob }) => void;
};
export type ResolvedResumePreviewProps = ResumePreviewProps & {
diff --git a/apps/web/src/features/resume/share/download-tab.tsx b/apps/web/src/features/resume/share/download-tab.tsx
index 4badd4f25..7295e4b8a 100644
--- a/apps/web/src/features/resume/share/download-tab.tsx
+++ b/apps/web/src/features/resume/share/download-tab.tsx
@@ -16,7 +16,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume } from "@/features/resume/builder/draft";
-import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count";
+import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export";
type Format = { id: ExportFormat; label: string; extension: string; icon: IconName; description: string };
diff --git a/apps/web/src/features/resume/share/history-tab.tsx b/apps/web/src/features/resume/share/history-tab.tsx
index bf469cc45..8a1a4d702 100644
--- a/apps/web/src/features/resume/share/history-tab.tsx
+++ b/apps/web/src/features/resume/share/history-tab.tsx
@@ -66,7 +66,10 @@ export function HistoryTab() {
await savePendingChanges(resume.id);
const restored = await restoreVersion.mutateAsync({ resumeId: resume.id, versionId: version.id });
useResumeStore.getState().replaceResumeFromServer(restored as Resume);
- queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), restored);
+ queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), {
+ ...restored,
+ applicationId: resume.applicationId ?? null,
+ });
setVersion(null);
void queryClient.invalidateQueries({ queryKey: listKey });
toast.add({
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index b38fd6937..b812bd6bc 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -11,3 +11,23 @@
transform: translateX(-33.333%);
}
}
+
+/* Check's score ring: the arc eases to a new score, so a fix visibly counts. */
+@property --ring-value {
+ syntax: "";
+ inherits: false;
+ initial-value: 0%;
+}
+
+@utility score-ring {
+ background: conic-gradient(var(--ring-color) 0 var(--ring-value), var(--sunken) 0);
+ transition: --ring-value var(--d3) var(--ease);
+}
+
+/* A wavy warn underline under a line of the page that has a Check issue. */
+@utility wavy-underline {
+ background-color: var(--warn);
+ mask:
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='4' viewBox='0 0 6 4'%3E%3Cpath d='M0 3 Q1.5 0 3 3 T6 3' fill='none' stroke='black' stroke-width='1.2'/%3E%3C/svg%3E")
+ repeat-x left center / 6px 4px;
+}
diff --git a/apps/web/src/libs/resume/ats.test.ts b/apps/web/src/libs/resume/ats.test.ts
deleted file mode 100644
index 3bc803d66..000000000
--- a/apps/web/src/libs/resume/ats.test.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-// @vitest-environment happy-dom
-
-import type { ResumeData } from "@reactive-resume/schema/resume/data";
-import { beforeAll, describe, expect, it } from "vitest";
-import { i18n } from "@lingui/core";
-import { ATS_RULE_CODES } from "@reactive-resume/resume/ats";
-import { defaultResumeData } from "@reactive-resume/schema/resume/default";
-import { atsFindingItemElementId, getAtsFindingLocation, getAtsFindingMessage, getAtsFindingTarget } from "./ats";
-
-const experienceItem = (id: string) => ({
- id,
- hidden: false,
- company: "Analytical Engines",
- position: "Engineer",
- location: "London",
- period: "",
- website: { url: "", label: "", inlineLink: false },
- description: "",
- roles: [],
-});
-
-function makeResume(): ResumeData {
- const data = structuredClone(defaultResumeData);
- data.sections.experience.items = [experienceItem("exp-a"), experienceItem("exp-b")] as never;
- data.customSections = [
- {
- id: "custom-1",
- type: "experience",
- title: "Consulting",
- icon: "",
- columns: 1,
- hidden: false,
- keepTogether: false,
- startOnNewPage: false,
- items: [experienceItem("custom-item-a")],
- },
- ] as never;
- return data;
-}
-
-beforeAll(() => {
- i18n.loadAndActivate({ locale: "en", messages: {} });
-});
-
-describe("getAtsFindingMessage", () => {
- it("covers every rule in the catalog", () => {
- for (const code of ATS_RULE_CODES) {
- const message = getAtsFindingMessage(code);
- expect(message.title.length, code).toBeGreaterThan(0);
- expect(message.action.length, code).toBeGreaterThan(0);
- }
- });
-
- it("gives each rule a distinct title", () => {
- const titles = ATS_RULE_CODES.map((code) => getAtsFindingMessage(code).title);
- expect(new Set(titles).size).toBe(titles.length);
- });
-});
-
-describe("getAtsFindingTarget", () => {
- it.each([
- ["/basics/email", { side: "left", section: "basics" }],
- ["/basics/customFields/0/link", { side: "left", section: "basics" }],
- ["/picture", { side: "left", section: "picture" }],
- ["/summary/content", { side: "left", section: "summary" }],
- ["/sections/experience/items/0/period", { side: "left", section: "experience" }],
- ["/customSections/2/items/1/period", { side: "left", section: "custom" }],
- ["/metadata/typography/body/fontSize", { side: "right", section: "typography" }],
- ["/metadata/page/marginX", { side: "right", section: "page" }],
- ["/metadata/layout/pages", { side: "right", section: "layout" }],
- ])("resolves %s", (pointer, expected) => {
- expect(getAtsFindingTarget(pointer)).toEqual(expected);
- });
-
- it("returns null for a pointer with no sidebar home", () => {
- expect(getAtsFindingTarget("/metadata/styleRules/0")).toBeNull();
- });
-
- it("decodes escaped pointer tokens", () => {
- expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({
- side: "left",
- section: "experience",
- });
- });
-});
-
-describe("getAtsFindingTarget item resolution", () => {
- it("resolves the item a finding belongs to", () => {
- expect(getAtsFindingTarget("/sections/experience/items/0/period", makeResume())).toEqual({
- side: "left",
- section: "experience",
- itemId: "exp-a",
- });
- });
-
- it("sends two findings in the same section to different items", () => {
- const data = makeResume();
- const first = getAtsFindingTarget("/sections/experience/items/0/period", data);
- const second = getAtsFindingTarget("/sections/experience/items/1/period", data);
-
- expect(first?.itemId).toBe("exp-a");
- expect(second?.itemId).toBe("exp-b");
- expect(first?.section).toBe(second?.section);
- });
-
- it("resolves items inside custom sections", () => {
- expect(getAtsFindingTarget("/customSections/0/items/0/period", makeResume())).toEqual({
- side: "left",
- section: "custom",
- itemId: "custom-item-a",
- });
- });
-
- it("omits the item when no resume data is supplied", () => {
- expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({
- side: "left",
- section: "experience",
- });
- });
-
- it("omits the item for a section-level pointer", () => {
- expect(getAtsFindingTarget("/sections/experience", makeResume())).toEqual({
- side: "left",
- section: "experience",
- });
- });
-
- it("omits the item when the index is out of range", () => {
- expect(getAtsFindingTarget("/sections/experience/items/9/period", makeResume())).toEqual({
- side: "left",
- section: "experience",
- });
- });
-
- it("builds a stable element id", () => {
- expect(atsFindingItemElementId("exp-a")).toBe("resume-item-exp-a");
- });
-});
-
-describe("getAtsFindingLocation", () => {
- it("names the section for a section-level pointer", () => {
- expect(getAtsFindingLocation("/basics/email")).toBe("Basics");
- });
-
- it("adds a one-based item position for an item-level pointer", () => {
- expect(getAtsFindingLocation("/sections/experience/items/0/period")).toBe("Experience · Item 1");
- });
-
- it("returns null when the pointer has no sidebar home", () => {
- expect(getAtsFindingLocation("/metadata/styleRules/0")).toBeNull();
- });
-});
diff --git a/apps/web/src/libs/resume/ats.ts b/apps/web/src/libs/resume/ats.ts
deleted file mode 100644
index ced85fa0e..000000000
--- a/apps/web/src/libs/resume/ats.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import type { AtsRuleCode } from "@reactive-resume/resume/ats";
-import type { ResumeData } from "@reactive-resume/schema/resume/data";
-import type { LeftSidebarSection, SidebarSection } from "./section";
-import { t } from "@lingui/core/macro";
-import { match } from "ts-pattern";
-import { getSectionTitle, leftSidebarSections } from "./section";
-
-export type AtsFindingMessage = {
- title: string;
- action: string;
-};
-
-export type AtsFindingTarget = {
- side: "left" | "right";
- section: SidebarSection;
- itemId?: string;
-};
-
-export const atsFindingItemElementId = (itemId: string) => `resume-item-${itemId}`;
-
-export function getAtsFindingMessage(code: AtsRuleCode): AtsFindingMessage {
- return match(code)
- .with("MISSING_NAME", () => ({
- title: t`Your resume has no name.`,
- action: t`Add your full name under Basics.`,
- }))
- .with("MISSING_EMAIL", () => ({
- title: t`Your resume has no email address.`,
- action: t`Most systems file candidates by email. Add one under Basics.`,
- }))
- .with("MALFORMED_EMAIL", () => ({
- title: t`This email address will not be recognized.`,
- action: t`Use a plain address such as name@example.com, with no surrounding text.`,
- }))
- .with("MISSING_PHONE", () => ({
- title: t`Your resume has no phone number.`,
- action: t`Some application systems require one before you can submit.`,
- }))
- .with("MISSING_LOCATION", () => ({
- title: t`Your resume has no location.`,
- action: t`Add at least a city and country so roles can be matched to your region.`,
- }))
- .with("MALFORMED_URL", () => ({
- title: t`This link is missing its protocol.`,
- action: t`Write the full address, including https://.`,
- }))
- .with("PICTURE_PRESENT", () => ({
- title: t`Your resume includes a photo.`,
- action: t`Some parsers mishandle images, and photos are discouraged in some regions.`,
- }))
- .with("EMPTY_PERIOD", () => ({
- title: t`This entry has no dates.`,
- action: t`Add a period such as "Jan 2020 - Present" so it lands on your timeline.`,
- }))
- .with("UNPARSEABLE_PERIOD", () => ({
- title: t`These dates will not be read correctly.`,
- action: t`Use a recognized form such as "Jan 2020 - Mar 2022" or "2020 - 2022".`,
- }))
- .with("UNPARSEABLE_DATE", () => ({
- title: t`This date will not be read correctly.`,
- action: t`Use a recognized form such as "March 2022" or "2022".`,
- }))
- .with("REVERSED_PERIOD", () => ({
- title: t`This period ends before it starts.`,
- action: t`Swap the start and end dates.`,
- }))
- .with("FUTURE_DATED_PERIOD", () => ({
- title: t`This period starts in the future.`,
- action: t`Correct the year, or use "Present" for ongoing work.`,
- }))
- .with("SECTION_MISSING_FROM_LAYOUT", () => ({
- title: t`This section has content but never appears.`,
- action: t`Place it on a page from the Layout panel, or hide it if you meant to park it.`,
- }))
- .with("NO_VISIBLE_EXPERIENCE", () => ({
- title: t`Your resume shows no work experience.`,
- action: t`Add an entry, or use projects and volunteer work to show equivalent history.`,
- }))
- .with("MISSING_EXPERIENCE_DESCRIPTION", () => ({
- title: t`This role has no description.`,
- action: t`Describe what you did so the entry contributes keywords for matching.`,
- }))
- .with("NON_STANDARD_SECTION_TITLE", () => ({
- title: t`This heading is not one parsers look for.`,
- action: t`Prefer a conventional heading such as "Work Experience" or "Education".`,
- }))
- .with("MULTI_COLUMN_PROSE_SECTION", () => ({
- title: t`This section is split across columns.`,
- action: t`Columns commonly scramble the order text is read in. Set it to a single column.`,
- }))
- .with("PROSE_SECTION_IN_SIDEBAR", () => ({
- title: t`This section sits in the sidebar.`,
- action: t`Move it into the main column and keep the sidebar for short lists.`,
- }))
- .with("SMALL_BODY_FONT", () => ({
- title: t`Your body text is very small.`,
- action: t`Use a size of at least 9pt so re-rendered copies stay accurate.`,
- }))
- .with("TIGHT_LINE_HEIGHT", () => ({
- title: t`Your lines are packed very tightly.`,
- action: t`Use a line height of at least 1.15 so lines are not merged together.`,
- }))
- .with("TIGHT_PAGE_MARGINS", () => ({
- title: t`Your page margins are very narrow.`,
- action: t`Increase them so content stays inside the reliably read area.`,
- }))
- .exhaustive();
-}
-
-function decodePointerToken(token: string): string {
- return token.replace(/~1/g, "/").replace(/~0/g, "~");
-}
-
-function pointerTokens(pointer: string): string[] {
- return pointer.split("/").slice(1).map(decodePointerToken);
-}
-
-const isLeftSidebarSection = (value: string): value is LeftSidebarSection =>
- (leftSidebarSections as string[]).includes(value);
-
-function resolveItemId(tokens: readonly string[], data?: ResumeData): string | undefined {
- if (!data) return undefined;
-
- const itemsIndex = tokens.indexOf("items");
- if (itemsIndex === -1) return undefined;
-
- const position = Number(tokens[itemsIndex + 1]);
- if (!Number.isInteger(position)) return undefined;
-
- const [head, next] = tokens;
- let items: readonly unknown[] | undefined;
-
- if (head === "sections" && next) {
- items = (data.sections as Record)[next]?.items;
- } else if (head === "customSections") {
- items = data.customSections[Number(next)]?.items;
- }
-
- const item = items?.[position] as { id?: unknown } | undefined;
- return typeof item?.id === "string" ? item.id : undefined;
-}
-
-export function getAtsFindingTarget(pointer: string, data?: ResumeData): AtsFindingTarget | null {
- const tokens = pointerTokens(pointer);
- const [head, next] = tokens;
- const itemId = resolveItemId(tokens, data);
- const withItem = (target: AtsFindingTarget): AtsFindingTarget => (itemId ? { ...target, itemId } : target);
-
- if (head === "basics") return { side: "left", section: "basics" };
- if (head === "picture") return { side: "left", section: "picture" };
- if (head === "summary") return { side: "left", section: "summary" };
- if (head === "customSections") return withItem({ side: "left", section: "custom" });
-
- if (head === "sections" && next && isLeftSidebarSection(next)) {
- return withItem({ side: "left", section: next });
- }
-
- if (head === "metadata") {
- if (next === "typography") return { side: "right", section: "typography" };
- if (next === "page") return { side: "right", section: "page" };
- if (next === "layout") return { side: "right", section: "layout" };
- }
-
- return null;
-}
-
-export function getAtsFindingLocation(pointer: string): string | null {
- const target = getAtsFindingTarget(pointer);
- if (!target) return null;
-
- const tokens = pointerTokens(pointer);
- const itemsIndex = tokens.indexOf("items");
- const sectionTitle = getSectionTitle(target.section);
-
- if (itemsIndex === -1) return sectionTitle;
-
- const position = Number(tokens[itemsIndex + 1]);
- if (!Number.isInteger(position)) return sectionTitle;
-
- const itemNumber = position + 1;
- return t`${sectionTitle} · Item ${itemNumber}`;
-}
diff --git a/apps/web/src/libs/resume/section.tsx b/apps/web/src/libs/resume/section.tsx
index c1753505a..c2b09f9fb 100644
--- a/apps/web/src/libs/resume/section.tsx
+++ b/apps/web/src/libs/resume/section.tsx
@@ -24,7 +24,6 @@ import {
PaletteIcon,
PhoneIcon,
ReadCvLogoIcon,
- SealCheckIcon,
ShareFatIcon,
StarIcon,
TextTIcon,
@@ -50,7 +49,6 @@ export type RightSidebarSection =
| "notes"
| "sharing"
| "statistics"
- | "ats"
| "export"
| "information";
@@ -85,7 +83,6 @@ export const rightSidebarSections: RightSidebarSection[] = [
"styles",
"page",
"notes",
- "ats",
"export",
"information",
] as const;
@@ -124,7 +121,6 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string =
.with("notes", () => t`Notes`)
.with("sharing", () => t`Sharing`)
.with("statistics", () => t`Statistics`)
- .with("ats", () => t`ATS Check`)
.with("export", () => t`Export`)
.with("information", () => t`Information`)
@@ -168,7 +164,6 @@ export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: Ic
.with("notes", () => )
.with("sharing", () => )
.with("statistics", () => )
- .with("ats", () => )
.with("export", () => )
.with("information", () => )
diff --git a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx
index 6b09df435..5791def15 100644
--- a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx
@@ -10,8 +10,8 @@ import { TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
+import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
import { useEditorStore } from "@/features/resume/editor/store";
-import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { BuilderAiAssistant } from "./ai-assistant";
import { DocumentMenu } from "./document-menu";
diff --git a/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx b/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx
index f57890103..e926c2db7 100644
--- a/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx
@@ -1,22 +1,23 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
-import type { EditorMode } from "@/features/resume/editor/store";
+import type { EditorMode, MobileView } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Outlet } from "@tanstack/react-router";
-import { useEffect, useState, useSyncExternalStore } from "react";
+import { useEffect, useSyncExternalStore } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
+import { IssueStepper } from "@/features/resume/editor/check/page-layer";
import { useEditorStore } from "@/features/resume/editor/store";
+import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
import { DesignSheet } from "./design-panel";
import { EditorBar } from "./editor-bar";
import { ModePanel } from "./mode-panels";
import { ShareSheet } from "./share-sheet";
import { useEditorHotkeys } from "./use-editor-hotkeys";
-import { useEditorMode } from "./use-editor-mode";
const LANDSCAPE_QUERY = "(orientation: landscape)";
@@ -123,8 +124,6 @@ function TabletBody({ mode }: { mode: EditorMode }) {
);
}
-type MobileView = "page" | EditorMode;
-
type MobileBodyProps = {
mode: EditorMode;
onModeChange: (mode: EditorMode) => void;
@@ -138,7 +137,8 @@ const MOBILE_TABS: { view: MobileView; icon: IconName }[] = [
];
function MobileBody({ mode, onModeChange }: MobileBodyProps) {
- const [view, setView] = useState(mode);
+ const view = useEditorStore((state) => state.mobileView) ?? mode;
+ const setView = useEditorStore((state) => state.setMobileView);
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
const labels: Record = { write: t`Write`, page: t`Page`, design: t`Design`, check: t`Check` };
@@ -156,7 +156,8 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) {
- {view === "page" && (
+ {view === "page" && mode === "check" && }
+ {view === "page" && mode !== "check" && (
{
setView("write");
diff --git a/apps/web/src/routes/builder/$resumeId/-components/mode-panels.tsx b/apps/web/src/routes/builder/$resumeId/-components/mode-panels.tsx
index adc823add..dc7fd95af 100644
--- a/apps/web/src/routes/builder/$resumeId/-components/mode-panels.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-components/mode-panels.tsx
@@ -1,20 +1,11 @@
import type { EditorMode } from "@/features/resume/editor/store";
+import { CheckPanel } from "@/features/resume/editor/check/check-panel";
import { OfflineBanner } from "@/features/resume/editor/save-status";
import { useEditorStore } from "@/features/resume/editor/store";
import { selectionFromPanelElement } from "@/features/resume/editor/write/reveal";
import { WritePanel } from "@/features/resume/editor/write/write-panel";
-import { AtsCheckSectionBuilder } from "../-sidebar/right/sections/ats-check";
import { DesignPanel } from "./design-panel";
-// Until Check mode is rebuilt (M7), it hosts the existing ATS check.
-function CheckPanel() {
- return (
-
- Every check passed. Nothing here should stop a parser from reading your resume.
-
-
- ) : (
-
- {findings.map((finding) => (
-
- ))}
-
- )}
-
- );
-}
-
-/**
- * Tier two and three: render the current resume to a PDF and run the same file-level check the
- * standalone page runs, then offer the optional AI review on top of the result.
- *
- * The render happens in this tab and the report is derived from those bytes, so the deep check
- * measures the file a recruiter would actually receive rather than the draft that produced it.
- */
-function DeepCheckTier() {
- const data = useResumeData();
-
- const [jobDescription, setJobDescription] = useState("");
- const [result, setResult] = useState(null);
- const [isRunning, setIsRunning] = useState(false);
- const [error, setError] = useState(null);
-
- const onRun = async () => {
- if (!data || isRunning) return;
-
- setIsRunning(true);
- setError(null);
-
- try {
- const blob = await createResumePdfBlob(data);
- setResult(await runAtsCheck(blobToPdfFile(blob, "resume.pdf"), { jobDescription }));
- } catch {
- setError(t`The deep check could not finish. Please try again.`);
- } finally {
- setIsRunning(false);
- }
- };
-
- if (!data) return null;
-
- return (
-
-
-
-
- Deep check
-
-
-
- Renders your current resume to PDF and runs the full file check in your browser. Nothing is uploaded.
-
-