From 0e30b29e34521dac6ad658548228a6c36b18c90c Mon Sep 17 00:00:00 2001 From: mibali Date: Mon, 4 May 2026 13:34:00 +0100 Subject: [PATCH 1/4] fix: remove inline event handlers to satisfy Chrome extension CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced onerror="..." attributes on logo img elements in popup.html and the modal HTML string in content.js with addEventListener('error') calls in their respective JS files — Chrome MV3 CSP blocks inline event handlers regardless of origin. Co-Authored-By: Claude Sonnet 4.6 --- extension-ready/content.js | 6 +++++- extension-ready/popup.html | 4 ++-- extension-ready/popup.js | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/extension-ready/content.js b/extension-ready/content.js index 9756aac..1b31221 100644 --- a/extension-ready/content.js +++ b/extension-ready/content.js @@ -167,7 +167,7 @@ class DraftApplyExtension { modal.innerHTML = `
- + DraftApply No context @@ -218,6 +218,10 @@ class DraftApplyExtension { modal.style.display = 'none'; this.modal = modal; + // Hide logo if icon fails to load (CSP-safe alternative to onerror attribute) + const logoImg = modal.querySelector('.da-modal-logo'); + if (logoImg) logoImg.addEventListener('error', () => { logoImg.style.display = 'none'; }); + // Bind events first (they persist even if modal is detached from DOM) modal.querySelector('.da-modal-close').onclick = () => this.hideModal(); modal.querySelector('#da-btn-insert').onclick = () => this.insertAnswer(); diff --git a/extension-ready/popup.html b/extension-ready/popup.html index c24baa4..165c0ed 100644 --- a/extension-ready/popup.html +++ b/extension-ready/popup.html @@ -700,7 +700,7 @@
DraftApply
@@ -785,7 +785,7 @@
DraftApply
diff --git a/extension-ready/popup.js b/extension-ready/popup.js index ea4a56a..4c55169 100644 --- a/extension-ready/popup.js +++ b/extension-ready/popup.js @@ -6,6 +6,10 @@ */ document.addEventListener('DOMContentLoaded', async () => { + // Hide logo images that fail to load (CSP-safe — no inline onerror attribute) + document.querySelectorAll('.da-popup-logo-img').forEach(img => { + img.addEventListener('error', () => { img.style.display = 'none'; }); + }); const TAILOR_DRAFT_KEY = 'tailorCvDraft'; const elements = { From 84a3b46ba0c848ecc38913dcd7fa84c0d8860d8e Mon Sep 17 00:00:00 2001 From: mibali Date: Mon, 4 May 2026 14:27:27 +0100 Subject: [PATCH 2/4] fix: suppress browser print headers and footers on CV export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @page { margin: 0 } — Chrome only injects date/title/URL text into the margin area when no CSS @page rule is present. The cv-page element already has 20mm/18mm padding so content margins are preserved. Co-Authored-By: Claude Sonnet 4.6 --- extension-ready/cv-export.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/extension-ready/cv-export.html b/extension-ready/cv-export.html index 96b3f9e..610b717 100644 --- a/extension-ready/cv-export.html +++ b/extension-ready/cv-export.html @@ -193,6 +193,11 @@ } /* ── Print ── */ + + /* Suppress browser-injected date / title / URL headers and footers. + Chrome only adds those when no @page margin is set via CSS. */ + @page { margin: 0; } + @media print { body { background: white; } #toolbar { display: none !important; } From 77a70b5fb94b510bdc0636d2d9c09dde2e396e8a Mon Sep 17 00:00:00 2001 From: mibali Date: Mon, 4 May 2026 14:31:46 +0100 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20name=20PDF=20export=20after=20candid?= =?UTF-8?q?ate=20=E2=80=94=20"Full=20Name=20CV.pdf"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set document.title from the first non-empty line of the tailored CV text so the browser uses that as the default Save As filename. Co-Authored-By: Claude Sonnet 4.6 --- extension-ready/cv-export.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extension-ready/cv-export.js b/extension-ready/cv-export.js index b7051a0..7c915e3 100644 --- a/extension-ready/cv-export.js +++ b/extension-ready/cv-export.js @@ -23,6 +23,10 @@ await chrome.storage.local.remove('tailoredCvExport'); + // Set page title (and therefore PDF filename) to "Full Name CV" + const candidateName = tailoredCvExport.split('\n').map(l => l.trim()).find(l => l.length > 0); + if (candidateName) document.title = `${candidateName} CV`; + content.innerHTML = formatCvToHtml(tailoredCvExport); loading.hidden = true; content.hidden = false; From 601347423aea89d2cfdcdc1cdcbd0efacdd7dbde Mon Sep 17 00:00:00 2001 From: mibali Date: Mon, 4 May 2026 14:37:56 +0100 Subject: [PATCH 4/4] ensure confirmed CV skills are included --- backend/server.js | 9 +++-- render-proxy/server.js | 9 +++-- shared/cv-tailor.js | 75 +++++++++++++++++++++++++++++++++++++---- tests/cv-tailor.test.js | 67 ++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 13 deletions(-) diff --git a/backend/server.js b/backend/server.js index 9c4756c..46eb6d3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -358,9 +358,12 @@ app.post('/api/cv/tailor', async (req, res) => { max_tokens: 4000 }); - const tailoredCvText = tailor.removeTailoringMetaPhrases( - tailor.enforceTargetHeadline(result.answer, jdData.jobTitle), - jdData.company + const tailoredCvText = tailor.ensureConfirmedSkillsIncluded( + tailor.removeTailoringMetaPhrases( + tailor.enforceTargetHeadline(result.answer, jdData.jobTitle), + jdData.company + ), + confirmedSkills ); if (!tailoredCvText?.trim()) { return res.status(502).json({ error: 'No output from provider' }); diff --git a/render-proxy/server.js b/render-proxy/server.js index 0324e63..e354269 100644 --- a/render-proxy/server.js +++ b/render-proxy/server.js @@ -616,9 +616,12 @@ app.post('/api/cv/tailor', authRequired, generateLimiter, async (req, res) => { } const data = await response.json(); - const tailoredCvText = tailor.removeTailoringMetaPhrases( - tailor.enforceTargetHeadline(data?.choices?.[0]?.message?.content, jdData.jobTitle), - jdData.company + const tailoredCvText = tailor.ensureConfirmedSkillsIncluded( + tailor.removeTailoringMetaPhrases( + tailor.enforceTargetHeadline(data?.choices?.[0]?.message?.content, jdData.jobTitle), + jdData.company + ), + confirmedSkills ); if (!tailoredCvText?.trim()) { return res.status(502).json({ error: 'No output from provider' }); diff --git a/shared/cv-tailor.js b/shared/cv-tailor.js index 1876781..3e94c98 100644 --- a/shared/cv-tailor.js +++ b/shared/cv-tailor.js @@ -6,11 +6,15 @@ export class CVTailor { buildMatchMap(cvData, jdData, confirmedSkills = []) { const cvText = cvData.rawText || ''; const cvLower = cvText.toLowerCase(); - const confirmedSet = new Set( - (Array.isArray(confirmedSkills) ? confirmedSkills : []) - .map(skill => this._normaliseText(skill)) - .filter(Boolean) - ); + const confirmedEntries = (Array.isArray(confirmedSkills) ? confirmedSkills : []) + .map(skill => String(skill || '').trim()) + .filter(Boolean); + const confirmedByKey = new Map(); + for (const skill of confirmedEntries) { + const key = this._normaliseText(skill); + if (key && !confirmedByKey.has(key)) confirmedByKey.set(key, skill); + } + const confirmedSet = new Set(confirmedByKey.keys()); // Flatten all CV text sources for searching const cvSources = [ @@ -41,6 +45,13 @@ export class CVTailor { return true; }); + for (const skill of confirmedByKey.values()) { + const key = this._normaliseText(skill); + if (!deduped.some(({ req }) => this._normaliseText(req) === key)) { + deduped.push({ req: skill, type: 'user_confirmed' }); + } + } + return deduped.map(({ req, type }) => { const confirmedByUser = confirmedSet.has(this._normaliseText(req)); const evidence = this._findEvidence(req, cvSources); @@ -124,7 +135,7 @@ STRICT RULES: 3. Preserve every locked field exactly as written — same spelling, capitalisation, and format. 4. Do not add new employment entries, new education entries, or new certifications. 5. Do not remove any role or educational entry. -6. Exact product/tool names from the JD may be added to skills, summaries, or bullets only when supported by the original CV or match report. +6. Exact product/tool names from the JD or user-confirmed review may be added to skills, summaries, or bullets only when supported by the original CV or match report. 7. If the JD mentions an unsupported tool, you may emphasize adjacent supported experience instead, but do not name the unsupported tool as a candidate skill. 8. Do not write meta phrases such as "Tailored for", "customized for", "aligned to this job", or "for this application". 9. Do not mention the target company name in the CV body unless it already appears in the original CV as part of the candidate's history. @@ -138,6 +149,7 @@ WHAT YOU MAY DO: • Rephrase existing responsibility bullets using vocabulary from the job description, as long as the underlying meaning is unchanged. • Reorder bullets within a role to put the most relevant ones first. • Expand or compress bullet points within the bounds of what the original bullet states. +• Include every user-confirmed addition in the skills/core competencies section. • Add truthful role-positioning lines in the form "Focus: ..." under existing role titles when supported by that role's original responsibilities.`; const supported = matchMap.filter(m => m.allowedToMention).map(m => m.requirement); @@ -182,7 +194,7 @@ INSTRUCTION 3. Reorder and rename skills/competencies so supported JD-relevant items appear first, especially supported technologies, methods, domain terms, and operational practices from the JD. 4. For each relevant role: preserve the official job title exactly, then add one short "Focus:" line below it when the original responsibilities support the target role. Example: "Focus: MLOps, platform reliability, cloud infrastructure, automation, and production diagnostics". 5. For each role: rewrite relevant bullets with JD vocabulary (same meaning, aligned language), reorder bullets so the strongest target-role evidence comes first, and make Infra/MLOps/platform evidence obvious when supported. -6. You may include user-confirmed additions as skills/tools, but do not attach them to a specific employer, project, metric, certification, or achievement unless that context exists in the original CV. +6. Include every user-confirmed addition in the skills/core competencies section. You may also use them in the summary when natural, but do not attach them to a specific employer, project, metric, certification, or achievement unless that context exists in the original CV. 7. Preserve all locked fields exactly — same spelling, capitalisation, and punctuation. 8. The final CV must read like a polished CV for "${jdData.jobTitle || 'the target role'}", not like a generic CV and not like generated marketing copy. @@ -240,6 +252,42 @@ Output the complete tailored CV text with no preamble, no commentary, and no mar .join('\n'); } + ensureConfirmedSkillsIncluded(tailoredText, confirmedSkills = []) { + if (!tailoredText) return tailoredText; + + const skills = this._uniqueDisplaySkills(confirmedSkills); + if (skills.length === 0) return tailoredText; + + const existingText = this._normaliseText(tailoredText); + const missing = skills.filter(skill => !existingText.includes(this._normaliseText(skill))); + if (missing.length === 0) return tailoredText; + + const lines = String(tailoredText).split('\n'); + const headingIdx = lines.findIndex(line => + /^(core\s+competenc(?:y|ies)|technical\s+skills?|skills|technologies|tools|expertise)\s*[:\-]?$/i + .test(line.trim()) + ); + + if (headingIdx === -1) { + return `${tailoredText.trim()}\n\nTechnical Skills\n${missing.join(', ')}`; + } + + let insertIdx = headingIdx + 1; + while (insertIdx < lines.length && !lines[insertIdx].trim()) insertIdx++; + + if (insertIdx >= lines.length || this._isLikelySectionHeader(lines[insertIdx])) { + lines.splice(headingIdx + 1, 0, missing.join(', ')); + return lines.join('\n'); + } + + const current = lines[insertIdx].trim(); + const prefix = /^[-•]\s+/.test(current) ? current.match(/^[-•]\s+/)[0] : ''; + const body = prefix ? current.replace(/^[-•]\s+/, '') : current; + const separator = body && !/[,:;]\s*$/.test(body) ? ', ' : ' '; + lines[insertIdx] = `${prefix}${body}${separator}${missing.join(', ')}`.trimEnd(); + return lines.join('\n'); + } + /** * Rule-based validation that locked fields were not altered. * @returns {string[]} warnings @@ -339,6 +387,19 @@ Output the complete tailored CV text with no preamble, no commentary, and no mar .map(([label, value]) => `${label}: "${value.trim()}"`); } + _uniqueDisplaySkills(skills = []) { + const seen = new Set(); + const result = []; + for (const skill of Array.isArray(skills) ? skills : []) { + const clean = String(skill || '').trim().replace(/\s+/g, ' '); + const key = this._normaliseText(clean); + if (!key || seen.has(key)) continue; + seen.add(key); + result.push(clean); + } + return result; + } + _getCoreTokens(requirement) { const NOISE = this._noiseWords(); const needle = this._normaliseText(requirement); diff --git a/tests/cv-tailor.test.js b/tests/cv-tailor.test.js index 6a634f3..3f5f6b3 100644 --- a/tests/cv-tailor.test.js +++ b/tests/cv-tailor.test.js @@ -140,6 +140,24 @@ describe('buildMatchMap', () => { expect(graphql.confirmedByUser).toBe(true); }); + it('adds user-confirmed domain suggestions even when they are not in the JD', () => { + const map = tailor.buildMatchMap(CV, JD, ['Prometheus', 'Grafana']); + const prometheus = map.find(m => m.requirement === 'Prometheus'); + const grafana = map.find(m => m.requirement === 'Grafana'); + expect(prometheus).toMatchObject({ + type: 'user_confirmed', + status: 'user_confirmed', + allowedToMention: true, + confirmedByUser: true, + }); + expect(grafana).toMatchObject({ + type: 'user_confirmed', + status: 'user_confirmed', + allowedToMention: true, + confirmedByUser: true, + }); + }); + it('user_confirmed evidence mentions user confirmation', () => { const map = tailor.buildMatchMap(CV, JD, ['GraphQL']); const graphql = map.find(m => m.requirement === 'GraphQL'); @@ -187,6 +205,12 @@ describe('buildMatchSummary', () => { expect(summary.confirmedAdditions).toContain('GraphQL'); }); + it('surfaces confirmed domain suggestions in the summary', () => { + const map = tailor.buildMatchMap(CV, JD, ['Prometheus']); + const summary = tailor.buildMatchSummary(map); + expect(summary.confirmedAdditions).toContain('Prometheus'); + }); + it('surfaces unsupported requirements in the summary', () => { const map = tailor.buildMatchMap(CV, JD); const summary = tailor.buildMatchSummary(map); @@ -397,6 +421,40 @@ describe('detectChangedSections', () => { }); }); +// ── ensureConfirmedSkillsIncluded ──────────────────────────────────────────── + +describe('ensureConfirmedSkillsIncluded', () => { + it('adds omitted confirmed skills to an existing skills section', () => { + const tailored = `John Doe + +Senior Software Engineer + +SKILLS +React, TypeScript, Docker + +EXPERIENCE +TechCorp`; + + const result = tailor.ensureConfirmedSkillsIncluded(tailored, ['Prometheus', 'Grafana']); + expect(result).toContain('React, TypeScript, Docker, Prometheus, Grafana'); + }); + + it('does not duplicate confirmed skills already present', () => { + const tailored = `John Doe + +SKILLS +React, Grafana`; + + const result = tailor.ensureConfirmedSkillsIncluded(tailored, ['Grafana', 'Prometheus']); + expect(result).toContain('React, Grafana, Prometheus'); + expect(result.match(/Grafana/g)).toHaveLength(1); + }); + + it('creates a skills section when the tailored CV has none', () => { + const result = tailor.ensureConfirmedSkillsIncluded('John Doe\n\nEXPERIENCE\nTechCorp', ['Prometheus']); + expect(result).toContain('Technical Skills\nPrometheus'); + }); +}); // ── buildTailoringPrompt ────────────────────────────────────────────────────── @@ -450,6 +508,15 @@ describe('buildTailoringPrompt', () => { expect(userPrompt).toContain('strongest target-role evidence comes first'); }); + it('requires every user-confirmed addition to be included in skills', () => { + const map = tailor.buildMatchMap(CV, JD, ['Prometheus', 'Grafana']); + const { systemPrompt, userPrompt } = tailor.buildTailoringPrompt(CV, JD, map); + expect(systemPrompt).toContain('Include every user-confirmed addition in the skills/core competencies section'); + expect(userPrompt).toContain('Include every user-confirmed addition in the skills/core competencies section'); + expect(userPrompt).toContain('+ Prometheus'); + expect(userPrompt).toContain('+ Grafana'); + }); + it('includes the original CV text in the user prompt', () => { const map = tailor.buildMatchMap(CV, JD); const { userPrompt } = tailor.buildTailoringPrompt(CV, JD, map);