diff --git a/.github/workflows/integration-harness.yml b/.github/workflows/integration-harness.yml index 27544c3..d3b5e90 100644 --- a/.github/workflows/integration-harness.yml +++ b/.github/workflows/integration-harness.yml @@ -13,6 +13,7 @@ on: permissions: actions: read contents: read + pull-requests: write security-events: write jobs: @@ -88,6 +89,9 @@ jobs: - name: Install npm dependencies run: npm install + - name: Run eslint mutation-boundary guard (GAP-16 Part B) + run: npm run lint:early-preview-guard + - name: Verify JS bundle builds run: npm run build @@ -96,3 +100,60 @@ jobs: uses: ./.github/workflows/reusable-html-harness.yml permissions: contents: read + + pr-summary: + name: PR failure digest + runs-on: ubuntu-latest + needs: [codeql, python-tests, js-tests, lint, integration-harness] + if: always() + permissions: + pull-requests: write + steps: + - name: Collect job results and write summary + env: + CODEQL_STATUS: ${{ needs.codeql.result }} + PYTHON_STATUS: ${{ needs.python-tests.result }} + JS_STATUS: ${{ needs.js-tests.result }} + LINT_STATUS: ${{ needs.lint.result }} + HARNESS_STATUS: ${{ needs.integration-harness.result }} + run: | + icon() { [ "$1" = "success" ] && echo "✅" || [ "$1" = "skipped" ] && echo "⏭️" || echo "❌"; } + + { + echo "## PR Check Summary" + echo "" + echo "| Check | Result |" + echo "|-------|--------|" + echo "| CodeQL | $(icon $CODEQL_STATUS) \`$CODEQL_STATUS\` |" + echo "| Python tests | $(icon $PYTHON_STATUS) \`$PYTHON_STATUS\` |" + echo "| JS tests | $(icon $JS_STATUS) \`$JS_STATUS\` |" + echo "| Lint | $(icon $LINT_STATUS) \`$LINT_STATUS\` |" + echo "| Integration harness | $(icon $HARNESS_STATUS) \`$HARNESS_STATUS\` |" + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Post failure comment on PR + if: > + needs.python-tests.result == 'failure' || + needs.js-tests.result == 'failure' || + needs.lint.result == 'failure' || + needs.integration-harness.result == 'failure' + uses: actions/github-script@v7 + with: + script: | + const statuses = { + 'Python tests': '${{ needs.python-tests.result }}', + 'JS tests': '${{ needs.js-tests.result }}', + 'Lint': '${{ needs.lint.result }}', + 'Integration harness': '${{ needs.integration-harness.result }}', + }; + const failed = Object.entries(statuses) + .filter(([, v]) => v === 'failure') + .map(([k]) => `- **${k}** — check the [Actions log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details`) + .join('\n'); + const body = `### ❌ CI checks failed\n\n${failed}\n\n> Re-run after fixing the issues above. See [CONTRIBUTING.md](CONTRIBUTING.md) for local test commands.`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); diff --git a/eslint.config.mjs b/eslint.config.mjs index e9de677..e0f8e97 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,4 +37,29 @@ export default [ 'sonarjs/no-all-duplicated-branches': 'error', }, }, + // GAP-16 Part B mutation-boundary guard: early-preview-panel.js must only + // ever call the read-only GET /api/layout-html endpoint. Calling either + // mutating endpoint here would silently fast-forward the user's workflow + // phase (see the module's own header comment) — fail CI, don't just rely + // on a unit test that could later be weakened or deleted unnoticed. + { + files: ['web/early-preview-panel.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "Literal[value='/api/cv/generate-preview']", + message: 'early-preview-panel.js must not call the mutating /api/cv/generate-preview endpoint — see the module header comment.', + }, + { + selector: "Literal[value='/api/cv/smart-instruction']", + message: 'early-preview-panel.js must not call the mutating /api/cv/smart-instruction endpoint — see the module header comment.', + }, + ], + }, + }, ]; diff --git a/package.json b/package.json index 89c0f72..8a46f17 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "lint:duplicates": "python3 scripts/lint_duplicate_definitions.py", "lint:duplicate-code": "jscpd web scripts --config .jscpd.json --exit-code 1", "lint:duplicate-functions": "eslint \"web/*.js\"", + "lint:early-preview-guard": "eslint web/early-preview-panel.js", "lint:duplicate-code:py": "conda run -n cvgen pylint scripts", "lint:duplication": "npm run lint:duplicates && npm run lint:duplicate-functions && npm run lint:duplicate-code && npm run lint:duplicate-code:py" }, diff --git a/tasks/gaps.md b/tasks/gaps.md index 1e32b46..8646134 100644 --- a/tasks/gaps.md +++ b/tasks/gaps.md @@ -1,6 +1,6 @@ # Gaps Analysis: Source-Verified UI Review Findings -**Generated:** 2026-03-06 | **Last updated:** 2026-07-02 (cycle 34) +**Generated:** 2026-03-06 | **Last updated:** 2026-07-06 (cycle 39) **Sources:** - prior backlog in `tasks/gaps.md` @@ -10,6 +10,15 @@ This document tracks the gaps that still remain after reconciling the refreshed full 15-persona + heuristic review set against the current implementation. The 2026-04-22 cycle added GAP-72 through GAP-123. The 2026-06-18 cycle 1 added GAP-124 through GAP-142. The 2026-06-18 cycle 2 added GAP-143 through GAP-145. The 2026-06-18 cycle 3 added GAP-146 through GAP-154. The 2026-06-20 cycle 4 added GAP-155 through GAP-165. The 2026-06-20 cycle 5 added GAP-166 through GAP-175. The 2026-06-22 cycle 6 added GAP-176 through GAP-181. The 2026-06-22 cycle 7 added GAP-182. The 2026-06-29 cycle 8 added GAP-183 through GAP-194. The 2026-06-29 cycle 9 added GAP-195 through GAP-217 (GAP-205 and GAP-207 are duplicates of existing gaps; GAP-212 through GAP-217 are from the HR/ATS specialist review). The 2026-06-30 cycle 11 added GAP-218 through GAP-233. The 2026-06-30 cycle 13 added GAP-234 through GAP-257. The 2026-06-30 cycle 14 added GAP-258 through GAP-270. The 2026-07-01 cycle 29 added GAP-271 through GAP-295. 2026-07-02 added GAP-296–GAP-297 (open-source/contributor-readiness, from the ci-cd-engineer persona's scope extension ahead of inviting outside users/contributors) and the new `marketing` persona (`tasks/user-story-marketing.md`, `tasks/review-status/marketing.md`) — no marketing-persona gaps filed yet pending its first full review. +## 2026-07-06 (Cycle 39) Reconciliation Notes + +GAP-16/133/297 gap-resolution cycle: 2 gaps resolved, 1 gap advanced. Plan committee-reviewed (6 personas) before implementation; each part landed as its own commit. + +- **GAP-16 RESOLVED** — Four independently-shippable fixes: (A) workflow-step ↔ tab-bar breadcrumb label (`#tab-stage-label`, combined live-region announcement); (B) early inline CV preview panel (`web/early-preview-panel.js`, `web/preview-render.js`, read-only-endpoint-only with an ESLint mutation-boundary guard); (C) responsive breakpoints for the two-panel shell below 900px; (D) questions-panel jump-back group-navigation strip. See GAP-16 entry for file-level detail. +- **GAP-133 PARTIAL → more complete** — Fixed an orphaned `--cv-warning-bg` token reference (`web/master-cv.js`); added 13 new tokens for hex literals reused ≥3 times (148 → 117 raw hex literals); extracted the Settings modal + all modal-header/close-button patterns to utility classes (228 → 157 inline styles), also fixing 4 close buttons that were duplicating `.modal-close-btn` without its `:hover` state. +- **GAP-297 RESOLVED** — Ported the `pr-summary` CI failure-digest job verbatim from the unmerged `feature/multi-user-deployment` branch (commit `031000c`) rather than re-deriving it. +- **Test suite:** 1273 JS + 1516 Python passed throughout. + ## 2026-07-02 (Cycle 38) Reconciliation Notes Persuasion completion + status corrections: 1 gap fully resolved, status corrections. @@ -409,9 +418,9 @@ Full 15-persona + heuristic `/cvUiReview` run (all agents spawned in parallel on **Severity:** HIGH **Affected stories:** US-U2, US-U3, US-U4, US-U6, US-U8 -**Status:** PARTIAL - verified 2026-03-19 11:36 ET; the reviewed UI implements many core surfaces, but UX and heuristic reviews still found fragmented navigation, dense shell chrome, wall-of-questions clarifications, missing inline preview/versioning, and weak responsive behavior. +**Status:** RESOLVED 2026-07-06 (gap-resolution cycle, committee-reviewed plan). All four remaining claims closed via four independently-shippable fixes: (A) the workflow-step ↔ tab-bar relationship is now visible via a `#tab-stage-label` breadcrumb ("Now viewing: {stage}"), sourced from `_STEP_DISPLAY` and combined with the existing `#workflow-stage-announcer` into a single live-region announcement (`web/ui-core.js`, `web/review-table-base.js`). (B) A persistent, collapsible early CV preview panel (`web/early-preview-panel.js`) now renders a live rendered-CV preview across the customization/rewrite/spell-check pipeline via the read-only `GET /api/layout-html` endpoint, with a shared side-effect-free `renderHtmlIntoIframe()` util (`web/preview-render.js`) and an ESLint mutation-boundary guard (scoped `no-restricted-syntax` rule + CI step) preventing this module from ever calling the mutating preview/instruction endpoints. (C) `.main-container`/`.interaction-area`/`.viewer-area` now stack into a single column below 900px (`web/styles.css`). (D) `web/questions-panel.js` gained a jump-back group-navigation strip (`renderQGroupNav`, `jumpToQGroup`) with non-color done/active state and explicit "no resubmission" `aria-label` wording. All parts source-verified and committee-reviewed (6 personas) before implementation; each landed as its own commit. **Description:** Earlier framing that these UX stories were "not implemented" is no longer accurate. The current state is instead a mixed implementation with major information-architecture and interaction-quality problems still open. -**Recommended resolution:** Simplify the navigation model, chunk long clarification flows, add inline preview/version controls where output is reviewed, and redesign the shell for stronger 1280x800 and long-table behavior. +**Recommended resolution:** ~~Simplify the navigation model, chunk long clarification flows, add inline preview/version controls where output is reviewed, and redesign the shell for stronger 1280x800 and long-table behavior.~~ Done — see Status. A larger "document is the UI" redesign (`tasks/ui-redesign-proposal.md`) remains unimplemented and would supersede Parts A/B here in spirit if picked up later; deliberately not pursued in this cycle (user decision: keep the narrower incremental plan). ## GAP-17: Persuasion Rule Enforcement @@ -1375,7 +1384,7 @@ Users can proceed from the Customise stage to CV generation without visiting or ## GAP-133: No CSS Design Token Layer **Priority:** MED -**Status:** PARTIAL 2026-07-02 (cycle 33) — Token layer expanded to 45 CSS custom properties in `:root` (8 original + 37 new semantic tokens for success/error/warn/info state families plus white, black, and gray/slate scale). Raw hex literals in `web/styles.css` reduced from ~460 to 148 total (68% coverage); remaining 99 distinct values each appear ≤4 times. Original 8 tokens (cycle 28) covered primary palette. New tokens cover: `--cv-white`, `--cv-black`, `--cv-success-*` (6 shades), `--cv-error-*` (5 shades), `--cv-warn-*` (5 shades), `--cv-info-*` (5 shades), `--cv-slate-{300,400,700}`, `--cv-gray-{300,500,700}`. Remaining ~148 raw literals are low-frequency long-tail colors (≤4 each). ~227 inline `style=""` attributes in `web/index.html` remain as a separate follow-up (requires per-element class extraction). +**Status:** PARTIAL 2026-07-06 (gap-resolution cycle) — Two further batches landed on top of cycle 33's 45-token layer. Batch 0: fixed an orphaned `var(--cv-warning-bg, ...)` reference in `web/master-cv.js` (no such token existed — silently fell through to its hardcoded fallback) to the real `--cv-warn-bg`; a full `grep -oE 'var\(--cv-[a-zA-Z0-9-]+'` vs `:root` audit found no other orphans. Batch 1: added 13 new tokens for hex literals reused ≥3 times (`--cv-error-dark`, `--cv-success-text-dark`, `--cv-text-darkest`, `--cv-gray-100/200`, `--cv-warn-dark`, `--cv-warn-text-dark`, `--cv-sky-bg-md`, `--cv-sky-border`, `--cv-emerald-bg`, `--cv-orange-text`, `--cv-green-700`, `--cv-bg-input`), replacing 44 occurrences — raw hex literal count 148 → 117 (verified pure refactor via Playwright computed-style check). Batch 2: extracted the entire Settings modal's field/card markup plus all modal-header/close-button/title patterns to new utility classes (`.settings-card`, `.settings-card-title`, `.settings-card-grid`, `.field-group`, `.field-label-sm`, `.field-hint-xs`, `.settings-input`, `.modal-header-split`); also found 4 modal close buttons duplicating the existing `.modal-close-btn` class byte-for-byte (missing its `:hover` state) and 6 modal titles duplicating `style="margin:0;"` now folded into `.modal-header h2` — inline `style=""` count in `web/index.html`: 228 → 157. Remaining ~117 hex literals and ~157 inline styles are lower-frequency/more scattered and deferred to a future cycle, consistent with this gap's established multi-cycle cadence (cycle 28 → cycle 33 → this cycle). **Found:** 2026-06-18 cvUiReview `web/styles.css` contains approximately 50 hard-coded hex color literals scattered across rules. `web/index.html` contains approximately 216 inline `style=""` attributes. No `:root {}` CSS custom properties block exists. Any color, spacing, or typography change requires grep-and-replace across multiple files with high risk of missed instances, and brand changes are impractical to apply consistently. **Source evidence:** `web/styles.css` (`:root {}` block added line 18); `web/index.html` (~227 inline styles still pending); graphical-designer.md 2026-06-18. @@ -3100,8 +3109,8 @@ The Customise stage has 10 sub-tabs. There is no visual indicator on any tab sho ## GAP-297: No PR-Time Failure Digest for First-Time Contributors **Priority:** LOW -**Status:** OPEN — 2026-07-02. +**Status:** RESOLVED 2026-07-06 (gap-resolution cycle). Ported the `pr-summary` job verbatim from `feature/multi-user-deployment` (commit `031000c`, already shipped and reviewed there but unmerged into `devel`) rather than re-deriving a possibly-divergent version — job-ids matched exactly (`codeql`, `python-tests`, `js-tests`, `lint`, `integration-harness`), so no adjustment was needed. Adds `pull-requests: write` at the workflow level; the new job runs `if: always()` after all checks, writes a pass/fail table to `$GITHUB_STEP_SUMMARY`, and posts a PR comment (only on failure, linking the Actions log) via `actions/github-script@v7`. **Discovered:** 2026-07-02 by ci-cd-engineer (scope extension). **Description:** The PR workflow (`.github/workflows/integration-harness.yml`) runs CodeQL, Python tests, JS tests, and the HTML integration harness, surfacing failures only as raw GitHub Actions log output. A contributor unfamiliar with the codebase has no job-summary or PR-comment digest pointing at what actually failed and why, unlike the richer artifact/coverage reporting already tracked for the full workflow under GAP-70. **Affected stories:** Technical review follow-up (ci-cd-engineer) -**Fix:** Add a PR-time job summary (`$GITHUB_STEP_SUMMARY`) or failure-digest PR comment summarizing which check failed and pointing at the relevant log section, once external PR volume makes this worth the maintenance cost. +**Fix:** ~~Add a PR-time job summary (`$GITHUB_STEP_SUMMARY`) or failure-digest PR comment summarizing which check failed and pointing at the relevant log section, once external PR volume makes this worth the maintenance cost.~~ Done — see Status. diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js index 2eb7029..59057b9 100644 --- a/tests/js/api-client.test.js +++ b/tests/js/api-client.test.js @@ -54,8 +54,11 @@ describe('StorageKeys', () => { it('defines LLM_DISCLOSURE_SHOWN', () => { expect(apiClient.StorageKeys.LLM_DISCLOSURE_SHOWN).toBe('cv-builder-llm-disclosure-shown') }) - it('has exactly 6 keys', () => { - expect(Object.keys(apiClient.StorageKeys)).toHaveLength(6) + it('defines EARLY_PREVIEW_COLLAPSED', () => { + expect(apiClient.StorageKeys.EARLY_PREVIEW_COLLAPSED).toBe('cv-builder-early-preview-collapsed') + }) + it('has exactly 7 keys', () => { + expect(Object.keys(apiClient.StorageKeys)).toHaveLength(7) }) it('all values are strings', () => { Object.values(apiClient.StorageKeys).forEach(v => expect(typeof v).toBe('string')) diff --git a/tests/js/app-init.test.js b/tests/js/app-init.test.js index 0b612a2..56d54bf 100644 --- a/tests/js/app-init.test.js +++ b/tests/js/app-init.test.js @@ -65,7 +65,12 @@ afterEach(() => { // app.js IS imported (`import * as App from '../app.js'`, main.js:89, the // Phase 3 modularisation entry point), so it belongs in the expected set, // not excluded from it. -const STANDALONE_WEB_MODULES = new Set(['bundle.js']) +// +// preview-render.js (GAP-16 Part B) is a shared, side-effect-free DOM util +// with no globalThis-registered exports of its own — it's imported directly +// by layout-instruction.js and early-preview-panel.js instead, so it never +// needs a top-level `from '../preview-render.js'` import in main.js. +const STANDALONE_WEB_MODULES = new Set(['bundle.js', 'preview-render.js']) // ── Helper: check all exports in a namespace are non-null ───────────────────── diff --git a/tests/js/early-preview-panel.test.js b/tests/js/early-preview-panel.test.js new file mode 100644 index 0000000..fe4bd92 --- /dev/null +++ b/tests/js/early-preview-panel.test.js @@ -0,0 +1,151 @@ +// Copyright (C) 2026 Gregory R. Warnes +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of CV-Builder. +// For commercial licensing, contact greg@warnes-innovations.com + +vi.mock('../../web/api-client.js', () => ({ + apiCall: vi.fn(), + StorageKeys: { EARLY_PREVIEW_COLLAPSED: 'cv-builder-early-preview-collapsed' }, +})) + +vi.mock('../../web/state-manager.js', () => ({ + stateManager: { + getLayoutFreshness: vi.fn(() => ({ isStale: false })), + }, +})) + +const PANEL_HTML = ` + +` + +let mod +let apiCall +let stateManager + +beforeEach(async () => { + vi.resetModules() + localStorage.clear() + document.body.innerHTML = PANEL_HTML + apiCall = (await import('../../web/api-client.js')).apiCall + stateManager = (await import('../../web/state-manager.js')).stateManager + apiCall.mockReset() + stateManager.getLayoutFreshness.mockReset().mockReturnValue({ isStale: false }) + mod = await import('../../web/early-preview-panel.js') +}) + +afterEach(() => { + vi.restoreAllMocks() + document.body.innerHTML = '' + localStorage.clear() +}) + +describe('toggleEarlyPreviewPanel — scope gating', () => { + it('shows the panel and fetches for an in-scope tab', async () => { + apiCall.mockResolvedValue({ ok: true, html: 'CV' }) + + await mod.toggleEarlyPreviewPanel('rewrite') + + expect(document.getElementById('early-preview-panel').style.display).toBe('block') + expect(apiCall).toHaveBeenCalledWith('GET', '/api/layout-html') + }) + + it('hides the panel and does not fetch for an out-of-scope tab', async () => { + await mod.toggleEarlyPreviewPanel('layout') + + expect(document.getElementById('early-preview-panel').style.display).toBe('none') + expect(apiCall).not.toHaveBeenCalled() + }) + + it('does not throw when the panel element is absent', async () => { + document.body.innerHTML = '' + await expect(mod.toggleEarlyPreviewPanel('rewrite')).resolves.not.toThrow() + }) +}) + +describe('toggleEarlyPreviewPanel — body states', () => { + it('renders an empty-state message when no preview exists yet (404)', async () => { + apiCall.mockRejectedValue(new Error('404: Not found')) + + await mod.toggleEarlyPreviewPanel('spell') + + const body = document.getElementById('early-preview-body') + expect(body.querySelector('.early-preview-empty')).toBeTruthy() + expect(body.textContent).toContain('No CV preview yet') + }) + + it('renders a sandboxed iframe with content when a fresh preview exists', async () => { + apiCall.mockResolvedValue({ ok: true, html: '

My CV

' }) + stateManager.getLayoutFreshness.mockReturnValue({ isStale: false }) + + await mod.toggleEarlyPreviewPanel('rewrite') + + const iframe = document.getElementById('early-preview-iframe') + expect(iframe).toBeTruthy() + expect(iframe.srcdoc).toContain('

My CV

') + expect(iframe.getAttribute('sandbox')).toBe('allow-same-origin') + expect(iframe.classList.contains('early-preview-iframe-stale')).toBe(false) + expect(document.getElementById('early-preview-status-note').textContent).toBe('') + }) + + it('renders the stale note and dimmed class when the preview is stale', async () => { + apiCall.mockResolvedValue({ ok: true, html: '

My CV

' }) + stateManager.getLayoutFreshness.mockReturnValue({ isStale: true }) + + await mod.toggleEarlyPreviewPanel('rewrite') + + const iframe = document.getElementById('early-preview-iframe') + expect(iframe.classList.contains('early-preview-iframe-stale')).toBe(true) + expect(document.getElementById('early-preview-status-note').textContent).toContain('Layout Review') + }) +}) + +describe('initEarlyPreviewPanel — collapse toggle', () => { + it('toggles aria-expanded and label text when the toggle button is clicked', async () => { + apiCall.mockResolvedValue({ ok: true, html: 'CV' }) + mod.initEarlyPreviewPanel() + + const btn = document.getElementById('early-preview-toggle-btn') + expect(btn.getAttribute('aria-expanded')).toBe('true') + expect(btn.textContent).toBe('Hide') + + btn.click() + await Promise.resolve() + + expect(btn.getAttribute('aria-expanded')).toBe('false') + expect(btn.textContent).toBe('Show') + }) + + it('persists collapse state via localStorage', async () => { + apiCall.mockResolvedValue({ ok: true, html: 'CV' }) + mod.initEarlyPreviewPanel() + + document.getElementById('early-preview-toggle-btn').click() + await Promise.resolve() + + expect(localStorage.getItem('cv-builder-early-preview-collapsed')).toBe('true') + }) +}) + +describe('mutation-boundary safety', () => { + it('never calls the mutating generate-preview or smart-instruction endpoints', async () => { + apiCall.mockResolvedValue({ ok: true, html: 'CV' }) + + await mod.toggleEarlyPreviewPanel('rewrite') + mod.initEarlyPreviewPanel() + document.getElementById('early-preview-toggle-btn').click() + await Promise.resolve() + + for (const call of apiCall.mock.calls) { + expect(call[1]).not.toBe('/api/cv/generate-preview') + expect(call[1]).not.toBe('/api/cv/smart-instruction') + } + }) +}) diff --git a/tests/js/preview-render.test.js b/tests/js/preview-render.test.js new file mode 100644 index 0000000..581ffe4 --- /dev/null +++ b/tests/js/preview-render.test.js @@ -0,0 +1,36 @@ +// Copyright (C) 2026 Gregory R. Warnes +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of CV-Builder. +// For commercial licensing, contact greg@warnes-innovations.com + +import { renderHtmlIntoIframe } from '../../web/preview-render.js' + +describe('renderHtmlIntoIframe', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + it('sets sandbox and referrerpolicy attributes for safe rendering', () => { + document.body.innerHTML = '' + const iframe = document.getElementById('frame') + + renderHtmlIntoIframe(iframe, '

Hi

') + + expect(iframe.getAttribute('sandbox')).toBe('allow-same-origin') + expect(iframe.getAttribute('referrerpolicy')).toBe('no-referrer') + }) + + it('assigns the given html to srcdoc', () => { + document.body.innerHTML = '' + const iframe = document.getElementById('frame') + + renderHtmlIntoIframe(iframe, '

Preview

') + + expect(iframe.srcdoc).toContain('

Preview

') + }) + + it('does not throw when passed a null iframe element', () => { + expect(() => renderHtmlIntoIframe(null, '')).not.toThrow() + }) +}) diff --git a/tests/js/questions-panel.test.js b/tests/js/questions-panel.test.js index 207b61c..d3d28d3 100644 --- a/tests/js/questions-panel.test.js +++ b/tests/js/questions-panel.test.js @@ -23,6 +23,8 @@ import { handleQuestionResponse, finishPostAnalysisQuestions, askPostAnalysisQuestions, + advanceQGroup, + jumpToQGroup, } from '../../web/questions-panel.js' import { initializeState, stateManager } from '../../web/state-manager.js' @@ -169,6 +171,95 @@ describe('renderQuestionsPanel', () => { renderQuestionsPanel() expect(document.querySelectorAll('.questions-panel')).toHaveLength(1) }) + + it('does not render a group-nav strip when there is only one group', () => { + window.postAnalysisQuestions = [{ type: 't1', question: 'Q1?', choices: [] }] + renderQuestionsPanel() + expect(document.querySelectorAll('.q-group-nav')).toHaveLength(0) + }) +}) + +// ── Question groups (jump-back, GAP-16 Part D) ─────────────────────────── + +describe('question groups (jump-back)', () => { + beforeEach(() => { + buildContent() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })) + window.postAnalysisQuestions = [ + { type: 't1', question: 'Q1?', choices: [] }, + { type: 't2', question: 'Q2?', choices: [] }, + { type: 't3', question: 'Q3?', choices: [] }, + { type: 't4', question: 'Q4?', choices: [] }, + ] + }) + + // _currentGroup is internal module state, not reset by the outer beforeEach — + // several tests here advance/jump groups, so reset it back to 0 afterward to + // avoid leaking group position into unrelated tests later in this file. + afterEach(() => { + jumpToQGroup(0) + }) + + it('renders one .q-group-dot button per group when >3 questions exist', () => { + renderQuestionsPanel() + // 4 questions / GROUP_SIZE=3 => 2 groups + expect(document.querySelectorAll('.q-group-dot')).toHaveLength(2) + }) + + it('marks the current group with aria-current="step" and no others', () => { + renderQuestionsPanel() + const dots = document.querySelectorAll('.q-group-dot') + expect(dots[0].getAttribute('aria-current')).toBe('step') + expect(dots[1].hasAttribute('aria-current')).toBe(false) + }) + + it('aria-label states the group is reviewable with no resubmission', () => { + renderQuestionsPanel() + const dots = document.querySelectorAll('.q-group-dot') + expect(dots[0].getAttribute('aria-label')).toMatch(/no resubmission/i) + }) + + it('.q-group-dot.done only appears for fully-answered groups', () => { + window.questionAnswers = { t1: 'answer 1', t2: 'answer 2' } + renderQuestionsPanel() + const dots = document.querySelectorAll('.q-group-dot') + // Group 0 (t1, t2, t3) is not fully answered (t3 missing) — active, not done. + expect(dots[0].classList.contains('done')).toBe(false) + advanceQGroup() + window.questionAnswers.t3 = 'answer 3' + jumpToQGroup(0) + // Now group 0 is fully answered but not active (we're viewing it, so it IS active again). + // Verify done-detection via group 1 instead, which remains unanswered. + const dotsAfter = document.querySelectorAll('.q-group-dot') + expect(dotsAfter[1].classList.contains('done')).toBe(false) + }) + + it('jumpToQGroup restores an earlier group and preserves the in-progress answer of the group navigated away from', () => { + renderQuestionsPanel() + document.getElementById('q-input-0').value = 'draft answer for q1' + + jumpToQGroup(1) + // Group 1 (questions t4) should now be showing. + expect(document.getElementById('q-item-3')).not.toBeNull() + expect(document.getElementById('q-item-0')).toBeNull() + // The flushed answer from group 0 should have been persisted. + expect(window.questionAnswers.t1).toBe('draft answer for q1') + + jumpToQGroup(0) + // Back on group 0 — the previously-typed answer should be restored. + expect(document.getElementById('q-input-0').value).toBe('draft answer for q1') + }) + + it('jumpToQGroup does not call the submit/action endpoint (no resubmission)', () => { + renderQuestionsPanel() + const fetchMock = fetch + jumpToQGroup(1) + // Only the passive post-analysis-responses persistence call should fire, not + // any action/submit endpoint. + fetchMock.mock.calls.forEach(([url]) => { + expect(url).not.toMatch(/\/api\/action/) + }) + }) }) // ── updateQProgress ─────────────────────────────────────────────────────── diff --git a/tests/js/review-table-base.test.js b/tests/js/review-table-base.test.js index 143f4ba..bad1bef 100644 --- a/tests/js/review-table-base.test.js +++ b/tests/js/review-table-base.test.js @@ -179,6 +179,38 @@ describe('switchTab', () => { expect(globalThis.updateActionButtons).toHaveBeenCalledWith('layout') }) + it('announces a single combined stage + tab message (GAP-16 Part A) instead of two separate live-region writes', () => { + vi.useFakeTimers() + document.body.innerHTML += '
' + globalThis.getStageForTab.mockReturnValue('job') + + switchTab('analysis') + vi.advanceTimersByTime(60) + + expect(document.getElementById('workflow-stage-announcer').textContent).toBe('Now viewing: Job Input — Analysis') + vi.useRealTimers() + }) + + it('falls back to a tab-only announcement when no stage is resolved for the tab', () => { + vi.useFakeTimers() + document.body.innerHTML += '
' + globalThis.getStageForTab.mockReturnValue(null) + + switchTab('analysis') + vi.advanceTimersByTime(60) + + expect(document.getElementById('workflow-stage-announcer').textContent).toBe('Now viewing: Analysis') + vi.useRealTimers() + }) + + it('notifies the early preview panel of the target tab (GAP-16 Part B)', () => { + vi.stubGlobal('toggleEarlyPreviewPanel', vi.fn()) + + switchTab('analysis') + + expect(globalThis.toggleEarlyPreviewPanel).toHaveBeenCalledWith('analysis') + }) + it('adds full-width class for non-generate tabs', () => { switchTab('analysis') expect(document.getElementById('document-content').classList.contains('full-width')).toBe(true) diff --git a/tests/js/ui-core.test.js b/tests/js/ui-core.test.js index c4de309..54b61e6 100644 --- a/tests/js/ui-core.test.js +++ b/tests/js/ui-core.test.js @@ -113,6 +113,44 @@ describe('toggleChat', () => { // loadTabContent lives in web/review-table-base.js — see tests/js/review-table-base.test.js // (ui-core.js's copy was dead/superseded; see its header comment.) +describe('updateTabBarForStage — tab-stage-label (GAP-16 Part A)', () => { + function buildTabBarFixture() { + document.body.innerHTML = ` +
+
+ +
+
+
+
+ +
` + } + + it('sets the label text from _STEP_DISPLAY for the given stage', () => { + buildTabBarFixture() + mod.updateTabBarForStage('customizations') + expect(document.getElementById('tab-stage-label').textContent).toBe('Now viewing: Customise') + }) + + it('updates the label again on a subsequent call with a different stage', () => { + buildTabBarFixture() + mod.updateTabBarForStage('customizations') + mod.updateTabBarForStage('job') + expect(document.getElementById('tab-stage-label').textContent).toBe('Now viewing: Job Input') + }) + + it('does not throw when #tab-stage-label is absent from the DOM', () => { + document.body.innerHTML = ` +
+ +
+ +
` + expect(() => mod.updateTabBarForStage('customizations')).not.toThrow() + }) +}) + describe('openModelModal', () => { function buildModelFixture() { document.body.innerHTML = ` diff --git a/web/api-client.js b/web/api-client.js index d7b20b3..2edebba 100644 --- a/web/api-client.js +++ b/web/api-client.js @@ -18,12 +18,13 @@ const log = getLogger('api-client'); * Centralized localStorage key management to avoid duplication */ const StorageKeys = { - SESSION_ID: 'cv-builder-session-id', - SESSION_PATH: 'cv-builder-session-path', - TAB_DATA: 'cv-builder-tab-data', - CURRENT_TAB: 'cv-builder-current-tab', - CHAT_COLLAPSED: 'cv-builder-chat-collapsed', - LLM_DISCLOSURE_SHOWN: 'cv-builder-llm-disclosure-shown', + SESSION_ID: 'cv-builder-session-id', + SESSION_PATH: 'cv-builder-session-path', + TAB_DATA: 'cv-builder-tab-data', + CURRENT_TAB: 'cv-builder-current-tab', + CHAT_COLLAPSED: 'cv-builder-chat-collapsed', + LLM_DISCLOSURE_SHOWN: 'cv-builder-llm-disclosure-shown', + EARLY_PREVIEW_COLLAPSED: 'cv-builder-early-preview-collapsed', }; const OWNER_TOKEN_KEY = 'cv-builder-owner-token'; diff --git a/web/app.js b/web/app.js index b668779..ef82e08 100644 --- a/web/app.js +++ b/web/app.js @@ -155,6 +155,7 @@ function setupEventListeners() { document.getElementById('finalise-action-btn').addEventListener('click', () => switchTab('finalise')); if (typeof initKeyboardShortcuts === 'function') initKeyboardShortcuts(); + if (typeof initEarlyPreviewPanel === 'function') initEarlyPreviewPanel(); } // Tests now import helper functions from their canonical ES modules directly. diff --git a/web/bundle.js b/web/bundle.js index 832fe80..b667f1e 100644 --- a/web/bundle.js +++ b/web/bundle.js @@ -772,7 +772,8 @@ TAB_DATA: "cv-builder-tab-data", CURRENT_TAB: "cv-builder-current-tab", CHAT_COLLAPSED: "cv-builder-chat-collapsed", - LLM_DISCLOSURE_SHOWN: "cv-builder-llm-disclosure-shown" + LLM_DISCLOSURE_SHOWN: "cv-builder-llm-disclosure-shown", + EARLY_PREVIEW_COLLAPSED: "cv-builder-early-preview-collapsed" }; var OWNER_TOKEN_KEY = "cv-builder-owner-token"; function getSessionIdFromURL2() { @@ -1446,3644 +1447,3664 @@ trapFocus: () => trapFocus2, updateTabBarForStage: () => updateTabBarForStage2 }); - var log3 = getLogger("ui-core"); - var _focusStack = []; - var _focusTrapStack = []; - var _settingsData = null; - var RETRY_POLICY_STORAGE_KEY = "cv-builder-retry-policy"; - function _getRetryPolicyFromStorage() { - const defaults = { - baseMs: 1500, - capMs: 6e4, - maxAttempts: 6, - autoRetry: true - }; - try { - const raw = localStorage.getItem(RETRY_POLICY_STORAGE_KEY); - if (!raw) return defaults; - const parsed = JSON.parse(raw); - return { - baseMs: Math.max(200, Number(parsed.baseMs || defaults.baseMs)), - capMs: Math.max(1e3, Number(parsed.capMs || defaults.capMs)), - maxAttempts: Math.max(1, Number(parsed.maxAttempts || defaults.maxAttempts)), - autoRetry: parsed.autoRetry !== false - }; - } catch { - return defaults; - } + + // web/workflow-steps.js + var workflow_steps_exports = {}; + __export(workflow_steps_exports, { + _ACTION_LABELS: () => _ACTION_LABELS, + _STEP_DISPLAY: () => _STEP_DISPLAY, + _STEP_ORDER: () => _STEP_ORDER, + _applyBulletOrder: () => _applyBulletOrder, + _executeReRunPhase: () => _executeReRunPhase, + _getStepTooltip: () => _getStepTooltip, + _highlightChangedItems: () => _highlightChangedItems, + _markChanged: () => _markChanged, + _showAnalysisClarificationAmendModal: () => _showAnalysisClarificationAmendModal, + _showReRunConfirmModal: () => _showReRunConfirmModal, + _updateBulletArrows: () => _updateBulletArrows, + _updateViewingIndicator: () => _updateViewingIndicator2, + applyDirtyPhaseNavigationState: () => applyDirtyPhaseNavigationState, + applyLayoutFreshnessNavigationState: () => applyLayoutFreshnessNavigationState, + backToPhase: () => backToPhase, + confirmReRunPhase: () => confirmReRunPhase2, + handleStepClick: () => handleStepClick, + moveBullet: () => moveBullet, + reRunPhase: () => reRunPhase, + resetBulletOrder: () => resetBulletOrder, + saveBulletOrder: () => saveBulletOrder, + showBulletReorder: () => showBulletReorder2, + updateWorkflowSteps: () => updateWorkflowSteps + }); + var log3 = getLogger("workflow-steps"); + function _findExperienceRecommendationRecord(expId) { + const data = globalThis.window?.pendingRecommendations; + if (!data || !Array.isArray(data.experience_recommendations)) return null; + return data.experience_recommendations.find((rec) => String(rec?.id || rec?.experience_id || "") === String(expId)) || null; } - function _saveRetryPolicyToStorage(policy) { - try { - localStorage.setItem(RETRY_POLICY_STORAGE_KEY, JSON.stringify(policy)); - } catch (error) { - log3.warn("Could not save retry policy preference:", error); + var _STEP_ORDER = [ + "job", + "analysis", + "customizations", + "rewrite", + "spell", + "layout", + "download", + "cover_letter", + "screening", + "interview_prep", + "thank_you", + "harvest" + ]; + var _STEP_DISPLAY = { + job: "Job Input", + analysis: "Job Analysis", + customizations: "Customise", + rewrite: "Rewrite Review", + spell: "Spell Check", + layout: "Layout Review", + download: "File Review", + cover_letter: "Cover Letter", + screening: "Screening", + interview_prep: "Interview Prep", + thank_you: "Thank You", + harvest: "Harvest" + }; + var _ACTION_LABELS = { + recommend_customizations: "Selecting experiences & skills\u2026", + generate_cv: "Generating CV\u2026" + }; + var _NAV_TAB_LABELS = { + download: "\u2B07\uFE0F File Review" + }; + function applyLayoutFreshnessNavigationState() { + const freshness = stateManager2.getLayoutFreshness(); + const generationState2 = stateManager2.getGenerationState(); + const layoutStep = document.getElementById("step-layout"); + if (layoutStep) { + layoutStep.classList.remove("stale", "stale-critical"); + const rerun = layoutStep.querySelector(".step-rerun")?.outerHTML || ""; + const refineBadge = layoutStep.querySelector(".step-inline-badge")?.outerHTML || ""; + const staleBadge = freshness.isStale ? ' Outdated' : ""; + if (freshness.isStale) { + layoutStep.classList.add("stale"); + if (freshness.isCritical) layoutStep.classList.add("stale-critical"); + } + layoutStep.innerHTML = `\u{1F3A8} Layout Review${staleBadge}${refineBadge ? ` ${refineBadge}` : ""}${rerun ? ` ${rerun}` : ""}`; } + const showDownstreamBadge = freshness.isStale && Boolean( + generationState2.finalGeneratedAt || generationState2.phase === "final_complete" + ); + Object.entries(_NAV_TAB_LABELS).forEach(([tab, label]) => { + const tabEl = document.getElementById(`tab-${tab}`); + if (!tabEl) return; + tabEl.classList.remove("tab-stale", "tab-stale-critical"); + if (showDownstreamBadge) { + tabEl.classList.add("tab-stale"); + if (freshness.isCritical) tabEl.classList.add("tab-stale-critical"); + tabEl.innerHTML = `${label} Outdated`; + } else { + tabEl.textContent = label; + } + }); } - function _settingsSourceLabel(meta, key, runtimeOverrides = null) { - if (runtimeOverrides && key in runtimeOverrides) { - return "Source: runtime selection"; + async function backToPhase(step, feedback) { + try { + const body = feedback ? { phase: step, feedback } : { phase: step }; + const res = await fetch("/api/back-to-phase", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + const data = await res.json(); + if (!res.ok || !data.ok) { + appendRetryMessage("\u26A0 Could not navigate back: " + (data.error || "Unknown error"), () => backToPhase(step, feedback)); + return; + } + appendMessage("assistant", `\u21BB Navigating back to ${step}. Prior decisions and approvals are preserved.`); + if (feedback) appendMessage("system", `Refinement feedback queued: "${feedback}"`); + await fetchStatus(); + const tabMap = { + job: null, + analysis: "analysis", + customizations: "goals", + rewrite: "rewrite", + spell: "spell" + }; + const resolvedTab = tabMap[step] || tabMap[data.phase] || null; + if (resolvedTab) switchTab(resolvedTab); + } catch (err) { + appendRetryMessage("\u26A0 Network error in backToPhase: " + err.message, () => backToPhase(step)); } - const source = meta?.sources?.[key] || "default"; - const envKey = meta?.env_keys?.[key] || null; - if (source === "env") return `Source: environment variable (${envKey || "locked"})`; - if (source === "dotenv") return `Source: .env (${envKey || "locked"})`; - if (source === "config") return "Source: config.yaml"; - return "Source: built-in default"; } - function _renderSettingsSources(meta, runtimeOverrides = null) { - const sourceTargets = [ - "llm.default_provider", - "llm.default_model", - "llm.request_timeout_seconds", - "llm.temperature", - "generation.max_skills", - "generation.max_achievements", - "generation.max_publications", - "generation.skills_section_title", - "generation.formats.ats_docx", - "generation.formats.human_pdf", - "generation.formats.human_docx" - ]; - sourceTargets.forEach((key) => { - const el = document.getElementById(`source-${key}`); - if (!el) return; - el.textContent = _settingsSourceLabel(meta, key, runtimeOverrides); - const runtimeKey = runtimeOverrides && key in runtimeOverrides; - const isLocked = Boolean(meta?.locked?.[key]); - el.style.color = runtimeKey ? "#1e40af" : isLocked ? "#b45309" : "#64748b"; + function _showReRunConfirmModal(step, mode, onConfirm) { + const stepIdx = _STEP_ORDER.indexOf(step); + const downstream = _STEP_ORDER.slice(stepIdx + 1).filter((s) => { + const el = document.getElementById(`step-${s}`); + return el && el.classList.contains("completed"); + }); + const stepLabel = _STEP_DISPLAY[step] || step; + const title = mode === "rerun" ? `\u21BB Re-run ${stepLabel}?` : `\u2190 Navigate back to ${stepLabel}?`; + const bodyText = mode === "rerun" ? "The following stages will see updated inputs and may show changed recommendations:" : "You are navigating back past the following completed stages:"; + const note = "All existing approvals and rewrites are preserved as context."; + const listHtml = downstream.map((s) => `
  • ${_STEP_DISPLAY[s] || s}
  • `).join(""); + const overlay = document.createElement("div"); + overlay.id = "rerun-confirm-overlay"; + overlay.style.cssText = `position:fixed;inset:0;background:rgba(0,0,0,0.5); + z-index:10000;display:flex;align-items:center;justify-content:center;`; + overlay.innerHTML = ` +
    +

    + ${escapeHtml(title)}

    +

    ${escapeHtml(bodyText)}

    + +

    ${escapeHtml(note)}

    +
    + + +
    +
    `; + document.body.appendChild(overlay); + _focusedElementBeforeModal = document.activeElement; + if (typeof trapFocus === "function") trapFocus("rerun-confirm-overlay"); + document.getElementById("rerun-proceed-btn").focus(); + const close = () => { + overlay.remove(); + restoreFocus(); + }; + document.getElementById("rerun-cancel-btn").addEventListener("click", close); + document.getElementById("rerun-proceed-btn").addEventListener("click", () => { + close(); + onConfirm(); + }); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) close(); + }); + overlay.addEventListener("keydown", (e) => { + if (e.key === "Escape") close(); }); } - function _setSettingsStatus(message, kind = "info") { - const el = document.getElementById("settings-status-msg"); - if (!el) return; - el.style.display = ""; - if (kind === "success") { - el.style.background = "#ecfdf5"; - el.style.border = "1px solid #86efac"; - el.style.color = "#166534"; - } else if (kind === "error") { - el.style.background = "#fef2f2"; - el.style.border = "1px solid #fecaca"; - el.style.color = "#991b1b"; - } else { - el.style.background = "#eff6ff"; - el.style.border = "1px solid #bfdbfe"; - el.style.color = "#1e40af"; - } - el.textContent = message; + function confirmReRunPhase2(step) { + _showReRunConfirmModal(step, "rerun", () => backToPhase(step)); } - function _collectSettingsPayloadFromForm() { - return { - llm: { - default_provider: document.getElementById("settings-llm-default-provider")?.value?.trim() || null, - default_model: document.getElementById("settings-llm-default-model")?.value?.trim() || null, - request_timeout_seconds: Number(document.getElementById("settings-llm-request-timeout")?.value || 120), - temperature: Number(document.getElementById("settings-llm-temperature")?.value || 0.7) - }, - generation: { - max_skills: Number(document.getElementById("settings-gen-max-skills")?.value || 20), - max_achievements: Number(document.getElementById("settings-gen-max-achievements")?.value || 5), - max_publications: Number(document.getElementById("settings-gen-max-publications")?.value || 10), - skills_section_title: document.getElementById("settings-gen-skills-title")?.value?.trim() || "Skills", - formats: { - ats_docx: Boolean(document.getElementById("settings-format-ats-docx")?.checked), - human_pdf: Boolean(document.getElementById("settings-format-human-pdf")?.checked), - human_docx: Boolean(document.getElementById("settings-format-human-docx")?.checked) - }, - ai_attribution: Boolean(document.getElementById("settings-gen-ai-attribution")?.checked) - } - }; - } - function _collectRetryPolicyFromForm() { - return { - baseMs: Number(document.getElementById("settings-retry-base-ms")?.value || 1500), - capMs: Number(document.getElementById("settings-retry-cap-ms")?.value || 6e4), - maxAttempts: Number(document.getElementById("settings-retry-max-attempts")?.value || 6), - autoRetry: Boolean(document.getElementById("settings-retry-auto")?.checked) - }; + function _getStepTooltip(step, isActive, isViewing, isBrowsingAway, isCompleted, isStale, isStaleCritical) { + if (isStaleCritical) return isViewing ? "Critical changes \u2014 review required. Click \u21BB to rerun." : "Critical changes \u2014 review required."; + if (isStale) return isViewing ? "Results may be outdated. Click \u21BB to rerun." : "Results may be outdated."; + if (isBrowsingAway) return "Active step \u2014 click to return"; + if (isActive && isViewing) return "Current step"; + if (isCompleted && isViewing) return "Click \u21BB to rerun from here"; + if (isCompleted) return "Click to view"; + return ""; } - function _renderSettingsToForm(payload) { - const settings = payload?.settings || {}; - const llm = settings.llm || {}; - const generation = settings.generation || {}; - const formats = generation.formats || {}; - const runtimeProvider = payload?.runtime?.llm?.provider || null; - const runtimeModel = payload?.runtime?.llm?.model || null; - const runtimeOverrides = {}; - if (runtimeProvider) runtimeOverrides["llm.default_provider"] = runtimeProvider; - if (runtimeModel) runtimeOverrides["llm.default_model"] = runtimeModel; - const setValue = (id, value) => { - const el = document.getElementById(id); - if (el) el.value = value ?? ""; - }; - const setChecked = (id, value) => { - const el = document.getElementById(id); - if (el) el.checked = Boolean(value); + function _updateViewingIndicator2(tabName) { + const tabToStep = { + "job": "job", + "analysis": "analysis", + "questions": "analysis", + "exp-review": "customizations", + "rewrite": "rewrite", + "spell": "spell", + "layout": "layout", + "final_generate": "download", + "download": "download", + "cover-letter": "cover_letter", + "screening": "screening", + "interview-prep": "interview_prep", + "thank-you": "thank_you", + "harvest": "harvest" }; - setValue("settings-llm-default-provider", runtimeProvider || llm.default_provider || ""); - setValue("settings-llm-default-model", runtimeModel || llm.default_model || ""); - setValue("settings-llm-request-timeout", llm.request_timeout_seconds ?? 120); - setValue("settings-llm-temperature", llm.temperature ?? 0.7); - setValue("settings-gen-max-skills", generation.max_skills ?? 20); - setValue("settings-gen-max-achievements", generation.max_achievements ?? 5); - setValue("settings-gen-max-publications", generation.max_publications ?? 10); - setValue("settings-gen-skills-title", generation.skills_section_title || "Skills"); - setChecked("settings-format-ats-docx", formats.ats_docx); - setChecked("settings-format-human-pdf", formats.human_pdf); - setChecked("settings-format-human-docx", formats.human_docx); - setChecked("settings-gen-ai-attribution", generation.ai_attribution); - const retryPolicy = _getRetryPolicyFromStorage(); - setValue("settings-retry-base-ms", retryPolicy.baseMs); - setValue("settings-retry-cap-ms", retryPolicy.capMs); - setValue("settings-retry-max-attempts", retryPolicy.maxAttempts); - setChecked("settings-retry-auto", retryPolicy.autoRetry); - _renderSettingsSources(payload.meta || {}, runtimeOverrides); - const pathEl = document.getElementById("settings-config-path"); - if (pathEl) { - pathEl.textContent = payload?.meta?.config_path ? `Config file: ${payload.meta.config_path}` : ""; - } + const viewedStep = tabToStep[tabName] || null; + let activeStep = null; + _STEP_ORDER.forEach((step) => { + const el = document.getElementById(`step-${step}`); + if (el && el.classList.contains("active")) activeStep = step; + }); + _STEP_ORDER.forEach((step) => { + const el = document.getElementById(`step-${step}`); + if (!el) return; + const isViewing = step === viewedStep; + const isActive = step === activeStep; + const isBrowsingAway = isActive && !!viewedStep && viewedStep !== activeStep; + const isCompleted = el.classList.contains("completed"); + const isStale = el.classList.contains("stale"); + const isStaleCritical = el.classList.contains("stale-critical"); + el.classList.toggle("viewing", isViewing); + el.classList.toggle("browsing-away", isBrowsingAway); + const tooltipText = _getStepTooltip(step, isActive, isViewing, isBrowsingAway, isCompleted, isStale, isStaleCritical); + if (tooltipText) { + el.setAttribute("data-bs-toggle", "tooltip"); + el.setAttribute("data-bs-placement", "bottom"); + el.setAttribute("data-bs-title", tooltipText); + if (typeof bootstrap !== "undefined") { + const tip = bootstrap.Tooltip.getInstance(el); + if (tip) tip.setContent({ ".tooltip-inner": tooltipText }); + else new bootstrap.Tooltip(el); + } + } else { + el.removeAttribute("data-bs-toggle"); + el.removeAttribute("data-bs-title"); + if (typeof bootstrap !== "undefined") { + const tip = bootstrap.Tooltip.getInstance(el); + if (tip) tip.dispose(); + } + } + }); } - async function reloadSettingsModal() { - try { - _setSettingsStatus("Loading settings...", "info"); - const [result, status] = await Promise.all([fetchSettings(), fetchStatus2().catch(() => null)]); - _renderSettingsToForm(result); - const attrEl = document.getElementById("settings-gen-ai-attribution"); - if (attrEl && status) attrEl.checked = Boolean(status.ai_attribution); - _setSettingsStatus("Settings loaded.", "success"); - } catch (error) { - _setSettingsStatus(`Failed to load settings: ${error.message || error}`, "error"); + async function _showAnalysisClarificationAmendModal(onProceed) { + const questions = window.postAnalysisQuestions || []; + const answers = window.questionAnswers || {}; + if (!questions.length) { + await onProceed(); + return; } - } - async function saveSettingsModal() { - const saveBtn = document.getElementById("settings-save-btn"); - try { - if (saveBtn) { - saveBtn.disabled = true; - saveBtn.textContent = "Saving..."; + const overlay = document.createElement("div"); + overlay.id = "clar-amend-overlay"; + overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;overflow-y:auto;"; + const formRows = questions.map((q2, i) => { + const qtype = q2.type || `clarification_${i + 1}`; + const qtext = escapeHtml(q2.question || ""); + const curAns = escapeHtml(answers[qtype] || ""); + const choices = Array.isArray(q2.choices) ? q2.choices : []; + if (choices.length) { + const radios = choices.map((c) => ` + `).join(""); + return ` +
    + + ${radios} +
    `; } - _setSettingsStatus("Saving settings...", "info"); - const payload = _collectSettingsPayloadFromForm(); - const aiAttribution = Boolean(payload.generation?.ai_attribution); - const result = await updateSettings(payload); - _saveRetryPolicyToStorage(_collectRetryPolicyFromForm()); - _settingsData = result; - _renderSettingsToForm(result); + return ` +
    + + +
    `; + }).join(""); + overlay.innerHTML = ` +
    +

    \u21BB Amend Clarification Answers

    +

    + Update your answers before rerunning analysis, or keep them as-is. +

    +
    + ${formRows} +
    +
    + + + +
    +
    `; + document.body.appendChild(overlay); + if (typeof _focusedElementBeforeModal !== "undefined") window._focusedElementBeforeModal = document.activeElement; + if (typeof trapFocus === "function") trapFocus("clar-amend-overlay"); + overlay.querySelector("#clar-amend-save").focus(); + function closeModal2() { + overlay.remove(); + if (typeof restoreFocus === "function") restoreFocus(); + } + overlay.querySelector("#clar-amend-cancel").addEventListener("click", closeModal2); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) closeModal2(); + }); + overlay.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeModal2(); + }); + overlay.querySelector("#clar-amend-keep").addEventListener("click", () => { + closeModal2(); + onProceed(); + }); + overlay.querySelector("#clar-amend-save").addEventListener("click", async () => { + const form = document.getElementById("clar-amend-form"); + const updated = {}; + questions.forEach((q2, i) => { + const qtype = q2.type || `clarification_${i + 1}`; + const el = form.querySelector(`[name="clar-${qtype}"]`); + if (!el) return; + if (el.type === "radio") { + const checked = form.querySelector(`[name="clar-${qtype}"]:checked`); + if (checked) updated[qtype] = checked.value; + } else { + const val = el.value.trim(); + if (val) updated[qtype] = val; + } + }); + closeModal2(); try { - await apiCall2("POST", "/api/generation-settings", { ai_attribution: aiAttribution }); - } catch (_2) { - } - _setSettingsStatus("Settings saved successfully.", "success"); - } catch (error) { - _setSettingsStatus(`Failed to save settings: ${error.message || error}`, "error"); - } finally { - if (saveBtn) { - saveBtn.disabled = false; - saveBtn.textContent = "Save Settings"; + await fetch("/api/post-analysis-responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ questions, answers: updated }) + }); + window.questionAnswers = updated; + } catch (_e2) { } + await onProceed(); + }); + } + async function reRunPhase(step) { + if (step === "analysis") { + await _showAnalysisClarificationAmendModal(() => _executeReRunPhase("analysis")); + return; } + await _executeReRunPhase(step); } - async function openSettingsModal() { - const overlay = document.getElementById("settings-modal-overlay"); - if (!overlay) return; - overlay.style.display = "flex"; - _focusStack.push(document.activeElement); - setInitialFocus2("settings-modal-overlay"); - trapFocus2("settings-modal-overlay"); - await reloadSettingsModal(); - } - function closeSettingsModal() { - const overlay = document.getElementById("settings-modal-overlay"); - if (overlay) overlay.style.display = "none"; - restoreFocus2(); - } - function getFocusableElements(container) { - const focusableSelectors = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "textarea:not([disabled])", - "select:not([disabled])", - '[tabindex]:not([tabindex="-1"])' - ].join(", "); - return Array.from(container.querySelectorAll(focusableSelectors)); - } - function setInitialFocus2(modalId) { - const modal = document.getElementById(modalId); - if (!modal) return; - const focusTarget = modal.querySelector('[data-focus-target="true"]') || modal.querySelector('input[type="text"]') || modal.querySelector("button"); - if (focusTarget) { - setTimeout(() => focusTarget.focus(), 50); + async function _executeReRunPhase(step) { + const loadingMsg = appendLoadingMessage(`\u21BB Re-running ${step}\u2026`); + setLoading(true, `Re-running ${step}\u2026`); + try { + const res = await fetch("/api/re-run-phase", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phase: step }) + }); + const data = await res.json(); + removeLoadingMessage(loadingMsg); + setLoading(false); + if (!res.ok || !data.ok) { + appendRetryMessage("\u26A0 Re-run failed: " + (data.error || "Unknown error"), () => reRunPhase(step)); + return; + } + appendMessage("assistant", `\u2705 ${step} re-run complete. Review the updated results \u2014 changed items are highlighted.`); + await fetchStatus(); + if (step === "spell") window._spellCheckCache = null; + if (step === "rewrite") window._rewritePanelCache = null; + const tabMap = { + analysis: "analysis", + customizations: "goals", + rewrite: "rewrite", + spell: "spell" + }; + if (tabMap[step]) switchTab(tabMap[step]); + if (data.prior_output && data.new_output) { + setTimeout(() => _highlightChangedItems(step, data.prior_output, data.new_output), 300); + } + } catch (err) { + removeLoadingMessage(loadingMsg); + setLoading(false); + appendRetryMessage("\u26A0 Network error in reRunPhase: " + err.message, () => reRunPhase(step)); } } - function trapFocus2(modalId) { - const modal = document.getElementById(modalId); - if (!modal) return; - const focusableElements = getFocusableElements(modal); - if (focusableElements.length === 0) return; - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - const listener = (e) => { - if (e.key !== "Tab") return; - const isShift = e.shiftKey; - const activeEl = document.activeElement; - if (isShift) { - if (activeEl === firstElement) { - e.preventDefault(); - lastElement.focus(); + function _highlightChangedItems(step, priorOutput, newOutput) { + if (step === "rewrite") { + const priorIds = new Set((priorOutput.pending_rewrites || []).map((r) => String(r.id))); + const newItems = newOutput.pending_rewrites || []; + for (const item of newItems) { + const id = String(item.id || ""); + const cardId = id.replace(/[^a-zA-Z0-9_-]/g, "_"); + const el = document.getElementById(`rw-card-${cardId}`); + if (!el) continue; + const isNew = !priorIds.has(id); + const priorItem = (priorOutput.pending_rewrites || []).find((r) => String(r.id) === id); + const changed = isNew || priorItem && priorItem.proposed !== item.proposed; + if (changed) _markChanged(el); + } + return; + } + if (step === "customizations") { + const priorExpIds = new Set( + (priorOutput.customizations?.experience_recommendations || []).map((r) => String(r.id)) + ); + const newExpRecs = newOutput.customizations?.experience_recommendations || []; + for (const rec of newExpRecs) { + const id = String(rec.id || ""); + const el = document.querySelector(`tr[data-exp-id="${CSS.escape(id)}"]`); + if (!el) continue; + const prior = (priorOutput.customizations?.experience_recommendations || []).find((r) => String(r.id) === id); + if (!priorExpIds.has(id) || prior && prior.recommendation !== rec.recommendation) { + _markChanged(el); } - } else { - if (activeEl === lastElement) { - e.preventDefault(); - firstElement.focus(); + } + const priorSkills = new Set( + (priorOutput.customizations?.skill_recommendations || []).map((r) => (r.skill || "").toLowerCase()) + ); + const newSkillRecs = newOutput.customizations?.skill_recommendations || []; + for (const rec of newSkillRecs) { + const name = (rec.skill || "").toLowerCase(); + const el = document.querySelector(`tr[data-skill="${CSS.escape(name)}"]`); + if (!el) continue; + const prior = (priorOutput.customizations?.skill_recommendations || []).find( + (r) => (r.skill || "").toLowerCase() === name + ); + if (!priorSkills.has(name) || prior && prior.recommendation !== rec.recommendation) { + _markChanged(el); } } - }; - document.addEventListener("keydown", listener); - _focusTrapStack.push(listener); - } - function restoreFocus2() { - const el = _focusStack.pop(); - if (el && typeof el.focus === "function") el.focus(); - const listener = _focusTrapStack.pop(); - if (listener) document.removeEventListener("keydown", listener); - } - function pushFocusStack2(el) { - _focusStack.push(el || document.activeElement); + return; + } } - var STAGE_TABS = { - job: ["job", "master"], - analysis: ["analysis"], - customizations: ["goals", "questions", "exp-review", "ach-editor", "skills-review", "achievements-review", "tagline-review", "summary-review", "publications-review", "ats-score"], - rewrite: ["rewrite"], - spell: ["spell"], - layout: ["layout"], - download: ["final_generate", "download"], - cover_letter: ["cover-letter"], - screening: ["screening"], - interview_prep: ["interview-prep"], - thank_you: ["thank-you"], - harvest: ["harvest"] - }; - function confirmDialog2(message, { confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) { - return new Promise((resolve) => { - const previousFocus = document.activeElement; - let overlay = document.getElementById("confirm-dialog-overlay"); - if (!overlay) { - overlay = document.createElement("div"); - overlay.id = "confirm-dialog-overlay"; - overlay.style.cssText = "display:none; position:fixed; inset:0; background:rgba(0,0,0,0.45); z-index:9999;align-items:center; justify-content:center;"; - overlay.innerHTML = ''; - document.body.appendChild(overlay); - } - const okBtn = document.getElementById("confirm-dialog-ok"); - const cancelBtn = document.getElementById("confirm-dialog-cancel"); - const msgEl = document.getElementById("confirm-dialog-msg"); - msgEl.textContent = message; - okBtn.textContent = confirmLabel; - cancelBtn.textContent = cancelLabel; - okBtn.style.background = danger ? "#dc2626" : "#3b82f6"; - overlay.style.display = "flex"; - okBtn.focus(); - const trapFocus3 = (e) => { - if (e.key !== "Tab") return; - e.preventDefault(); - const focused = document.activeElement; - if (e.shiftKey) { - (focused === cancelBtn ? okBtn : cancelBtn).focus(); - } else { - (focused === okBtn ? cancelBtn : okBtn).focus(); - } - }; - overlay.addEventListener("keydown", trapFocus3); - const finish = (result) => { - overlay.style.display = "none"; - overlay.removeEventListener("keydown", trapFocus3); - okBtn.replaceWith(okBtn.cloneNode(true)); - cancelBtn.replaceWith(cancelBtn.cloneNode(true)); - if (previousFocus && typeof previousFocus.focus === "function") { - previousFocus.focus(); - } - resolve(result); - }; - overlay.addEventListener("keydown", (e) => { - if (e.key === "Escape") finish(false); - }, { once: true }); - document.getElementById("confirm-dialog-ok").addEventListener("click", () => finish(true), { once: true }); - document.getElementById("confirm-dialog-cancel").addEventListener("click", () => finish(false), { once: true }); - overlay.addEventListener("click", (e) => { - if (e.target === overlay) finish(false); - }, { once: true }); - }); + function _markChanged(el) { + el.setAttribute("data-changed", "true"); + setTimeout(() => el.removeAttribute("data-changed"), 2500); } - function setupEventListeners() { - document.querySelectorAll(".tab").forEach((tab) => { - tab.addEventListener("click", (e) => { - const tabName = e.target.id.replace("tab-", ""); - switchTab(tabName); + async function showBulletReorder2(expId, expTitle) { + let achievements = []; + let proposedOrder = null; + let hasJobAnalysis = false; + let proposedReasoning = ""; + let proposedAtsImpact = ""; + let proposedPageImpact = ""; + try { + const detailsRes = await fetch("/api/experience-details", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ experience_id: expId }) }); - tab.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.target.click(); - return; - } - if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) { - e.preventDefault(); - const tabs = Array.from(document.querySelectorAll('.tab:not([style*="display: none"])')); - const currentIndex = tabs.indexOf(e.target); - let nextTab; - if (e.key === "ArrowLeft" || e.key === "Home") { - nextTab = e.key === "Home" ? tabs[0] : tabs[(currentIndex - 1 + tabs.length) % tabs.length]; - } else { - nextTab = e.key === "End" ? tabs[tabs.length - 1] : tabs[(currentIndex + 1) % tabs.length]; - } - if (nextTab) { - nextTab.focus(); - nextTab.click(); - } + if (!detailsRes.ok) { + let detailsErr = `HTTP ${detailsRes.status}`; + try { + const payload = await detailsRes.json(); + detailsErr = payload.error || payload.message || detailsErr; + } catch (_2) { } - }); - }); - const messageInput = document.getElementById("message-input"); - if (messageInput) { - messageInput.addEventListener("keypress", (e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - if (typeof globalThis.sendMessage === "function") { - globalThis.sendMessage(); + throw new Error(detailsErr); + } + const detailsData = await detailsRes.json(); + achievements = detailsData.experience && detailsData.experience.achievements || []; + const recRecord = _findExperienceRecommendationRecord(expId); + const llmBulletOrder = recRecord && typeof recRecord.bullet_order === "object" ? recRecord.bullet_order : null; + if (Array.isArray(llmBulletOrder?.order) && llmBulletOrder.order.length > 1) { + proposedOrder = llmBulletOrder.order; + hasJobAnalysis = true; + proposedReasoning = String(llmBulletOrder.reasoning || "").trim(); + proposedAtsImpact = String(llmBulletOrder.ats_impact || "").trim(); + proposedPageImpact = String(llmBulletOrder.page_length_impact || "").trim(); + } + if (!proposedOrder) { + try { + const proposedRes = await fetch(`/api/proposed-bullet-order?experience_id=${encodeURIComponent(expId)}`); + if (proposedRes.ok) { + const proposedData = await proposedRes.json(); + proposedOrder = proposedData.proposed_order || null; + hasJobAnalysis = proposedData.has_job_analysis || false; + } else { + log3.warn("Could not load suggested bullet order:", proposedRes.status); } + } catch (e) { + log3.warn("Could not load suggested bullet order:", e); } - }); + } + } catch (e) { + const errorText = e.message === "Failed to fetch" ? "Failed to fetch (server unavailable)." : e.message; + appendRetryMessage("\u26A0 Could not load bullets: " + errorText, () => showBulletReorder2(expId, expTitle)); + return; } - document.addEventListener("keydown", (e) => { + if (!achievements.length) { + appendMessage("system", "No bullet points found for this experience."); + return; + } + if (typeof _focusedElementBeforeModal !== "undefined") { + _focusedElementBeforeModal = document.activeElement; + } + const modal = document.createElement("div"); + modal.id = "bullet-reorder-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-labelledby", "bullet-reorder-title"); + modal.style.cssText = ` + position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5); + z-index:9999;display:flex;align-items:center;justify-content:center;`; + const suggestedBtn = hasJobAnalysis ? `` : ""; + const suggestionMeta = proposedReasoning || proposedAtsImpact || proposedPageImpact ? `
    + ${proposedReasoning ? `
    AI rationale: ${escapeHtml(proposedReasoning)}
    ` : ""} + ${proposedAtsImpact ? `
    ATS impact: ${escapeHtml(proposedAtsImpact)}
    ` : ""} + ${proposedPageImpact ? `
    Page length impact: ${escapeHtml(proposedPageImpact)}
    ` : ""} +
    ` : ""; + modal.innerHTML = ` +
    +
    +
    +

    \u2195 Reorder Bullets

    +
    ${expTitle}
    +
    + +
    +
    + Use \u2191 \u2193 to reorder. Bullets higher in the list appear first on your CV. + The most relevant bullet will be auto-ranked highest if you reset. +
    + ${suggestionMeta} +
      +
    +
    + ${suggestedBtn} + + +
    +
    `; + document.body.appendChild(modal); + if (typeof trapFocus === "function") trapFocus("bullet-reorder-modal"); + if (typeof setInitialFocus === "function") setInitialFocus("bullet-reorder-modal"); + const escHandler = (e) => { if (e.key === "Escape") { - closeAllModals(); + e.preventDefault(); + restoreFocus(); + modal.remove(); + document.removeEventListener("keydown", escHandler); } + }; + document.addEventListener("keydown", escHandler); + const list = document.getElementById("bullet-reorder-list"); + achievements.forEach((ach, idx) => { + const text2 = typeof ach === "object" ? ach.text || "" : String(ach); + const li = document.createElement("li"); + li.dataset.origIndex = idx; + li.style.cssText = `display:flex;align-items:flex-start;gap:8px;padding:8px;margin-bottom:6px; + background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;`; + li.innerHTML = ` +
    + + +
    + ${text2}`; + list.appendChild(li); }); - document.querySelectorAll('[role="dialog"]').forEach((modal) => { - modal.addEventListener("click", (e) => { - if (e.target === modal) { - closeModal(modal.id); - } + _updateBulletArrows(); + if (hasJobAnalysis && proposedOrder) { + document.getElementById("use-llm-order-btn")?.addEventListener("click", () => { + _applyBulletOrder(proposedOrder); }); - }); - } - function getStageForTab2(tab) { - for (const [stage, tabs] of Object.entries(STAGE_TABS)) { - if (tabs.includes(tab)) return stage; } - return null; } - function getVisibleStage() { - const activeTab = stateManager2.getCurrentTab(); - return getStageForTab2(activeTab) || getWorkflowStepForPhase(stateManager2.getPhase()); + function _applyBulletOrder(order) { + const list = document.getElementById("bullet-reorder-list"); + if (!list) return; + const items = Array.from(list.querySelectorAll("li")); + const byOrig = {}; + items.forEach((li) => { + byOrig[parseInt(li.dataset.origIndex, 10)] = li; + }); + const listed = new Set(order.map(Number)); + order.forEach((idx) => { + if (byOrig[idx]) list.appendChild(byOrig[idx]); + }); + items.forEach((li) => { + if (!listed.has(parseInt(li.dataset.origIndex, 10))) list.appendChild(li); + }); + _updateBulletArrows(); } - function updateTabScrollButtons() { - const tabBar = document.getElementById("tab-bar"); - const leftBtn = document.getElementById("tab-scroll-left"); - const rightBtn = document.getElementById("tab-scroll-right"); - if (!tabBar || !leftBtn || !rightBtn) return; - leftBtn.style.display = tabBar.scrollLeft > 0 ? "" : "none"; - rightBtn.style.display = tabBar.scrollLeft < tabBar.scrollWidth - tabBar.clientWidth - 1 ? "" : "none"; + function moveBullet(btn, direction) { + const li = btn.closest("li"); + const list = li.parentNode; + if (direction === -1 && li.previousElementSibling) { + list.insertBefore(li, li.previousElementSibling); + } else if (direction === 1 && li.nextElementSibling) { + list.insertBefore(li.nextElementSibling, li); + } + _updateBulletArrows(); } - function updateTabBarForStage2(stage) { - const stageTabs = STAGE_TABS[stage] || []; - document.querySelectorAll(".tab").forEach((tab) => { - tab.style.display = stageTabs.includes(tab.dataset.tab) ? "" : "none"; + function _updateBulletArrows() { + const list = document.getElementById("bullet-reorder-list"); + if (!list) return; + const items = list.querySelectorAll("li"); + items.forEach((li, idx) => { + const [upBtn, downBtn] = li.querySelectorAll("button"); + upBtn.disabled = idx === 0; + downBtn.disabled = idx === items.length - 1; + upBtn.style.opacity = upBtn.disabled ? "0.3" : "1"; + downBtn.style.opacity = downBtn.disabled ? "0.3" : "1"; }); - const tabBar = document.getElementById("tab-bar"); - if (tabBar) tabBar.scrollLeft = 0; - updateTabScrollButtons(); } - function toggleChat() { - const interactionArea = document.querySelector(".interaction-area"); - const viewerArea = document.querySelector(".viewer-area"); - if (interactionArea) { - const isCollapsed = interactionArea.classList.toggle("collapsed"); - if (viewerArea) { - viewerArea.style.flex = isCollapsed ? "1 1 100%" : "0 1 60%"; - } - const toggleBtn = document.getElementById("toggle-chat"); - if (toggleBtn) { - toggleBtn.setAttribute("aria-expanded", String(!isCollapsed)); - toggleBtn.setAttribute("aria-label", isCollapsed ? "Expand chat panel" : "Collapse chat panel"); - } - try { - localStorage.setItem(StorageKeys2.CHAT_COLLAPSED, isCollapsed); - } catch (e) { - log3.warn("Could not save chat state"); + async function saveBulletOrder(expId) { + const list = document.getElementById("bullet-reorder-list"); + const items = list ? list.querySelectorAll("li") : []; + const order = Array.from(items).map((li) => parseInt(li.dataset.origIndex, 10)); + try { + const res = await fetch("/api/reorder-bullets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ experience_id: expId, order }) + }); + const data = await res.json(); + if (!res.ok || !data.ok) { + appendRetryMessage("\u26A0 Could not save bullet order: " + (data.error || "Unknown"), () => saveBulletOrder(expId)); + return; } + appendMessage("assistant", "\u2195 Bullet order saved. It will apply when you generate the CV."); + document.getElementById("bullet-reorder-modal")?.remove(); + } catch (e) { + appendRetryMessage("\u26A0 Network error saving bullet order: " + e.message, () => saveBulletOrder(expId)); } } - function openModal(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - _focusStack.push(document.activeElement); - modal.classList.add("visible"); - modal.setAttribute("aria-hidden", "false"); - document.body.style.overflow = "hidden"; - setInitialFocus2(modalId); - trapFocus2(modalId); - } - } - function closeModal(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - if (modal.dataset.dismissDisabled === "1") return; - modal.classList.remove("visible"); - modal.setAttribute("aria-hidden", "true"); - if (!document.querySelector('[role="dialog"].visible')) { - document.body.style.overflow = ""; + async function resetBulletOrder(expId) { + try { + const res = await fetch("/api/reorder-bullets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ experience_id: expId, order: [] }) + }); + const data = await res.json(); + if (!res.ok || !data.ok) { + appendRetryMessage("\u26A0 Could not reset bullet order: " + (data.error || "Unknown"), () => resetBulletOrder(expId)); + return; } - restoreFocus2(); + appendMessage("assistant", "\u21BA Bullet order reset. Relevance-based ordering will apply."); + restoreFocus(); + document.getElementById("bullet-reorder-modal")?.remove(); + } catch (e) { + appendRetryMessage("\u26A0 Network error resetting bullet order: " + e.message, () => resetBulletOrder(expId)); } } - function closeAllModals() { - document.querySelectorAll('[role="dialog"]').forEach((modal) => { - if (modal.dataset.dismissDisabled === "1") return; - modal.classList.remove("visible"); - modal.setAttribute("aria-hidden", "true"); - if (modal.style.display && modal.style.display !== "none") { - modal.style.display = "none"; - } - }); - document.body.style.overflow = ""; - restoreFocus2(); - } - var _modelData = null; - var _modelDataTable = null; - var _selectedModelProviders = /* @__PURE__ */ new Set(); - var _modelSelectorLoading = false; - var _catalogRefreshing = false; - var _catalogRefreshPending = false; - var _showFullModelCatalog = false; - var _copilotAuthPollTimer = null; - var _modelWizardStep = 1; - var _modelWizardSelectedProvider = null; - function _getModelPrefsFromStorage() { - try { - const saved = localStorage.getItem(StorageKeys2.TAB_DATA); - return saved ? JSON.parse(saved) || {} : {}; - } catch { - return {}; - } - } - function _saveModelPrefsToStorage(patch) { - try { - const parsed = _getModelPrefsFromStorage(); - localStorage.setItem(StorageKeys2.TAB_DATA, JSON.stringify({ ...parsed, ...patch })); - } catch (e) { - log3.warn("Failed to persist model preferences locally:", e); - } - } - function _appendRecentModel(provider, model) { - if (!provider || !model) return; - const parsed = _getModelPrefsFromStorage(); - const existing = Array.isArray(parsed.recentModels) ? parsed.recentModels : []; - const filtered = existing.filter((item) => !(item.provider === provider && item.model === model)); - filtered.unshift({ provider, model }); - _saveModelPrefsToStorage({ recentModels: filtered.slice(0, 6) }); - } - function _updateLlmStatusPill2(kind, text2, icon = "", tooltip = "") { - const pill = document.getElementById("llm-status-pill"); - const label = document.getElementById("llm-status-label"); - const iconEl = document.getElementById("llm-status-icon"); - if (!pill || !label) return; - const aliases = { - authenticated: "connected", - unauthenticated: "unconfigured", - polling: "connecting" + var _PHASE_PROGRESSION = [ + "init", + "job_analysis", + "customization", + "rewrite_review", + "spell_check", + "generation", + "layout_review", + "final_generation", + "refinement" + ]; + var _STEP_FWD_PHASE_MIN = { + analysis: 1, + // job_analysis + customizations: 2, + // customization + rewrite: 3, + // rewrite_review + spell: 4, + // spell_check + layout: 5, + // generation + download: 7, + // final_generation + cover_letter: 7, + screening: 7, + interview_prep: 7, + thank_you: 7, + harvest: 7 + }; + function updateWorkflowSteps(status) { + const UPCOMING = /* @__PURE__ */ new Set(); + const RE_RUN_STEPS = /* @__PURE__ */ new Set(["analysis", "customizations", "rewrite", "spell", "layout"]); + const STEP_LABELS = { + job: "\u{1F4E5} Job Input", + analysis: "\u{1F50D} Analysis", + customizations: "\u2699\uFE0F Customise", + rewrite: "\u270F\uFE0F Rewrites", + spell: "\u{1F524} Spell Check", + layout: "\u{1F3A8} Layout Review", + download: "\u2B07\uFE0F Download Files", + cover_letter: "\u{1F4E9} Cover Letter", + screening: "\u{1F4CB} Screening", + interview_prep: "\u{1F3A4} Interview Prep", + thank_you: "\u{1F64F} Thank You", + harvest: "\u{1F33E} Harvest" }; - const normalizedKind = aliases[kind] || kind || "unconfigured"; - const defaultTooltip = { - unconfigured: "No provider/model is configured yet.", - configured: "Provider/model is configured. Connectivity not yet verified.", - connecting: "Testing or connecting to the selected provider.", - connected: "Provider responded successfully to a live request.", - "auth-required": "Authentication is required. Check API key or sign in.", - "rate-limited": "Rate limit reached. Wait before retrying requests.", - unavailable: "Provider is temporarily unavailable or unreachable.", - error: "Connection failed. Open model settings for details." + const phase = status.phase || ""; + const postLayout = phase === PHASES.FINAL_GENERATION || phase === PHASES.REFINEMENT; + const done = { + job: !!status.job_description, + analysis: !!status.job_analysis, + customizations: !!status.customizations, + rewrite: phase !== PHASES.REWRITE_REVIEW && !!status.customizations, + spell: phase === PHASES.GENERATION || phase === PHASES.LAYOUT_REVIEW || phase === PHASES.FINAL_GENERATION || phase === PHASES.REFINEMENT, + layout: postLayout, + download: postLayout, + cover_letter: postLayout, + screening: postLayout, + interview_prep: postLayout, + thank_you: postLayout, + harvest: postLayout }; - pill.classList.remove("authenticated", "unauthenticated", "polling", "unconfigured", "configured", "connecting", "connected", "auth-required", "rate-limited", "unavailable", "error"); - if (normalizedKind) pill.classList.add(normalizedKind); - label.textContent = text2; - const iconByKind = { - unconfigured: "\u25CB", - configured: "\u25D4", - connecting: "\u29D7", - connected: "\u2713", - "auth-required": "\u{1F511}", - "rate-limited": "\u23F3", - unavailable: "\u2601", - error: "\u26A0" + const highestPhaseIdx = _PHASE_PROGRESSION.indexOf(status.highest_phase || ""); + const currentPhaseIdx = _PHASE_PROGRESSION.indexOf(phase); + const phaseToStep = { + "init": "job", + "job_analysis": "analysis", + "customization": "customizations", + "rewrite_review": "rewrite", + "spell_check": "spell", + "generation": "layout", + "layout_review": "layout", + "final_generation": "download", + "refinement": "download" }; - const iconText = icon || iconByKind[normalizedKind] || "\u26A0"; - if (iconEl) iconEl.textContent = iconText; - const tooltipText = tooltip || defaultTooltip[normalizedKind] || ""; - pill.title = tooltipText; - if (iconEl) iconEl.title = tooltipText; - label.title = tooltipText; - } - async function loadModelSelector() { - if (_modelSelectorLoading) return; - _modelSelectorLoading = true; - try { - _modelData = await apiCall2("GET", "/api/model"); - try { - const saved = localStorage.getItem(StorageKeys2.TAB_DATA); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed && !(_modelData && _modelData.provider) && parsed.currentModelProvider) { - _modelData = _modelData || {}; - _modelData.provider = parsed.currentModelProvider; - } - if (parsed && !(_modelData && _modelData.model) && parsed.currentModelName) { - _modelData = _modelData || {}; - _modelData.model = parsed.currentModelName; - } + const activeStep = phaseToStep[phase] || "job"; + const _phaseToStep2 = Object.assign({ "init": "job" }, phaseToStep); + const reentryStep = status.iterating ? _phaseToStep2[status.reentry_phase] || status.reentry_phase || null : null; + const stepIds = [ + "job", + "analysis", + "customizations", + "rewrite", + "spell", + "layout", + "download", + "cover_letter", + "screening", + "interview_prep", + "thank_you", + "harvest" + ]; + const staleSteps = new Set(status.stale_steps || []); + stepIds.forEach((step) => { + const el = document.getElementById(`step-${step}`); + if (!el) return; + if (UPCOMING.has(step)) return; + el.classList.remove("active", "completed", "clickable", "upcoming", "stale", "forward-skip"); + let label = STEP_LABELS[step] || step; + if (step === activeStep) { + el.classList.add("active"); + if (status.iterating && reentryStep === step) { + label += ' \u21BB Refining'; } - } catch (e) { - log3.warn("Could not read saved model from localStorage:", e); - } - const label = document.getElementById("model-current-label"); - if (label) { - const prov = _modelData.provider; - const model = _modelData.model || "\u2014"; - label.textContent = prov ? `${prov} \xB7 ${model}` : model; - } - if (_modelData && _modelData.provider) { - _selectedModelProviders = /* @__PURE__ */ new Set([_modelData.provider]); - _updateLlmStatusPill2("configured", `${_modelData.provider} configured`); - } else { - _updateLlmStatusPill2("unconfigured", "Not configured"); + } else if (done[step]) { + el.classList.add("completed"); + el.classList.add("clickable"); + if (RE_RUN_STEPS.has(step)) { + const rerunLabel = _STEP_DISPLAY[step] || step; + label += ` `; + } + } else if (highestPhaseIdx >= 0 && (_STEP_FWD_PHASE_MIN[step] ?? Infinity) <= highestPhaseIdx && highestPhaseIdx > currentPhaseIdx) { + el.classList.add("forward-skip", "clickable"); + label += ' \u23E9'; } - } catch (e) { - log3.warn("Could not load model list:", e); - } finally { - _modelSelectorLoading = false; + if (staleSteps.has(step)) el.classList.add("stale"); + const isStale = staleSteps.has(step); + const isStaleCritical = el.classList.contains("stale-critical"); + const isActive = el.classList.contains("active"); + const isCompleted = el.classList.contains("completed"); + const isFwdSkip = el.classList.contains("forward-skip"); + const srState = isStaleCritical ? " (critical \u2014 review required)" : isStale ? " (stale \u2014 results may be outdated)" : isActive ? " (current step)" : isCompleted ? " (completed)" : isFwdSkip ? " (previously completed \u2014 click to jump ahead)" : ""; + if (srState) label += `${srState}`; + el.innerHTML = label; + }); + applyLayoutFreshnessNavigationState(); + if (!document.getElementById("step-rerun-style")) { + const s = document.createElement("style"); + s.id = "step-rerun-style"; + s.textContent = ".step.completed:hover .step-rerun, .step.completed:focus-within .step-rerun { opacity: 1 !important; } .step-rerun:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; opacity: 1 !important; }"; + document.head.appendChild(s); } + if (typeof updateTabBarForStage === "function") { + updateTabBarForStage(activeStep); + } + updateActionButtons(activeStep); + _updateViewingIndicator2(stateManager2.getCurrentTab()); } - function _providerStageLabel(provider, capableSet) { - const isCapable = capableSet.has(provider); - return isCapable ? "list_models" : "fallback"; - } - function _providerDisplayLabel(provider) { - const deprecated = { - "copilot-oauth": "Copilot OAuth (deprecated)", - copilot: "Copilot (deprecated)", - github: "GitHub Models (deprecated)", - "copilot-sdk": "Copilot SDK (recommended)" - }; - if (deprecated[provider]) return deprecated[provider]; - return provider; + if (typeof window !== "undefined") { + window.addEventListener(GENERATION_STATE_EVENT, applyLayoutFreshnessNavigationState); + window.addEventListener(GENERATION_STATE_EVENT, applyDirtyPhaseNavigationState); } - function _getQuickModelCandidates(rows) { - const parsed = _getModelPrefsFromStorage(); - const recents = Array.isArray(parsed.recentModels) ? parsed.recentModels : []; - const ranked = []; - for (const recent of recents) { - const hit = rows.find((row) => row.provider === recent.provider && row.model === recent.model); - if (hit) ranked.push(hit); - } - const popular = [...rows].sort((a, b2) => { - const am = Number(a.copilot_multiplier ?? 999); - const bm = Number(b2.copilot_multiplier ?? 999); - if (am !== bm) return am - bm; - const ac = Number(a.context_window || 0); - const bc = Number(b2.context_window || 0); - return bc - ac; + function applyDirtyPhaseNavigationState() { + const dirtyPhases = stateManager2.getDirtyPhases(); + const stepsToClear = []; + stepsToClear.forEach((step) => { + const el = document.getElementById(`step-${step}`); + if (!el) return; + const isDirty = dirtyPhases.includes(step); + el.classList.toggle("stale", isDirty); + const rerunHtml = el.querySelector(".step-rerun")?.outerHTML || ""; + const label = _STEP_DISPLAY[step] || step; + const badge = isDirty ? ' Content changed' : ""; + el.innerHTML = `${label}${badge}${rerunHtml ? ` ${rerunHtml}` : ""}`; }); - for (const item of popular) { - if (!ranked.some((r) => r.provider === item.provider && r.model === item.model)) { - ranked.push(item); + } + function _doStepNavigate(step) { + const stepToTab = { + analysis: "analysis", + customizations: "goals", + rewrite: "rewrite", + spell: "spell", + layout: "layout", + download: "final_generate", + cover_letter: "cover-letter", + screening: "screening", + interview_prep: "interview-prep", + thank_you: "thank-you", + harvest: "harvest" + }; + const tabName = stepToTab[step]; + if (!tabName) return; + if (typeof updateTabBarForStage === "function") updateTabBarForStage(step); + switchTab(tabName); + } + function handleStepClick(step) { + const el = document.getElementById(`step-${step}`); + if (!el) return; + if (step === "job") { + if (el.classList.contains("completed") || el.classList.contains("active")) { + switchTab("job"); + } else { + showLoadJobPanel(); } + return; } - return ranked.slice(0, 8); - } - function _renderQuickModelList(rows) { - const quickEl = document.getElementById("model-quick-list"); - if (!quickEl) return; - quickEl.innerHTML = ""; - const candidates = _getQuickModelCandidates(rows || []); - for (const item of candidates) { - const isActive = item.provider === _modelData?.provider && item.model === _modelData?.model; - const button = document.createElement("button"); - button.className = "header-pill-btn"; - button.style.padding = "5px 10px"; - button.style.fontSize = "0.82em"; - button.style.background = isActive ? "#dbeafe" : "#f8fafc"; - button.style.color = isActive ? "#1e3a8a" : "#334155"; - button.style.borderColor = isActive ? "#93c5fd" : "#cbd5e1"; - button.title = `${item.provider} \xB7 ${item.model}`; - button.textContent = `${item.provider} \xB7 ${item.model}`; - button.addEventListener("click", async () => { - await setModel(item.model, item.provider); + if (el.classList.contains("forward-skip")) { + const label = _STEP_DISPLAY[step] || step; + if (typeof confirmDialog === "function") { + confirmDialog( + `Jump ahead to ${label}? + +Intermediate stages may need re-running if you make changes here.`, + { confirmLabel: "Jump ahead", cancelLabel: "Stay here" } + ).then((confirmed) => { + if (confirmed) _doStepNavigate(step); + }); + } else { + _doStepNavigate(step); + } + return; + } + if (!el.classList.contains("completed") && !el.classList.contains("active")) return; + const hasUnansweredPostAnalysisQuestions = () => { + const qs = Array.isArray(window.postAnalysisQuestions) ? window.postAnalysisQuestions : []; + if (qs.length === 0) return false; + const answers = window.questionAnswers && typeof window.questionAnswers === "object" ? window.questionAnswers : {}; + return qs.some((q2) => { + const value = answers[q2.type]; + return !value || !String(value).trim(); }); - quickEl.appendChild(button); + }; + const stepToTab = { + analysis: hasUnansweredPostAnalysisQuestions() ? "questions" : "analysis", + customizations: "goals", + rewrite: "rewrite", + spell: "spell", + layout: "layout", + download: "final_generate", + cover_letter: "cover-letter", + screening: "screening", + interview_prep: "interview-prep", + thank_you: "thank-you", + harvest: "harvest" + }; + const tabName = stepToTab[step]; + if (!tabName) return; + const doNavigate = () => { + if (typeof updateTabBarForStage === "function") updateTabBarForStage(step); + switchTab(tabName); + }; + if (el.classList.contains("completed") && !el.classList.contains("active")) { + const stepIdx = _STEP_ORDER.indexOf(step); + const hasDownstreamCompleted = _STEP_ORDER.slice(stepIdx + 1).some((s) => { + const downstream = document.getElementById(`step-${s}`); + return downstream && downstream.classList.contains("completed"); + }); + if (hasDownstreamCompleted) { + _showReRunConfirmModal(step, "back-nav", doNavigate); + return; + } } + doNavigate(); } - function _syncCatalogVisibility() { - const wrap = document.getElementById("model-full-table-wrap"); - const search = document.getElementById("model-global-search"); - const toggle = document.getElementById("model-show-all-btn"); - if (wrap) wrap.style.display = _showFullModelCatalog ? "" : "none"; - if (search) search.style.display = _showFullModelCatalog ? "" : "none"; - if (toggle) toggle.textContent = _showFullModelCatalog ? "Hide Full Catalog" : "Show Full Catalog"; - } - function toggleModelCatalogVisibility() { - _showFullModelCatalog = !_showFullModelCatalog; - _syncCatalogVisibility(); - } - async function _renderAuthStep(provider) { - const content = document.getElementById("model-auth-step-content"); - const keyPanel = document.getElementById("model-auth-key-panel"); - const devPanel = document.getElementById("model-auth-device-panel"); - const keyInput = document.getElementById("model-auth-key-input"); - const keyStatus = document.getElementById("model-auth-key-status"); - if (!content) return; - if (keyPanel) keyPanel.style.display = "none"; - if (devPanel) devPanel.style.display = "none"; - if (keyInput) keyInput.value = ""; - if (keyStatus) keyStatus.textContent = ""; - if (!provider) { - content.innerHTML = '

    Select a provider in Step 1 first.

    '; - return; - } - let credData = null; + + // web/ui-core.js + var log4 = getLogger("ui-core"); + var _focusStack = []; + var _focusTrapStack = []; + var _settingsData = null; + var RETRY_POLICY_STORAGE_KEY = "cv-builder-retry-policy"; + function _getRetryPolicyFromStorage() { + const defaults = { + baseMs: 1500, + capMs: 6e4, + maxAttempts: 6, + autoRetry: true + }; try { - const resp = await fetch("/api/settings/credentials/status"); - if (resp.ok) { - const data = await resp.json(); - credData = (data.providers || {})[provider] || null; - } + const raw = localStorage.getItem(RETRY_POLICY_STORAGE_KEY); + if (!raw) return defaults; + const parsed = JSON.parse(raw); + return { + baseMs: Math.max(200, Number(parsed.baseMs || defaults.baseMs)), + capMs: Math.max(1e3, Number(parsed.capMs || defaults.capMs)), + maxAttempts: Math.max(1, Number(parsed.maxAttempts || defaults.maxAttempts)), + autoRetry: parsed.autoRetry !== false + }; } catch { + return defaults; } - const authType = credData?.auth_type || "api_key"; - const label = credData?.label || provider; - const helpText = credData?.help_text || ""; - const getKeyUrl = credData?.get_key_url || ""; - const isSet = credData?.is_set || false; - const source = credData?.source || "unset"; - const envVar = credData?.env_var || null; - const isLocked = credData?.locked || false; - const isSetBadge = isSet ? '✓ Key saved' : 'Not configured'; - let sourceLabel = ""; - let tooltipText = ""; - if (source === "env") { - sourceLabel = `Source: environment variable (${envVar || "locked"})`; - tooltipText = `Currently loaded from the ${envVar} environment variable. To change it permanently, update your shell config (e.g. ~/.zshrc) and restart the server. You can still save a value below \u2014 it takes effect immediately but will be overridden by the environment variable on next restart.`; - } else if (source === "dotenv") { - sourceLabel = `Source: .env file (${envVar || "locked"})`; - tooltipText = `Currently loaded from ${envVar} in your .env file. To change it permanently, edit the .env file in the project directory and restart the server. You can still save a value below \u2014 it takes effect immediately but will be overridden by .env on next restart.`; - } else if (source === "config") { - sourceLabel = "Source: config.yaml"; - tooltipText = "Stored in config.yaml. Update it using the input below."; + } + function _saveRetryPolicyToStorage(policy) { + try { + localStorage.setItem(RETRY_POLICY_STORAGE_KEY, JSON.stringify(policy)); + } catch (error) { + log4.warn("Could not save retry policy preference:", error); } - const infoIcon = tooltipText ? ` ` : ""; - const sourceLabelHtml = sourceLabel ? `${escapeHtml2(sourceLabel)}${infoIcon}` : ""; - const sourceDesc = source === "env" ? "environment variable" : ".env file"; - const sourceRef = envVar ? `${sourceDesc} ${escapeHtml2(envVar)}` : sourceDesc; - const lockedWarnHtml = isLocked ? `
    \u26A0 This key is loaded from the ${sourceRef}. Any value saved below takes effect immediately for the current session, but will be overridden by the ${sourceDesc} when the server restarts or reloads.
    ` : ""; - const getKeyLink = getKeyUrl ? `Get your key ↗` : ""; - content.innerHTML = `
    ${escapeHtml2(label)} ${isSetBadge}${sourceLabelHtml}` + (getKeyLink ? ` ${getKeyLink}` : "") + `
    ` + (helpText ? `

    ${escapeHtml2(helpText)}

    ` : "") + lockedWarnHtml; - if (authType === "api_key") { - if (keyPanel) { - keyPanel.style.display = ""; - if (keyInput) { - keyInput.disabled = false; - keyInput.style.opacity = ""; - keyInput.placeholder = isSet ? "Enter new key to replace the saved one" : "Paste your API key here"; - keyInput.dataset.provider = provider; - } - } - } else if (authType === "device_flow") { - if (devPanel) { - devPanel.style.display = ""; - await _refreshCopilotAuthStatus(); - } - } else if (authType === "cli") { - content.innerHTML += `

    Run this command in a terminal, then return here and click Next:

    gh auth login
    `; - } else { - content.innerHTML += `
    No authentication required for this provider.
    `; + } + function _settingsSourceLabel(meta, key, runtimeOverrides = null) { + if (runtimeOverrides && key in runtimeOverrides) { + return "Source: runtime selection"; } + const source = meta?.sources?.[key] || "default"; + const envKey = meta?.env_keys?.[key] || null; + if (source === "env") return `Source: environment variable (${envKey || "locked"})`; + if (source === "dotenv") return `Source: .env (${envKey || "locked"})`; + if (source === "config") return "Source: config.yaml"; + return "Source: built-in default"; } - async function _refreshCopilotAuthStatus() { - const statusEl = document.getElementById("model-auth-status"); - const startBtn = document.getElementById("model-auth-start-btn"); - const logoutBtn = document.getElementById("model-auth-logout-btn"); - const codeEl = document.getElementById("model-auth-code"); - const linkEl = document.getElementById("model-auth-link"); - if (!statusEl) return; - if (codeEl) codeEl.textContent = ""; - if (linkEl) linkEl.style.display = "none"; - try { - const st = await fetch("/api/copilot-auth/status").then((res) => res.json()); - if (st.authenticated) { - statusEl.textContent = "Authenticated with GitHub Copilot."; - if (startBtn) startBtn.style.display = "none"; - if (logoutBtn) logoutBtn.style.display = ""; - _updateLlmStatusPill2("authenticated", "Copilot ready"); - } else if (st.polling) { - statusEl.textContent = "Waiting for device approval\u2026"; - if (startBtn) startBtn.style.display = ""; - if (logoutBtn) logoutBtn.style.display = "none"; - _updateLlmStatusPill2("polling", "Copilot auth pending", "\u29D7"); - } else { - statusEl.textContent = "Not authenticated. Start sign-in to enable Copilot OAuth."; - if (startBtn) startBtn.style.display = ""; - if (logoutBtn) logoutBtn.style.display = "none"; - _updateLlmStatusPill2("unauthenticated", "Copilot not authenticated"); - } - } catch { - statusEl.textContent = "Unable to load auth status."; - } + function _renderSettingsSources(meta, runtimeOverrides = null) { + const sourceTargets = [ + "llm.default_provider", + "llm.default_model", + "llm.request_timeout_seconds", + "llm.temperature", + "generation.max_skills", + "generation.max_achievements", + "generation.max_publications", + "generation.skills_section_title", + "generation.formats.ats_docx", + "generation.formats.human_pdf", + "generation.formats.human_docx" + ]; + sourceTargets.forEach((key) => { + const el = document.getElementById(`source-${key}`); + if (!el) return; + el.textContent = _settingsSourceLabel(meta, key, runtimeOverrides); + const runtimeKey = runtimeOverrides && key in runtimeOverrides; + const isLocked = Boolean(meta?.locked?.[key]); + el.style.color = runtimeKey ? "#1e40af" : isLocked ? "#b45309" : "#64748b"; + }); } - async function saveProviderApiKey() { - const keyInput = document.getElementById("model-auth-key-input"); - const keyStatus = document.getElementById("model-auth-key-status"); - const provider = keyInput?.dataset?.provider || _modelWizardSelectedProvider; - const value = (keyInput?.value || "").trim(); - if (!value) { - if (keyStatus) { - keyStatus.textContent = "Please enter a key value."; - keyStatus.style.color = "#b91c1c"; - } - return; + function _setSettingsStatus(message, kind = "info") { + const el = document.getElementById("settings-status-msg"); + if (!el) return; + el.style.display = ""; + if (kind === "success") { + el.style.background = "#ecfdf5"; + el.style.border = "1px solid #86efac"; + el.style.color = "#166534"; + } else if (kind === "error") { + el.style.background = "#fef2f2"; + el.style.border = "1px solid #fecaca"; + el.style.color = "#991b1b"; + } else { + el.style.background = "#eff6ff"; + el.style.border = "1px solid #bfdbfe"; + el.style.color = "#1e40af"; } - if (!provider) { - if (keyStatus) { - keyStatus.textContent = "No provider selected."; - keyStatus.style.color = "#b91c1c"; + el.textContent = message; + } + function _collectSettingsPayloadFromForm() { + return { + llm: { + default_provider: document.getElementById("settings-llm-default-provider")?.value?.trim() || null, + default_model: document.getElementById("settings-llm-default-model")?.value?.trim() || null, + request_timeout_seconds: Number(document.getElementById("settings-llm-request-timeout")?.value || 120), + temperature: Number(document.getElementById("settings-llm-temperature")?.value || 0.7) + }, + generation: { + max_skills: Number(document.getElementById("settings-gen-max-skills")?.value || 20), + max_achievements: Number(document.getElementById("settings-gen-max-achievements")?.value || 5), + max_publications: Number(document.getElementById("settings-gen-max-publications")?.value || 10), + skills_section_title: document.getElementById("settings-gen-skills-title")?.value?.trim() || "Skills", + formats: { + ats_docx: Boolean(document.getElementById("settings-format-ats-docx")?.checked), + human_pdf: Boolean(document.getElementById("settings-format-human-pdf")?.checked), + human_docx: Boolean(document.getElementById("settings-format-human-docx")?.checked) + }, + ai_attribution: Boolean(document.getElementById("settings-gen-ai-attribution")?.checked) } - return; - } - if (keyStatus) { - keyStatus.textContent = "Saving\u2026"; - keyStatus.style.color = "#475569"; + }; + } + function _collectRetryPolicyFromForm() { + return { + baseMs: Number(document.getElementById("settings-retry-base-ms")?.value || 1500), + capMs: Number(document.getElementById("settings-retry-cap-ms")?.value || 6e4), + maxAttempts: Number(document.getElementById("settings-retry-max-attempts")?.value || 6), + autoRetry: Boolean(document.getElementById("settings-retry-auto")?.checked) + }; + } + function _renderSettingsToForm(payload) { + const settings = payload?.settings || {}; + const llm = settings.llm || {}; + const generation = settings.generation || {}; + const formats = generation.formats || {}; + const runtimeProvider = payload?.runtime?.llm?.provider || null; + const runtimeModel = payload?.runtime?.llm?.model || null; + const runtimeOverrides = {}; + if (runtimeProvider) runtimeOverrides["llm.default_provider"] = runtimeProvider; + if (runtimeModel) runtimeOverrides["llm.default_model"] = runtimeModel; + const setValue = (id, value) => { + const el = document.getElementById(id); + if (el) el.value = value ?? ""; + }; + const setChecked = (id, value) => { + const el = document.getElementById(id); + if (el) el.checked = Boolean(value); + }; + setValue("settings-llm-default-provider", runtimeProvider || llm.default_provider || ""); + setValue("settings-llm-default-model", runtimeModel || llm.default_model || ""); + setValue("settings-llm-request-timeout", llm.request_timeout_seconds ?? 120); + setValue("settings-llm-temperature", llm.temperature ?? 0.7); + setValue("settings-gen-max-skills", generation.max_skills ?? 20); + setValue("settings-gen-max-achievements", generation.max_achievements ?? 5); + setValue("settings-gen-max-publications", generation.max_publications ?? 10); + setValue("settings-gen-skills-title", generation.skills_section_title || "Skills"); + setChecked("settings-format-ats-docx", formats.ats_docx); + setChecked("settings-format-human-pdf", formats.human_pdf); + setChecked("settings-format-human-docx", formats.human_docx); + setChecked("settings-gen-ai-attribution", generation.ai_attribution); + const retryPolicy = _getRetryPolicyFromStorage(); + setValue("settings-retry-base-ms", retryPolicy.baseMs); + setValue("settings-retry-cap-ms", retryPolicy.capMs); + setValue("settings-retry-max-attempts", retryPolicy.maxAttempts); + setChecked("settings-retry-auto", retryPolicy.autoRetry); + _renderSettingsSources(payload.meta || {}, runtimeOverrides); + const pathEl = document.getElementById("settings-config-path"); + if (pathEl) { + pathEl.textContent = payload?.meta?.config_path ? `Config file: ${payload.meta.config_path}` : ""; } + } + async function reloadSettingsModal() { try { - await apiCall2("POST", "/api/settings/credentials", { provider, key_value: value }); - if (keyInput) keyInput.value = ""; - if (keyStatus) { - keyStatus.textContent = "Key saved successfully."; - keyStatus.style.color = "#065f46"; - } - await _renderAuthStep(provider); - } catch (e) { - if (keyStatus) { - keyStatus.textContent = `Error: ${e.message || e}`; - keyStatus.style.color = "#b91c1c"; - } + _setSettingsStatus("Loading settings...", "info"); + const [result, status] = await Promise.all([fetchSettings(), fetchStatus2().catch(() => null)]); + _renderSettingsToForm(result); + const attrEl = document.getElementById("settings-gen-ai-attribution"); + if (attrEl && status) attrEl.checked = Boolean(status.ai_attribution); + _setSettingsStatus("Settings loaded.", "success"); + } catch (error) { + _setSettingsStatus(`Failed to load settings: ${error.message || error}`, "error"); } } - function toggleApiKeyVisibility() { - const input = document.getElementById("model-auth-key-input"); - const toggle = document.getElementById("model-auth-key-toggle"); - if (!input) return; - const isHidden = input.type === "password"; - input.type = isHidden ? "text" : "password"; - if (toggle) toggle.textContent = isHidden ? "\u{1F648}" : "\u{1F441}"; - } - async function startCopilotAuthFromWizard() { - const statusEl = document.getElementById("model-auth-status"); - const codeEl = document.getElementById("model-auth-code"); - const linkEl = document.getElementById("model-auth-link"); - if (!statusEl || !codeEl || !linkEl) return; - _showModelWizardBusy("Starting GitHub device authorization..."); + async function saveSettingsModal() { + const saveBtn = document.getElementById("settings-save-btn"); try { - const flowRes = await fetch("/api/copilot-auth/start", { method: "POST" }); - if (!flowRes.ok) throw new Error(await flowRes.text()); - const flow = await flowRes.json(); - codeEl.textContent = flow.user_code || ""; - linkEl.href = flow.verification_uri || "https://github.com/login/device"; - linkEl.style.display = ""; - statusEl.textContent = "Open GitHub, enter the code, then return here."; - window.open(linkEl.href, "_blank"); - await fetch("/api/copilot-auth/poll", { method: "POST" }); - _updateLlmStatusPill2("polling", "Copilot auth pending", "\u29D7"); - if (_copilotAuthPollTimer) clearInterval(_copilotAuthPollTimer); - _copilotAuthPollTimer = setInterval(async () => { - try { - const st = await fetch("/api/copilot-auth/status").then((res) => res.json()); - if (st.authenticated) { - clearInterval(_copilotAuthPollTimer); - _copilotAuthPollTimer = null; - await _refreshCopilotAuthStatus(); - } - } catch { - } - }, 5e3); + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.textContent = "Saving..."; + } + _setSettingsStatus("Saving settings...", "info"); + const payload = _collectSettingsPayloadFromForm(); + const aiAttribution = Boolean(payload.generation?.ai_attribution); + const result = await updateSettings(payload); + _saveRetryPolicyToStorage(_collectRetryPolicyFromForm()); + _settingsData = result; + _renderSettingsToForm(result); + try { + await apiCall2("POST", "/api/generation-settings", { ai_attribution: aiAttribution }); + } catch (_2) { + } + _setSettingsStatus("Settings saved successfully.", "success"); } catch (error) { - statusEl.textContent = `Auth start failed: ${error.message || error}`; + _setSettingsStatus(`Failed to save settings: ${error.message || error}`, "error"); } finally { - _hideModelWizardBusy(); + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = "Save Settings"; + } } } - async function logoutCopilotAuthFromWizard() { - await fetch("/api/copilot-auth/logout", { method: "POST" }); - await _refreshCopilotAuthStatus(); + async function openSettingsModal() { + const overlay = document.getElementById("settings-modal-overlay"); + if (!overlay) return; + overlay.style.display = "flex"; + _focusStack.push(document.activeElement); + setInitialFocus2("settings-modal-overlay"); + trapFocus2("settings-modal-overlay"); + await reloadSettingsModal(); } - function _wireGlobalModelSearch() { - const input = document.getElementById("model-global-search"); - if (!input) return; - input.oninput = () => { - if (_modelDataTable && typeof _modelDataTable.search === "function") { - _modelDataTable.search(input.value || "").draw(); - } - }; + function closeSettingsModal() { + const overlay = document.getElementById("settings-modal-overlay"); + if (overlay) overlay.style.display = "none"; + restoreFocus2(); } - function _renderProviderSelector() { - const listEl = document.getElementById("model-provider-list"); - if (!listEl || !_modelData) return; - const providers = Array.isArray(_modelData.providers) ? _modelData.providers : Array.from(new Set((_modelData.all_models || []).map((r) => r.provider).filter(Boolean))).sort(); - const capableSet = new Set(_modelData.list_models_capable || []); - if (!_modelWizardSelectedProvider) { - _modelWizardSelectedProvider = _modelData.provider || providers[0] || null; - } - if (_modelWizardSelectedProvider) { - _selectedModelProviders = /* @__PURE__ */ new Set([_modelWizardSelectedProvider]); + function getFocusableElements(container) { + const focusableSelectors = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "textarea:not([disabled])", + "select:not([disabled])", + '[tabindex]:not([tabindex="-1"])' + ].join(", "); + return Array.from(container.querySelectorAll(focusableSelectors)); + } + function setInitialFocus2(modalId) { + const modal = document.getElementById(modalId); + if (!modal) return; + const focusTarget = modal.querySelector('[data-focus-target="true"]') || modal.querySelector('input[type="text"]') || modal.querySelector("button"); + if (focusTarget) { + setTimeout(() => focusTarget.focus(), 50); } - listEl.querySelectorAll('[data-bs-toggle="popover"]').forEach((el) => { - window.bootstrap?.Popover?.getInstance(el)?.dispose(); - }); - listEl.innerHTML = ""; - providers.forEach((provider) => { - const checked = provider === _modelWizardSelectedProvider; - const sourceLabel = _providerStageLabel(provider, capableSet); - const info = getProviderInfo(provider); - const label = document.createElement("label"); - label.style.cssText = "display:flex; align-items:center; gap:6px; padding:4px 8px; border:1px solid #cbd5e1; border-radius:999px; font-size:0.82em; background:#fff; cursor:pointer;"; - label.innerHTML = `${escapeHtml2(_providerDisplayLabel(provider))}(${escapeHtml2(sourceLabel)})`; - if (info) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.setAttribute("aria-label", "Provider info"); - btn.setAttribute("data-bs-toggle", "popover"); - btn.setAttribute("data-bs-trigger", "click"); - btn.setAttribute("data-bs-placement", "right"); - btn.setAttribute("data-bs-html", "true"); - btn.setAttribute("data-bs-container", "body"); - btn.setAttribute("data-bs-content", providerInfoPopoverContent(info)); - btn.style.cssText = "background:none;border:none;cursor:pointer;color:#64748b;font-size:0.9em;padding:0 1px;line-height:1;vertical-align:middle;"; - btn.textContent = "\u24D8"; - btn.addEventListener("click", (e) => e.stopPropagation()); - label.appendChild(btn); - requestAnimationFrame(() => { - if (window.bootstrap?.Popover) { - new window.bootstrap.Popover(btn, { sanitize: false }); - } - }); + } + function trapFocus2(modalId) { + const modal = document.getElementById(modalId); + if (!modal) return; + const focusableElements = getFocusableElements(modal); + if (focusableElements.length === 0) return; + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const listener = (e) => { + if (e.key !== "Tab") return; + const isShift = e.shiftKey; + const activeEl = document.activeElement; + if (isShift) { + if (activeEl === firstElement) { + e.preventDefault(); + lastElement.focus(); + } + } else { + if (activeEl === lastElement) { + e.preventDefault(); + firstElement.focus(); + } } - const checkbox = label.querySelector("input"); - checkbox.addEventListener("change", () => { - if (!checkbox.checked) return; - _modelWizardSelectedProvider = provider; - _selectedModelProviders = /* @__PURE__ */ new Set([provider]); - _updateModelWizardNav(); - }); - listEl.appendChild(label); - }); - _updateModelWizardNav(); + }; + document.addEventListener("keydown", listener); + _focusTrapStack.push(listener); } - function _showModelWizardBusy(message) { - const overlay = document.getElementById("model-wizard-busy-overlay"); - const messageEl = document.getElementById("model-wizard-busy-message"); - if (messageEl) messageEl.textContent = message || "Working..."; - if (overlay) overlay.style.display = "flex"; + function restoreFocus2() { + const el = _focusStack.pop(); + if (el && typeof el.focus === "function") el.focus(); + const listener = _focusTrapStack.pop(); + if (listener) document.removeEventListener("keydown", listener); } - function _hideModelWizardBusy() { - const overlay = document.getElementById("model-wizard-busy-overlay"); - if (overlay) overlay.style.display = "none"; + function pushFocusStack2(el) { + _focusStack.push(el || document.activeElement); } - function _setModelWizardStep(step) { - _modelWizardStep = [1, 2, 3, 4].includes(step) ? step : 1; - const panes = { - 1: document.getElementById("model-step-provider"), - 2: document.getElementById("model-step-auth"), - 3: document.getElementById("model-step-models"), - 4: document.getElementById("model-step-success") - }; - Object.entries(panes).forEach(([num, el]) => { - if (el) el.style.display = Number(num) === _modelWizardStep ? "" : "none"; + var STAGE_TABS = { + job: ["job", "master"], + analysis: ["analysis"], + customizations: ["goals", "questions", "exp-review", "ach-editor", "skills-review", "achievements-review", "tagline-review", "summary-review", "publications-review", "ats-score"], + rewrite: ["rewrite"], + spell: ["spell"], + layout: ["layout"], + download: ["final_generate", "download"], + cover_letter: ["cover-letter"], + screening: ["screening"], + interview_prep: ["interview-prep"], + thank_you: ["thank-you"], + harvest: ["harvest"] + }; + function confirmDialog2(message, { confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) { + return new Promise((resolve) => { + const previousFocus = document.activeElement; + let overlay = document.getElementById("confirm-dialog-overlay"); + if (!overlay) { + overlay = document.createElement("div"); + overlay.id = "confirm-dialog-overlay"; + overlay.style.cssText = "display:none; position:fixed; inset:0; background:rgba(0,0,0,0.45); z-index:9999;align-items:center; justify-content:center;"; + overlay.innerHTML = ''; + document.body.appendChild(overlay); + } + const okBtn = document.getElementById("confirm-dialog-ok"); + const cancelBtn = document.getElementById("confirm-dialog-cancel"); + const msgEl = document.getElementById("confirm-dialog-msg"); + msgEl.textContent = message; + okBtn.textContent = confirmLabel; + cancelBtn.textContent = cancelLabel; + okBtn.style.background = danger ? "#dc2626" : "#3b82f6"; + overlay.style.display = "flex"; + okBtn.focus(); + const trapFocus3 = (e) => { + if (e.key !== "Tab") return; + e.preventDefault(); + const focused = document.activeElement; + if (e.shiftKey) { + (focused === cancelBtn ? okBtn : cancelBtn).focus(); + } else { + (focused === okBtn ? cancelBtn : okBtn).focus(); + } + }; + overlay.addEventListener("keydown", trapFocus3); + const finish = (result) => { + overlay.style.display = "none"; + overlay.removeEventListener("keydown", trapFocus3); + okBtn.replaceWith(okBtn.cloneNode(true)); + cancelBtn.replaceWith(cancelBtn.cloneNode(true)); + if (previousFocus && typeof previousFocus.focus === "function") { + previousFocus.focus(); + } + resolve(result); + }; + overlay.addEventListener("keydown", (e) => { + if (e.key === "Escape") finish(false); + }, { once: true }); + document.getElementById("confirm-dialog-ok").addEventListener("click", () => finish(true), { once: true }); + document.getElementById("confirm-dialog-cancel").addEventListener("click", () => finish(false), { once: true }); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) finish(false); + }, { once: true }); }); - if (_modelWizardStep === 2) { - _renderAuthStep(_modelWizardSelectedProvider); - } - _updateModelWizardNav(); } - function _updateModelWizardProgressBar() { - [1, 2, 3, 4].forEach((stepNum) => { - const stepEl = document.getElementById(`model-progress-step-${stepNum}`); - if (!stepEl) return; - const badgeEl = stepEl.querySelector(".model-progress-badge"); - stepEl.classList.remove("is-active", "is-complete", "is-upcoming"); - if (stepNum < _modelWizardStep) { - stepEl.classList.add("is-complete"); - stepEl.setAttribute("aria-current", "false"); - if (badgeEl) badgeEl.textContent = "\u2713"; - } else if (stepNum === _modelWizardStep) { - stepEl.classList.add("is-active"); - stepEl.setAttribute("aria-current", "step"); - if (badgeEl) badgeEl.textContent = String(stepNum); - } else { - stepEl.classList.add("is-upcoming"); - stepEl.setAttribute("aria-current", "false"); - if (badgeEl) badgeEl.textContent = String(stepNum); + function setupEventListeners() { + document.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", (e) => { + const tabName = e.target.id.replace("tab-", ""); + switchTab(tabName); + }); + tab.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.target.click(); + return; + } + if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) { + e.preventDefault(); + const tabs = Array.from(document.querySelectorAll('.tab:not([style*="display: none"])')); + const currentIndex = tabs.indexOf(e.target); + let nextTab; + if (e.key === "ArrowLeft" || e.key === "Home") { + nextTab = e.key === "Home" ? tabs[0] : tabs[(currentIndex - 1 + tabs.length) % tabs.length]; + } else { + nextTab = e.key === "End" ? tabs[tabs.length - 1] : tabs[(currentIndex + 1) % tabs.length]; + } + if (nextTab) { + nextTab.focus(); + nextTab.click(); + } + } + }); + }); + const messageInput = document.getElementById("message-input"); + if (messageInput) { + messageInput.addEventListener("keypress", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (typeof globalThis.sendMessage === "function") { + globalThis.sendMessage(); + } + } + }); + } + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + closeAllModals(); } }); + document.querySelectorAll('[role="dialog"]').forEach((modal) => { + modal.addEventListener("click", (e) => { + if (e.target === modal) { + closeModal(modal.id); + } + }); + }); } - function _updateModelWizardNav() { - const stepLabel = document.getElementById("model-wizard-step-label"); - const backBtn = document.getElementById("model-wizard-back-btn"); - const nextBtn = document.getElementById("model-wizard-next-btn"); - const testBtn = document.getElementById("model-test-btn"); - const refreshBtn = document.getElementById("pricing-refresh-btn"); - const footer = document.querySelector(".model-wizard-footer"); - _updateModelWizardProgressBar(); - if (stepLabel) { - const labels = { - 1: "Step 1 of 4: Choose provider", - 2: "Step 2 of 4: Set up authentication", - 3: "Step 3 of 4: Choose model and test connection", - 4: "Step 4 of 4: Complete" - }; - stepLabel.textContent = labels[_modelWizardStep] || ""; - } - if (backBtn) { - backBtn.disabled = _modelWizardStep === 1; - backBtn.style.display = _modelWizardStep === 4 ? "none" : ""; - } - if (nextBtn) { - nextBtn.style.display = _modelWizardStep === 1 || _modelWizardStep === 2 ? "" : "none"; - nextBtn.disabled = _modelWizardStep === 1 && !_modelWizardSelectedProvider; - nextBtn.textContent = _modelWizardStep === 2 ? "Next" : "Next"; + function getStageForTab2(tab) { + for (const [stage, tabs] of Object.entries(STAGE_TABS)) { + if (tabs.includes(tab)) return stage; } - if (testBtn) testBtn.style.display = _modelWizardStep === 3 ? "" : "none"; - if (refreshBtn) refreshBtn.style.display = _modelWizardStep === 3 ? "" : "none"; - if (footer) footer.style.justifyContent = _modelWizardStep === 4 ? "center" : "space-between"; + return null; } - async function _loadModelsForSelectedProvider() { - if (!_modelWizardSelectedProvider) return false; - const loadingEl = document.getElementById("model-models-loading"); - if (loadingEl) { - loadingEl.style.display = ""; - loadingEl.style.color = "#475569"; - loadingEl.innerHTML = 'Loading models for selected provider...'; - } - _showModelWizardBusy("Querying available models..."); - try { - _selectedModelProviders = /* @__PURE__ */ new Set([_modelWizardSelectedProvider]); - _modelData.provider = _modelWizardSelectedProvider; - await _refreshModelCatalogForSelection(); - if (loadingEl) { - loadingEl.style.display = "none"; - loadingEl.textContent = ""; - } - return true; - } catch (error) { - log3.warn("Could not load models for selected provider:", error); - if (loadingEl) { - loadingEl.style.display = ""; - loadingEl.textContent = "Failed to load models for selected provider."; - loadingEl.style.color = "#b91c1c"; - } - return false; - } finally { - _hideModelWizardBusy(); - } + function getVisibleStage() { + const activeTab = stateManager2.getCurrentTab(); + return getStageForTab2(activeTab) || getWorkflowStepForPhase(stateManager2.getPhase()); } - async function nextWizardStep() { - if (_modelWizardStep === 1) { - if (!_modelWizardSelectedProvider) return; - _setModelWizardStep(2); - return; - } - if (_modelWizardStep === 2) { - const loaded = await _loadModelsForSelectedProvider(); - if (!loaded) return; - _setModelWizardStep(3); - } + function updateTabScrollButtons() { + const tabBar = document.getElementById("tab-bar"); + const leftBtn = document.getElementById("tab-scroll-left"); + const rightBtn = document.getElementById("tab-scroll-right"); + if (!tabBar || !leftBtn || !rightBtn) return; + leftBtn.style.display = tabBar.scrollLeft > 0 ? "" : "none"; + rightBtn.style.display = tabBar.scrollLeft < tabBar.scrollWidth - tabBar.clientWidth - 1 ? "" : "none"; } - function previousWizardStep() { - if (_modelWizardStep === 2) { - _setModelWizardStep(1); - return; - } - if (_modelWizardStep === 3) { - _setModelWizardStep(2); - return; - } - if (_modelWizardStep === 4) { - _setModelWizardStep(3); + function updateTabBarForStage2(stage) { + const stageTabs = STAGE_TABS[stage] || []; + document.querySelectorAll(".tab").forEach((tab) => { + tab.style.display = stageTabs.includes(tab.dataset.tab) ? "" : "none"; + }); + const tabBar = document.getElementById("tab-bar"); + if (tabBar) tabBar.scrollLeft = 0; + updateTabScrollButtons(); + const labelEl = document.getElementById("tab-stage-label"); + if (labelEl) { + const stepLabel = _STEP_DISPLAY[stage] || stage; + labelEl.textContent = stepLabel ? `Now viewing: ${stepLabel}` : ""; } } - async function _refreshModelCatalogForSelection() { - if (!_modelData) return; - if (_catalogRefreshing) { - _catalogRefreshPending = true; - return; - } - _catalogRefreshing = true; - try { - const selected = Array.from(_selectedModelProviders); - if (!selected.length) { - _selectedModelProviders = /* @__PURE__ */ new Set([_modelData.provider]); + function toggleChat() { + const interactionArea = document.querySelector(".interaction-area"); + const viewerArea = document.querySelector(".viewer-area"); + if (interactionArea) { + const isCollapsed = interactionArea.classList.toggle("collapsed"); + if (viewerArea) { + viewerArea.style.flex = isCollapsed ? "1 1 100%" : "0 1 60%"; } - const providersParam = encodeURIComponent(Array.from(_selectedModelProviders).join(",")); - const catalog = await apiCall2("GET", `/api/model-catalog?providers=${providersParam}`); - _modelData.all_models = catalog.all_models || []; - _modelData.pricing_updated_at = catalog.pricing_updated_at || _modelData.pricing_updated_at; - _modelData.pricing_source = catalog.pricing_source || _modelData.pricing_source; - if (Array.isArray(catalog.providers) && catalog.providers.length) { - _modelData.providers = catalog.providers; + const toggleBtn = document.getElementById("toggle-chat"); + if (toggleBtn) { + toggleBtn.setAttribute("aria-expanded", String(!isCollapsed)); + toggleBtn.setAttribute("aria-label", isCollapsed ? "Expand chat panel" : "Collapse chat panel"); } - if (Array.isArray(catalog.list_models_capable)) { - _modelData.list_models_capable = catalog.list_models_capable; + try { + localStorage.setItem(StorageKeys2.CHAT_COLLAPSED, isCollapsed); + } catch (e) { + log4.warn("Could not save chat state"); } - } catch (error) { - log3.warn("Could not refresh model catalog for selected providers:", error); - } finally { - _catalogRefreshing = false; - } - _renderProviderSelector(); - _buildModelTable(); - if (_catalogRefreshPending) { - _catalogRefreshPending = false; - await _refreshModelCatalogForSelection(); } } - async function openModelModal() { - const overlay = document.getElementById("model-modal-overlay"); - if (!overlay) return; - if (!_modelData) { - await loadModelSelector(); - } - await loadProviderInfo(); - if (!_modelWizardSelectedProvider) { - _modelWizardSelectedProvider = _modelData?.provider || null; - } - const successSummary = document.getElementById("model-success-summary"); - if (successSummary) { - successSummary.textContent = "Provider and model are configured and the connection check succeeded."; + function openModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + _focusStack.push(document.activeElement); + modal.classList.add("visible"); + modal.setAttribute("aria-hidden", "false"); + document.body.style.overflow = "hidden"; + setInitialFocus2(modalId); + trapFocus2(modalId); } - _renderProviderSelector(); - _syncCatalogVisibility(); - _wireGlobalModelSearch(); - _setModelWizardStep(1); - _hideModelWizardBusy(); - overlay.style.display = "flex"; - _focusStack.push(document.activeElement); - setInitialFocus2("model-modal-overlay"); - trapFocus2("model-modal-overlay"); } - function closeModelModal2() { - const overlay = document.getElementById("model-modal-overlay"); - if (overlay) overlay.style.display = "none"; - _setModelWizardStep(1); - _hideModelWizardBusy(); - if (_copilotAuthPollTimer) { - clearInterval(_copilotAuthPollTimer); - _copilotAuthPollTimer = null; + function closeModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + if (modal.dataset.dismissDisabled === "1") return; + modal.classList.remove("visible"); + modal.setAttribute("aria-hidden", "true"); + if (!document.querySelector('[role="dialog"].visible')) { + document.body.style.overflow = ""; + } + restoreFocus2(); } + } + function closeAllModals() { + document.querySelectorAll('[role="dialog"]').forEach((modal) => { + if (modal.dataset.dismissDisabled === "1") return; + modal.classList.remove("visible"); + modal.setAttribute("aria-hidden", "true"); + if (modal.style.display && modal.style.display !== "none") { + modal.style.display = "none"; + } + }); + document.body.style.overflow = ""; restoreFocus2(); } - function _applyModelRowVisualState(tr, isActive) { - tr.classList.toggle("model-row-active", isActive); - tr.style.cssText = isActive ? "background:#eff6ff; font-weight:600; cursor:pointer;" : "cursor:pointer;"; - const model = tr.getAttribute("data-model") || ""; - const modelCell = tr.cells && tr.cells[1]; - if (modelCell) { - modelCell.innerHTML = `${escapeHtml2(model)}` + (isActive ? ' ✓ active' : ""); + var _modelData = null; + var _modelDataTable = null; + var _selectedModelProviders = /* @__PURE__ */ new Set(); + var _modelSelectorLoading = false; + var _catalogRefreshing = false; + var _catalogRefreshPending = false; + var _showFullModelCatalog = false; + var _copilotAuthPollTimer = null; + var _modelWizardStep = 1; + var _modelWizardSelectedProvider = null; + function _getModelPrefsFromStorage() { + try { + const saved = localStorage.getItem(StorageKeys2.TAB_DATA); + return saved ? JSON.parse(saved) || {} : {}; + } catch { + return {}; } } - function _syncModelTableSelection() { - const tbody = document.getElementById("model-table-body"); - if (!tbody || !_modelData) return; - const activeProvider = _modelData.provider; - const activeModel = _modelData.model; - tbody.querySelectorAll("tr").forEach((tr) => { - const isActive = tr.getAttribute("data-provider") === activeProvider && tr.getAttribute("data-model") === activeModel; - _applyModelRowVisualState(tr, isActive); - }); - } - function _buildModelTable() { - const tbody = document.getElementById("model-table-body"); - const thead = document.getElementById("model-table-head"); - if (!tbody || !_modelData) return; - const currentProvider = _modelData.provider; - const currentModel = _modelData.model; - if (thead) { - const thS = "padding:10px 14px; white-space:nowrap;"; - thead.innerHTML = `ProviderModelContext$/1M in$/1M outCopilotSourceNotes`; + function _saveModelPrefsToStorage(patch) { + try { + const parsed = _getModelPrefsFromStorage(); + localStorage.setItem(StorageKeys2.TAB_DATA, JSON.stringify({ ...parsed, ...patch })); + } catch (e) { + log4.warn("Failed to persist model preferences locally:", e); } - if (window.$ && $.fn && $.fn.DataTable && $.fn.DataTable.isDataTable("#model-table")) { - try { - _modelDataTable = $("#model-table").DataTable(); - _modelDataTable.destroy(); - } catch (e) { - log3.warn("DataTable.destroy() failed (table may already be torn down):", e); - } finally { - _modelDataTable = null; - } - } - let rows = _modelData.all_models && _modelData.all_models.length ? _modelData.all_models.filter((r) => r.model) : (_modelData.available || []).map( - (r) => typeof r === "object" ? { ...r, provider: currentProvider } : { model: r, provider: currentProvider } - ); - _renderQuickModelList(rows); - tbody.innerHTML = ""; - const tdBase = "padding:9px 14px; border-bottom:1px solid #e2e8f0;"; - const fmtCost = (v2) => v2 != null ? "$" + Number(v2).toFixed(v2 < 1 ? 3 : 2) : "\u2014"; - const fmtPriceHint = (source) => { - if (source === "runtime_cache") { - return 'cache'; - } - return 'static'; - }; - const fmtSource = (s) => { - if (s === "list_models") { - return 'list_models'; - } - return 'fallback_static'; + } + function _appendRecentModel(provider, model) { + if (!provider || !model) return; + const parsed = _getModelPrefsFromStorage(); + const existing = Array.isArray(parsed.recentModels) ? parsed.recentModels : []; + const filtered = existing.filter((item) => !(item.provider === provider && item.model === model)); + filtered.unshift({ provider, model }); + _saveModelPrefsToStorage({ recentModels: filtered.slice(0, 6) }); + } + function _updateLlmStatusPill2(kind, text2, icon = "", tooltip = "") { + const pill = document.getElementById("llm-status-pill"); + const label = document.getElementById("llm-status-label"); + const iconEl = document.getElementById("llm-status-icon"); + if (!pill || !label) return; + const aliases = { + authenticated: "connected", + unauthenticated: "unconfigured", + polling: "connecting" }; - const fmtMult = (v2) => { - if (v2 == null) return "\u2014"; - if (v2 === 0) return 'free'; - return Number(v2).toFixed(v2 % 1 === 0 ? 0 : 2) + "×"; + const normalizedKind = aliases[kind] || kind || "unconfigured"; + const defaultTooltip = { + unconfigured: "No provider/model is configured yet.", + configured: "Provider/model is configured. Connectivity not yet verified.", + connecting: "Testing or connecting to the selected provider.", + connected: "Provider responded successfully to a live request.", + "auth-required": "Authentication is required. Check API key or sign in.", + "rate-limited": "Rate limit reached. Wait before retrying requests.", + unavailable: "Provider is temporarily unavailable or unreachable.", + error: "Connection failed. Open model settings for details." }; - rows.forEach((item) => { - const provider = item.provider || currentProvider; - const m2 = item.model; - const ctx = item.context_window ? Number(item.context_window).toLocaleString() : "\u2014"; - const source = item.source || "fallback_static"; - const priceSource = item.price_source || "static_baseline"; - const notes = item.notes || ""; - const isSelected = provider === currentProvider && m2 === currentModel; - const tr = document.createElement("tr"); - tr.setAttribute("data-provider", provider); - tr.setAttribute("data-model", m2); - _applyModelRowVisualState(tr, isSelected); - tr.addEventListener("mouseover", () => { - if (!tr.classList.contains("model-row-active")) tr.style.background = "#f8fafc"; - }); - tr.addEventListener("mouseout", () => { - if (!tr.classList.contains("model-row-active")) tr.style.background = ""; - }); - tr.innerHTML = `${escapeHtml2(_providerDisplayLabel(provider))}${escapeHtml2(m2)}${ctx}${fmtCost(item.cost_input)}${fmtPriceHint(priceSource)}${fmtCost(item.cost_output)}${fmtPriceHint(priceSource)}${fmtMult(item.copilot_multiplier)}${fmtSource(source)}${escapeHtml2(notes)}`; - tbody.appendChild(tr); - }); - tbody.onclick = async (event2) => { - const tr = event2.target.closest("tr"); - if (!tr) return; - const provider = tr.getAttribute("data-provider"); - const model = tr.getAttribute("data-model"); - if (!model) return; - tr.style.cssText = "background:#fef3c7; cursor:wait; opacity:0.85;"; - const modelCell = tr.cells && tr.cells[1]; - if (modelCell) { - modelCell.innerHTML = `${escapeHtml2(model)}`; - } - const status = document.getElementById("model-test-status"); - if (status) { - status.style.display = ""; - status.style.color = "#92400e"; - status.innerHTML = ` Switching to ${escapeHtml2(model)}\u2026`; - } - await setModel(model, provider); + pill.classList.remove("authenticated", "unauthenticated", "polling", "unconfigured", "configured", "connecting", "connected", "auth-required", "rate-limited", "unavailable", "error"); + if (normalizedKind) pill.classList.add(normalizedKind); + label.textContent = text2; + const iconByKind = { + unconfigured: "\u25CB", + configured: "\u25D4", + connecting: "\u29D7", + connected: "\u2713", + "auth-required": "\u{1F511}", + "rate-limited": "\u23F3", + unavailable: "\u2601", + error: "\u26A0" }; - if (window.$ && $.fn && $.fn.DataTable) { - _modelDataTable = $("#model-table").DataTable({ - paging: false, - searching: true, - info: false, - orderCellsTop: true, - order: [[0, "asc"], [1, "asc"]], - autoWidth: false, - language: { search: "Filter:" }, - initComplete: function() { - const api = this.api(); - const $thead = $("#model-table thead"); - const hasFilterRow = $thead.find("tr.model-filter-row").length > 0; - if (!hasFilterRow) { - const $filterRow = $(''); - api.columns().every(function(colIdx) { - const title = $(api.column(colIdx).header()).text().trim(); - const $th = $(''); - const $input = $("", { - type: "text", - placeholder: title, - style: "width:100%; padding:4px 6px; border:1px solid #cbd5e1; border-radius:4px; font-size:0.82em;" - }); - $th.append($input); - $filterRow.append($th); - }); - $thead.append($filterRow); - } - api.columns().every(function(colIdx) { - const $input = $("#model-table thead tr.model-filter-row th").eq(colIdx).find("input"); - if (!$input.length) return; - $input.off("click.modelFilter keyup.modelFilter change.modelFilter"); - $input.on("click.modelFilter", function(event2) { - event2.stopPropagation(); - }); - $input.on("keyup.modelFilter change.modelFilter", function() { - const value = this.value; - if (api.column(colIdx).search() !== value) { - api.column(colIdx).search(value).draw(); - } - }); - }); - } - }); - } - _syncModelTableSelection(); - _updatePricingFooter(); + const iconText = icon || iconByKind[normalizedKind] || "\u26A0"; + if (iconEl) iconEl.textContent = iconText; + const tooltipText = tooltip || defaultTooltip[normalizedKind] || ""; + pill.title = tooltipText; + if (iconEl) iconEl.title = tooltipText; + label.title = tooltipText; } - async function setModel(model, provider) { - if (!model) return; - _showModelWizardBusy(`Applying model ${model}...`); + async function loadModelSelector() { + if (_modelSelectorLoading) return; + _modelSelectorLoading = true; try { - const payload = provider ? { model, provider } : { model }; - await apiCall2("POST", "/api/model", payload); - if (_modelData) { - _modelData.model = model; - if (provider) _modelData.provider = provider; + _modelData = await apiCall2("GET", "/api/model"); + try { + const saved = localStorage.getItem(StorageKeys2.TAB_DATA); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed && !(_modelData && _modelData.provider) && parsed.currentModelProvider) { + _modelData = _modelData || {}; + _modelData.provider = parsed.currentModelProvider; + } + if (parsed && !(_modelData && _modelData.model) && parsed.currentModelName) { + _modelData = _modelData || {}; + _modelData.model = parsed.currentModelName; + } + } + } catch (e) { + log4.warn("Could not read saved model from localStorage:", e); } - const effectiveProvider = _modelData && _modelData.provider || provider; const label = document.getElementById("model-current-label"); if (label) { - label.textContent = effectiveProvider ? `${effectiveProvider} \xB7 ${model}` : model; + const prov = _modelData.provider; + const model = _modelData.model || "\u2014"; + label.textContent = prov ? `${prov} \xB7 ${model}` : model; } - _appendRecentModel(effectiveProvider, model); - _syncModelTableSelection(); - _renderQuickModelList(_modelData && _modelData.all_models || []); - _updateLlmStatusPill2("configured", `${effectiveProvider || "Provider"} configured`); - _saveModelPrefsToStorage({ - currentModelProvider: effectiveProvider || null, - currentModelName: model || null - }); - if (typeof globalThis.updateAuthBadge === "function") { - globalThis.updateAuthBadge({}, effectiveProvider || null); + if (_modelData && _modelData.provider) { + _selectedModelProviders = /* @__PURE__ */ new Set([_modelData.provider]); + _updateLlmStatusPill2("configured", `${_modelData.provider} configured`); + } else { + _updateLlmStatusPill2("unconfigured", "Not configured"); } - await _refreshCopilotAuthStatus(); - await testCurrentModel(); } catch (e) { - log3.error("Failed to switch model:", e); - const msg = e.message || String(e); - _syncModelTableSelection(); - const status = document.getElementById("model-test-status"); - if (status) { - status.style.display = ""; - status.style.color = "#dc2626"; - status.textContent = `\u274C ${msg}`; - } - if (typeof appendMessage === "function") { - appendMessage("system", `\u274C Model switch failed: ${msg}`); - } + log4.warn("Could not load model list:", e); } finally { - _hideModelWizardBusy(); + _modelSelectorLoading = false; } } - async function testCurrentModel() { - const badge = document.getElementById("model-test-badge"); - const status = document.getElementById("model-test-status"); - const btn = document.getElementById("model-test-btn"); - const SPIN = "\u23F3"; - const OK = "\u2705"; - const FAIL = "\u274C"; - const setRunning = () => { - if (badge) { - badge.textContent = SPIN; - badge.style.display = ""; - badge.title = "Testing\u2026"; - } - if (status) { - status.innerHTML = `${SPIN} Testing connection\u2026`; - status.style.display = ""; - } - if (btn) { - btn.disabled = true; - btn.textContent = "\u23F3 Testing\u2026"; - } - _updateLlmStatusPill2("connecting", "Connecting\u2026", "\u29D7"); + function _providerStageLabel(provider, capableSet) { + const isCapable = capableSet.has(provider); + return isCapable ? "list_models" : "fallback"; + } + function _providerDisplayLabel(provider) { + const deprecated = { + "copilot-oauth": "Copilot OAuth (deprecated)", + copilot: "Copilot (deprecated)", + github: "GitHub Models (deprecated)", + "copilot-sdk": "Copilot SDK (recommended)" }; - const setOk = (latencyMs) => { - const tip = `Connected \u2014 ${latencyMs}ms`; - if (badge) { - badge.textContent = OK; - badge.style.display = ""; - badge.title = tip; - } - if (status) { - status.innerHTML = `${OK} ${tip}`; - status.style.color = "#16a34a"; - status.style.display = ""; - } - if (btn) { - btn.disabled = false; - btn.innerHTML = "✓ Test connection"; - } - _updateLlmStatusPill2("connected", `Healthy (${latencyMs}ms)`, "\u2713"); - const successSummary = document.getElementById("model-success-summary"); - if (successSummary) { - successSummary.textContent = `Connection verified in ${latencyMs}ms. You can close the wizard and continue.`; - } - _setModelWizardStep(4); - }; - const setFail = (errMsg) => { - if (badge) { - badge.textContent = FAIL; - badge.style.display = ""; - badge.title = errMsg; + if (deprecated[provider]) return deprecated[provider]; + return provider; + } + function _getQuickModelCandidates(rows) { + const parsed = _getModelPrefsFromStorage(); + const recents = Array.isArray(parsed.recentModels) ? parsed.recentModels : []; + const ranked = []; + for (const recent of recents) { + const hit = rows.find((row) => row.provider === recent.provider && row.model === recent.model); + if (hit) ranked.push(hit); + } + const popular = [...rows].sort((a, b2) => { + const am = Number(a.copilot_multiplier ?? 999); + const bm = Number(b2.copilot_multiplier ?? 999); + if (am !== bm) return am - bm; + const ac = Number(a.context_window || 0); + const bc = Number(b2.context_window || 0); + return bc - ac; + }); + for (const item of popular) { + if (!ranked.some((r) => r.provider === item.provider && r.model === item.model)) { + ranked.push(item); } - if (status) { - status.innerHTML = `${FAIL} Connection failed`; - status.style.color = "#dc2626"; - status.style.display = ""; + } + return ranked.slice(0, 8); + } + function _renderQuickModelList(rows) { + const quickEl = document.getElementById("model-quick-list"); + if (!quickEl) return; + quickEl.innerHTML = ""; + const candidates = _getQuickModelCandidates(rows || []); + for (const item of candidates) { + const isActive = item.provider === _modelData?.provider && item.model === _modelData?.model; + const button = document.createElement("button"); + button.className = "header-pill-btn"; + button.style.padding = "5px 10px"; + button.style.fontSize = "0.82em"; + button.style.background = isActive ? "#dbeafe" : "#f8fafc"; + button.style.color = isActive ? "#1e3a8a" : "#334155"; + button.style.borderColor = isActive ? "#93c5fd" : "#cbd5e1"; + button.title = `${item.provider} \xB7 ${item.model}`; + button.textContent = `${item.provider} \xB7 ${item.model}`; + button.addEventListener("click", async () => { + await setModel(item.model, item.provider); + }); + quickEl.appendChild(button); + } + } + function _syncCatalogVisibility() { + const wrap = document.getElementById("model-full-table-wrap"); + const search = document.getElementById("model-global-search"); + const toggle = document.getElementById("model-show-all-btn"); + if (wrap) wrap.style.display = _showFullModelCatalog ? "" : "none"; + if (search) search.style.display = _showFullModelCatalog ? "" : "none"; + if (toggle) toggle.textContent = _showFullModelCatalog ? "Hide Full Catalog" : "Show Full Catalog"; + } + function toggleModelCatalogVisibility() { + _showFullModelCatalog = !_showFullModelCatalog; + _syncCatalogVisibility(); + } + async function _renderAuthStep(provider) { + const content = document.getElementById("model-auth-step-content"); + const keyPanel = document.getElementById("model-auth-key-panel"); + const devPanel = document.getElementById("model-auth-device-panel"); + const keyInput = document.getElementById("model-auth-key-input"); + const keyStatus = document.getElementById("model-auth-key-status"); + if (!content) return; + if (keyPanel) keyPanel.style.display = "none"; + if (devPanel) devPanel.style.display = "none"; + if (keyInput) keyInput.value = ""; + if (keyStatus) keyStatus.textContent = ""; + if (!provider) { + content.innerHTML = '

    Select a provider in Step 1 first.

    '; + return; + } + let credData = null; + try { + const resp = await fetch("/api/settings/credentials/status"); + if (resp.ok) { + const data = await resp.json(); + credData = (data.providers || {})[provider] || null; } - if (btn) { - btn.disabled = false; - btn.innerHTML = "✓ Test connection"; + } catch { + } + const authType = credData?.auth_type || "api_key"; + const label = credData?.label || provider; + const helpText = credData?.help_text || ""; + const getKeyUrl = credData?.get_key_url || ""; + const isSet = credData?.is_set || false; + const source = credData?.source || "unset"; + const envVar = credData?.env_var || null; + const isLocked = credData?.locked || false; + const isSetBadge = isSet ? '✓ Key saved' : 'Not configured'; + let sourceLabel = ""; + let tooltipText = ""; + if (source === "env") { + sourceLabel = `Source: environment variable (${envVar || "locked"})`; + tooltipText = `Currently loaded from the ${envVar} environment variable. To change it permanently, update your shell config (e.g. ~/.zshrc) and restart the server. You can still save a value below \u2014 it takes effect immediately but will be overridden by the environment variable on next restart.`; + } else if (source === "dotenv") { + sourceLabel = `Source: .env file (${envVar || "locked"})`; + tooltipText = `Currently loaded from ${envVar} in your .env file. To change it permanently, edit the .env file in the project directory and restart the server. You can still save a value below \u2014 it takes effect immediately but will be overridden by .env on next restart.`; + } else if (source === "config") { + sourceLabel = "Source: config.yaml"; + tooltipText = "Stored in config.yaml. Update it using the input below."; + } + const infoIcon = tooltipText ? ` ` : ""; + const sourceLabelHtml = sourceLabel ? `${escapeHtml2(sourceLabel)}${infoIcon}` : ""; + const sourceDesc = source === "env" ? "environment variable" : ".env file"; + const sourceRef = envVar ? `${sourceDesc} ${escapeHtml2(envVar)}` : sourceDesc; + const lockedWarnHtml = isLocked ? `
    \u26A0 This key is loaded from the ${sourceRef}. Any value saved below takes effect immediately for the current session, but will be overridden by the ${sourceDesc} when the server restarts or reloads.
    ` : ""; + const getKeyLink = getKeyUrl ? `Get your key ↗` : ""; + content.innerHTML = `
    ${escapeHtml2(label)} ${isSetBadge}${sourceLabelHtml}` + (getKeyLink ? ` ${getKeyLink}` : "") + `
    ` + (helpText ? `

    ${escapeHtml2(helpText)}

    ` : "") + lockedWarnHtml; + if (authType === "api_key") { + if (keyPanel) { + keyPanel.style.display = ""; + if (keyInput) { + keyInput.disabled = false; + keyInput.style.opacity = ""; + keyInput.placeholder = isSet ? "Enter new key to replace the saved one" : "Paste your API key here"; + keyInput.dataset.provider = provider; + } } - _updateLlmStatusPill2("error", "Connection failed", "\u26A0", errMsg); - if (_modelWizardStep === 3) { - _setModelWizardStep(2); + } else if (authType === "device_flow") { + if (devPanel) { + devPanel.style.display = ""; + await _refreshCopilotAuthStatus(); } - }; - setRunning(); - _showModelWizardBusy("Testing model connection..."); + } else if (authType === "cli") { + content.innerHTML += `

    Run this command in a terminal, then return here and click Next:

    gh auth login
    `; + } else { + content.innerHTML += `
    No authentication required for this provider.
    `; + } + } + async function _refreshCopilotAuthStatus() { + const statusEl = document.getElementById("model-auth-status"); + const startBtn = document.getElementById("model-auth-start-btn"); + const logoutBtn = document.getElementById("model-auth-logout-btn"); + const codeEl = document.getElementById("model-auth-code"); + const linkEl = document.getElementById("model-auth-link"); + if (!statusEl) return; + if (codeEl) codeEl.textContent = ""; + if (linkEl) linkEl.style.display = "none"; try { - const result = await apiCall2("POST", "/api/model/test"); - if (result.ok) { - setOk(result.latency_ms); - return true; + const st = await fetch("/api/copilot-auth/status").then((res) => res.json()); + if (st.authenticated) { + statusEl.textContent = "Authenticated with GitHub Copilot."; + if (startBtn) startBtn.style.display = "none"; + if (logoutBtn) logoutBtn.style.display = ""; + _updateLlmStatusPill2("authenticated", "Copilot ready"); + } else if (st.polling) { + statusEl.textContent = "Waiting for device approval\u2026"; + if (startBtn) startBtn.style.display = ""; + if (logoutBtn) logoutBtn.style.display = "none"; + _updateLlmStatusPill2("polling", "Copilot auth pending", "\u29D7"); + } else { + statusEl.textContent = "Not authenticated. Start sign-in to enable Copilot OAuth."; + if (startBtn) startBtn.style.display = ""; + if (logoutBtn) logoutBtn.style.display = "none"; + _updateLlmStatusPill2("unauthenticated", "Copilot not authenticated"); } - setFail(result.error || "Unknown error"); - return false; - } catch (e) { - setFail(e.message || String(e)); - return false; - } finally { - _hideModelWizardBusy(); + } catch { + statusEl.textContent = "Unable to load auth status."; } } - function _updatePricingFooter() { - const el = document.getElementById("pricing-updated-label"); - if (!el || !_modelData) return; - const ts = _modelData.pricing_updated_at; - const source = _modelData.pricing_source; - const sourceLabel = source === "openrouter" ? 'OpenRouter' : "static baseline (March 2026)"; - if (!ts) { - el.innerHTML = `Prices: ${sourceLabel}`; + async function saveProviderApiKey() { + const keyInput = document.getElementById("model-auth-key-input"); + const keyStatus = document.getElementById("model-auth-key-status"); + const provider = keyInput?.dataset?.provider || _modelWizardSelectedProvider; + const value = (keyInput?.value || "").trim(); + if (!value) { + if (keyStatus) { + keyStatus.textContent = "Please enter a key value."; + keyStatus.style.color = "#b91c1c"; + } return; } - try { - const d = new Date(ts); - const now = /* @__PURE__ */ new Date(); - const h = Math.round((now - d) / 36e5); - const age = h < 1 ? "just now" : h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`; - el.innerHTML = `Prices via ${sourceLabel} · updated ${age}`; - } catch { - el.innerHTML = `Prices: ${sourceLabel}`; + if (!provider) { + if (keyStatus) { + keyStatus.textContent = "No provider selected."; + keyStatus.style.color = "#b91c1c"; + } + return; } - } - async function refreshModelPricing() { - const btn = document.getElementById("pricing-refresh-btn"); - const lbl = document.getElementById("pricing-updated-label"); - if (btn) { - btn.disabled = true; - btn.textContent = "Refreshing\u2026"; + if (keyStatus) { + keyStatus.textContent = "Saving\u2026"; + keyStatus.style.color = "#475569"; } - _showModelWizardBusy("Refreshing model pricing..."); try { - await apiCall2("POST", "/api/model-pricing/refresh"); - _modelData = await apiCall2("GET", "/api/model"); - _buildModelTable(); + await apiCall2("POST", "/api/settings/credentials", { provider, key_value: value }); + if (keyInput) keyInput.value = ""; + if (keyStatus) { + keyStatus.textContent = "Key saved successfully."; + keyStatus.style.color = "#065f46"; + } + await _renderAuthStep(provider); } catch (e) { - if (lbl) lbl.textContent = "Refresh failed"; - log3.error("Pricing refresh failed:", e); - } finally { - _hideModelWizardBusy(); - if (btn) { - btn.disabled = false; - btn.textContent = "\u21BB Refresh prices"; + if (keyStatus) { + keyStatus.textContent = `Error: ${e.message || e}`; + keyStatus.style.color = "#b91c1c"; } } } - function updateWorkflowStepsClickable2(currentPhase) { - const sequentialSteps = [ - "step-job", - "step-analysis", - "step-customizations", - "step-rewrite", - "step-spell", - "step-layout" - ]; - const postLayoutSteps = [ - "step-download", - "step-cover_letter", - "step-screening", - "step-interview_prep", - "step-thank_you", - "step-harvest" - ]; - const phaseToIndex = { - "init": 0, - "job_analysis": 1, - "customization": 2, - "rewrite_review": 3, - "spell_check": 4, - "generation": 5, - "layout_review": 5, - "final_generation": 5, - "refinement": 5, - // Legacy fallbacks (step names sometimes passed on DOMContentLoaded) - "job": 0, - "layout": 5, - "finalise": 5 - }; - const postLayoutUnlocked = ["final_generation", "refinement"].includes(currentPhase); - const currentIdx = phaseToIndex[currentPhase] ?? 0; - function _makeStepClickable(el) { - if (!el || el.classList.contains("clickable")) return; - el.classList.add("clickable"); - el.setAttribute("role", "button"); - el.setAttribute("tabindex", "0"); - if (!el._stepKeyHandler) { - el._stepKeyHandler = (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - el.click(); + function toggleApiKeyVisibility() { + const input = document.getElementById("model-auth-key-input"); + const toggle = document.getElementById("model-auth-key-toggle"); + if (!input) return; + const isHidden = input.type === "password"; + input.type = isHidden ? "text" : "password"; + if (toggle) toggle.textContent = isHidden ? "\u{1F648}" : "\u{1F441}"; + } + async function startCopilotAuthFromWizard() { + const statusEl = document.getElementById("model-auth-status"); + const codeEl = document.getElementById("model-auth-code"); + const linkEl = document.getElementById("model-auth-link"); + if (!statusEl || !codeEl || !linkEl) return; + _showModelWizardBusy("Starting GitHub device authorization..."); + try { + const flowRes = await fetch("/api/copilot-auth/start", { method: "POST" }); + if (!flowRes.ok) throw new Error(await flowRes.text()); + const flow = await flowRes.json(); + codeEl.textContent = flow.user_code || ""; + linkEl.href = flow.verification_uri || "https://github.com/login/device"; + linkEl.style.display = ""; + statusEl.textContent = "Open GitHub, enter the code, then return here."; + window.open(linkEl.href, "_blank"); + await fetch("/api/copilot-auth/poll", { method: "POST" }); + _updateLlmStatusPill2("polling", "Copilot auth pending", "\u29D7"); + if (_copilotAuthPollTimer) clearInterval(_copilotAuthPollTimer); + _copilotAuthPollTimer = setInterval(async () => { + try { + const st = await fetch("/api/copilot-auth/status").then((res) => res.json()); + if (st.authenticated) { + clearInterval(_copilotAuthPollTimer); + _copilotAuthPollTimer = null; + await _refreshCopilotAuthStatus(); } - }; - el.addEventListener("keydown", el._stepKeyHandler); - } + } catch { + } + }, 5e3); + } catch (error) { + statusEl.textContent = `Auth start failed: ${error.message || error}`; + } finally { + _hideModelWizardBusy(); } - function _makeStepInert(el) { - if (!el) return; - el.classList.remove("clickable"); - el.removeAttribute("role"); - el.setAttribute("tabindex", "-1"); - if (el._stepKeyHandler) { - el.removeEventListener("keydown", el._stepKeyHandler); - delete el._stepKeyHandler; + } + async function logoutCopilotAuthFromWizard() { + await fetch("/api/copilot-auth/logout", { method: "POST" }); + await _refreshCopilotAuthStatus(); + } + function _wireGlobalModelSearch() { + const input = document.getElementById("model-global-search"); + if (!input) return; + input.oninput = () => { + if (_modelDataTable && typeof _modelDataTable.search === "function") { + _modelDataTable.search(input.value || "").draw(); } + }; + } + function _renderProviderSelector() { + const listEl = document.getElementById("model-provider-list"); + if (!listEl || !_modelData) return; + const providers = Array.isArray(_modelData.providers) ? _modelData.providers : Array.from(new Set((_modelData.all_models || []).map((r) => r.provider).filter(Boolean))).sort(); + const capableSet = new Set(_modelData.list_models_capable || []); + if (!_modelWizardSelectedProvider) { + _modelWizardSelectedProvider = _modelData.provider || providers[0] || null; } - sequentialSteps.forEach((id, idx) => { - const el = document.getElementById(id); - if (!el) return; - if (idx <= currentIdx || postLayoutUnlocked) { - _makeStepClickable(el); - } else { - _makeStepInert(el); - } + if (_modelWizardSelectedProvider) { + _selectedModelProviders = /* @__PURE__ */ new Set([_modelWizardSelectedProvider]); + } + listEl.querySelectorAll('[data-bs-toggle="popover"]').forEach((el) => { + window.bootstrap?.Popover?.getInstance(el)?.dispose(); }); - postLayoutSteps.forEach((id) => { - const el = document.getElementById(id); - if (!el) return; - if (postLayoutUnlocked) { - _makeStepClickable(el); - } else { - _makeStepInert(el); + listEl.innerHTML = ""; + providers.forEach((provider) => { + const checked = provider === _modelWizardSelectedProvider; + const sourceLabel = _providerStageLabel(provider, capableSet); + const info = getProviderInfo(provider); + const label = document.createElement("label"); + label.style.cssText = "display:flex; align-items:center; gap:6px; padding:4px 8px; border:1px solid #cbd5e1; border-radius:999px; font-size:0.82em; background:#fff; cursor:pointer;"; + label.innerHTML = `${escapeHtml2(_providerDisplayLabel(provider))}(${escapeHtml2(sourceLabel)})`; + if (info) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.setAttribute("aria-label", "Provider info"); + btn.setAttribute("data-bs-toggle", "popover"); + btn.setAttribute("data-bs-trigger", "click"); + btn.setAttribute("data-bs-placement", "right"); + btn.setAttribute("data-bs-html", "true"); + btn.setAttribute("data-bs-container", "body"); + btn.setAttribute("data-bs-content", providerInfoPopoverContent(info)); + btn.style.cssText = "background:none;border:none;cursor:pointer;color:#64748b;font-size:0.9em;padding:0 1px;line-height:1;vertical-align:middle;"; + btn.textContent = "\u24D8"; + btn.addEventListener("click", (e) => e.stopPropagation()); + label.appendChild(btn); + requestAnimationFrame(() => { + if (window.bootstrap?.Popover) { + new window.bootstrap.Popover(btn, { sanitize: false }); + } + }); } + const checkbox = label.querySelector("input"); + checkbox.addEventListener("change", () => { + if (!checkbox.checked) return; + _modelWizardSelectedProvider = provider; + _selectedModelProviders = /* @__PURE__ */ new Set([provider]); + _updateModelWizardNav(); + }); + listEl.appendChild(label); }); - const allStepIds = [...sequentialSteps, ...postLayoutSteps]; - allStepIds.forEach((id) => { - const el = document.getElementById(id); - if (el) el.removeAttribute("aria-current"); + _updateModelWizardNav(); + } + function _showModelWizardBusy(message) { + const overlay = document.getElementById("model-wizard-busy-overlay"); + const messageEl = document.getElementById("model-wizard-busy-message"); + if (messageEl) messageEl.textContent = message || "Working..."; + if (overlay) overlay.style.display = "flex"; + } + function _hideModelWizardBusy() { + const overlay = document.getElementById("model-wizard-busy-overlay"); + if (overlay) overlay.style.display = "none"; + } + function _setModelWizardStep(step) { + _modelWizardStep = [1, 2, 3, 4].includes(step) ? step : 1; + const panes = { + 1: document.getElementById("model-step-provider"), + 2: document.getElementById("model-step-auth"), + 3: document.getElementById("model-step-models"), + 4: document.getElementById("model-step-success") + }; + Object.entries(panes).forEach(([num, el]) => { + if (el) el.style.display = Number(num) === _modelWizardStep ? "" : "none"; }); - const activeStepId = postLayoutUnlocked ? "step-download" : sequentialSteps[currentIdx]; - if (activeStepId) { - const activeEl = document.getElementById(activeStepId); - if (activeEl) activeEl.setAttribute("aria-current", "step"); + if (_modelWizardStep === 2) { + _renderAuthStep(_modelWizardSelectedProvider); } + _updateModelWizardNav(); } - document.addEventListener("DOMContentLoaded", () => { - loadModelSelector(); - const tabBar = document.getElementById("tab-bar"); - const leftBtn = document.getElementById("tab-scroll-left"); - const rightBtn = document.getElementById("tab-scroll-right"); - if (tabBar && leftBtn && rightBtn) { - leftBtn.addEventListener("click", () => { - tabBar.scrollBy({ left: -160, behavior: "smooth" }); - }); - rightBtn.addEventListener("click", () => { - tabBar.scrollBy({ left: 160, behavior: "smooth" }); - }); - tabBar.addEventListener("scroll", updateTabScrollButtons); - new ResizeObserver(updateTabScrollButtons).observe(tabBar); - } - updateTabBarForStage2("job"); - updateWorkflowStepsClickable2("job"); - if (typeof stateManager2?.onPhaseChange === "function") { - stateManager2.onPhaseChange((phase) => { - updateWorkflowStepsClickable2(phase); - }); - } - }); - - // web/layout-instruction.js - var layout_instruction_exports = {}; - __export(layout_instruction_exports, { - addToInstructionHistory: () => addToInstructionHistory, - applyAcceptedProposals: () => applyAcceptedProposals, - completeLayoutReview: () => completeLayoutReview, - confirmLayoutReview: () => confirmLayoutReview, - displayLayoutPreview: () => displayLayoutPreview, - generateFinalOutputs: () => generateFinalOutputs, - handleLayoutPrimaryAction: () => handleLayoutPrimaryAction2, - initiateLayoutInstructions: () => initiateLayoutInstructions2, - loadLayoutInstructionHistory: () => loadLayoutInstructionHistory, - pxToPt: () => pxToPt, - renderContentProposals: () => renderContentProposals, - renderDirtyPhasesCallout: () => renderDirtyPhasesCallout, - renderInstructionHistory: () => renderInstructionHistory, - renderLayoutPreviewStatus: () => renderLayoutPreviewStatus, - renderPreviewOutputStatus: () => renderPreviewOutputStatus, - showConfirmationMessage: () => showConfirmationMessage, - showProcessing: () => showProcessing, - submitContentProposal: () => submitContentProposal, - submitLayoutInstruction: () => submitLayoutInstruction, - submitSmartInstruction: () => submitSmartInstruction, - undoInstruction: () => undoInstruction - }); - - // web/ats-refinement.js - var ats_refinement_exports = {}; - __export(ats_refinement_exports, { - formatAtsScoreSummary: () => formatAtsScoreSummary, - refreshAtsScore: () => refreshAtsScore2, - scheduleAtsRefresh: () => scheduleAtsRefresh2, - updateAtsBadge: () => updateAtsBadge2 - }); - function _dispatchAtsScoreUpdated() { - document.dispatchEvent(new CustomEvent("ats-score-updated")); - } - function _safeText(value) { - return typeof value === "string" ? value.trim() : ""; - } - function _coerceObject(value) { - return value && typeof value === "object" ? value : {}; - } - function _formatDateLabel(value) { - const text2 = _safeText(value); - if (!text2) return ""; - if (/^\d{4}-\d{2}-\d{2}$/.test(text2)) { - const [year, month, day] = text2.split("-"); - return `${month}/${day}/${year}`; - } - const parsed = new Date(text2); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString().slice(0, 10); - } - return text2; - } - function _getPageLengthLabel() { - const generationState2 = _coerceObject(globalThis.stateManager?.getGenerationState?.()); - const exact = Number(generationState2.pageCountExact); - if (Number.isFinite(exact) && exact > 0) { - return `Length ${exact} page${exact === 1 ? "" : "s"}`; - } - const estimate = Number(generationState2.pageCountEstimate); - if (Number.isFinite(estimate) && estimate > 0) { - return `Length ${estimate.toFixed(1)} pages est`; - } - return ""; - } - function _getJobSummaryLabel() { - const intake = _coerceObject(window._statusIntake); - const analysis = _coerceObject(globalThis.stateManager?.getTabData?.("analysis")); - const positionFallback = _safeText(document.getElementById("position-title")?.textContent || ""); - const role = _safeText( - intake.role || analysis.job_title || analysis.title || analysis.position_name || "" - ); - const company = _safeText(intake.company || analysis.company_name || analysis.company || ""); - const dateApplied = _formatDateLabel( - intake.date_applied || analysis.date_applied || analysis.application_date || "" - ); - const primary = role && company ? `${role} @ ${company}` : role || company || positionFallback; - if (!primary) return ""; - if (!dateApplied) return primary; - return `${primary} (${dateApplied})`; - } - function _keywordCount(keywords, type, statuses = ["matched", "partial"]) { - return keywords.filter((keyword) => keyword.type === type && statuses.includes(keyword.status)).length; + function _updateModelWizardProgressBar() { + [1, 2, 3, 4].forEach((stepNum) => { + const stepEl = document.getElementById(`model-progress-step-${stepNum}`); + if (!stepEl) return; + const badgeEl = stepEl.querySelector(".model-progress-badge"); + stepEl.classList.remove("is-active", "is-complete", "is-upcoming"); + if (stepNum < _modelWizardStep) { + stepEl.classList.add("is-complete"); + stepEl.setAttribute("aria-current", "false"); + if (badgeEl) badgeEl.textContent = "\u2713"; + } else if (stepNum === _modelWizardStep) { + stepEl.classList.add("is-active"); + stepEl.setAttribute("aria-current", "step"); + if (badgeEl) badgeEl.textContent = String(stepNum); + } else { + stepEl.classList.add("is-upcoming"); + stepEl.setAttribute("aria-current", "false"); + if (badgeEl) badgeEl.textContent = String(stepNum); + } + }); } - function _buildSummaryDetail(score, keywords) { - const missingHard = keywords.filter((keyword) => keyword.type === "hard" && keyword.status === "missing").map((keyword) => keyword.keyword).filter(Boolean); - if (missingHard.length > 0) { - const preview = missingHard.slice(0, 2).join(", "); - const suffix = missingHard.length > 2 ? ` +${missingHard.length - 2} more` : ""; - return `Missing hard: ${preview}${suffix}`; + function _updateModelWizardNav() { + const stepLabel = document.getElementById("model-wizard-step-label"); + const backBtn = document.getElementById("model-wizard-back-btn"); + const nextBtn = document.getElementById("model-wizard-next-btn"); + const testBtn = document.getElementById("model-test-btn"); + const refreshBtn = document.getElementById("pricing-refresh-btn"); + const footer = document.querySelector(".model-wizard-footer"); + _updateModelWizardProgressBar(); + if (stepLabel) { + const labels = { + 1: "Step 1 of 4: Choose provider", + 2: "Step 2 of 4: Set up authentication", + 3: "Step 3 of 4: Choose model and test connection", + 4: "Step 4 of 4: Complete" + }; + stepLabel.textContent = labels[_modelWizardStep] || ""; } - const exact = keywords.filter((keyword) => keyword.match_type === "exact" || keyword.match_type === "synonym").length; - const partial = keywords.filter((keyword) => keyword.match_type === "partial").length; - const topSections = Object.entries(score.section_scores || {}).filter(([, value]) => typeof value === "number" && value > 0).sort((left, right) => right[1] - left[1]).slice(0, 2).map(([section, value]) => `${section} ${Math.round(value)}%`); - if (topSections.length > 0) { - return `Top sections: ${topSections.join(" \u2022 ")}`; + if (backBtn) { + backBtn.disabled = _modelWizardStep === 1; + backBtn.style.display = _modelWizardStep === 4 ? "none" : ""; } - if (exact > 0 || partial > 0) { - return `Match quality: ${exact} exact \u2022 ${partial} partial`; + if (nextBtn) { + nextBtn.style.display = _modelWizardStep === 1 || _modelWizardStep === 2 ? "" : "none"; + nextBtn.disabled = _modelWizardStep === 1 && !_modelWizardSelectedProvider; + nextBtn.textContent = _modelWizardStep === 2 ? "Next" : "Next"; } - return `Basis: ${score.basis || "review"}`; + if (testBtn) testBtn.style.display = _modelWizardStep === 3 ? "" : "none"; + if (refreshBtn) refreshBtn.style.display = _modelWizardStep === 3 ? "" : "none"; + if (footer) footer.style.justifyContent = _modelWizardStep === 4 ? "center" : "space-between"; } - function formatAtsScoreSummary(score) { - const keywords = Array.isArray(score?.keyword_status) ? score.keyword_status : []; - const overall = typeof score?.overall === "number" ? Math.round(score.overall) : null; - if (overall === null) { - return { overall: null, line: "", detail: "", keywords }; - } - if (keywords.length === 0) { - return { - overall, - line: `Overall ${overall}%`, - detail: `Basis: ${score.basis || "review"}`, - keywords - }; - } - const hardTotal = keywords.filter((keyword) => keyword.type === "hard").length; - const softTotal = keywords.filter((keyword) => keyword.type === "soft").length; - const bonusTotal = keywords.filter((keyword) => keyword.type === "bonus").length; - const summaryParts = []; - if (hardTotal > 0) { - summaryParts.push(`Hard ${_keywordCount(keywords, "hard")}/${hardTotal}`); - } - if (softTotal > 0) { - summaryParts.push(`Soft ${_keywordCount(keywords, "soft")}/${softTotal}`); + async function _loadModelsForSelectedProvider() { + if (!_modelWizardSelectedProvider) return false; + const loadingEl = document.getElementById("model-models-loading"); + if (loadingEl) { + loadingEl.style.display = ""; + loadingEl.style.color = "#475569"; + loadingEl.innerHTML = 'Loading models for selected provider...'; } - if (bonusTotal > 0) { - summaryParts.push(`Bonus ${_keywordCount(keywords, "bonus")}/${bonusTotal}`); + _showModelWizardBusy("Querying available models..."); + try { + _selectedModelProviders = /* @__PURE__ */ new Set([_modelWizardSelectedProvider]); + _modelData.provider = _modelWizardSelectedProvider; + await _refreshModelCatalogForSelection(); + if (loadingEl) { + loadingEl.style.display = "none"; + loadingEl.textContent = ""; + } + return true; + } catch (error) { + log4.warn("Could not load models for selected provider:", error); + if (loadingEl) { + loadingEl.style.display = ""; + loadingEl.textContent = "Failed to load models for selected provider."; + loadingEl.style.color = "#b91c1c"; + } + return false; + } finally { + _hideModelWizardBusy(); } - return { - overall, - line: summaryParts.join(" \u2022 ") || `Overall ${overall}%`, - detail: _buildSummaryDetail(score, keywords), - keywords - }; } - function _updateAtsSummary(score) { - const header = document.getElementById("ats-score-header"); - const summary = document.getElementById("ats-score-summary"); - const summaryLine = document.getElementById("ats-score-summary-line"); - const summaryDetail = document.getElementById("ats-score-summary-detail"); - if (!header || !summary || !summaryLine || !summaryDetail) return; - const keywords = Array.isArray(score?.keyword_status) ? score.keyword_status : []; - const hasScore = score && typeof score.overall === "number"; - const pageLengthLabel = _getPageLengthLabel(); - const jobSummaryLabel = _getJobSummaryLabel(); - if (!hasScore) { - header.style.display = "none"; - summary.style.display = "none"; - summaryLine.textContent = ""; - summaryDetail.textContent = ""; + async function nextWizardStep() { + if (_modelWizardStep === 1) { + if (!_modelWizardSelectedProvider) return; + _setModelWizardStep(2); return; } - header.style.display = "flex"; - const summaryContent = formatAtsScoreSummary(score); - const lineParts = [summaryContent.line, pageLengthLabel].filter(Boolean); - const detailParts = [jobSummaryLabel, summaryContent.detail].filter(Boolean); - if (lineParts.length === 0 && detailParts.length === 0 && keywords.length === 0) { - summary.style.display = "none"; - summaryLine.textContent = ""; - summaryDetail.textContent = ""; - return; + if (_modelWizardStep === 2) { + const loaded = await _loadModelsForSelectedProvider(); + if (!loaded) return; + _setModelWizardStep(3); } - summaryLine.textContent = lineParts.join(" \u2022 "); - summaryDetail.textContent = detailParts.join(" \u2022 "); - summary.style.display = "flex"; } - function updateAtsBadge2(score) { - _updateAtsSummary(score); - const badge = document.getElementById("ats-score-badge"); - const valueEl = document.getElementById("ats-score-value"); - if (!badge || !valueEl) { - _dispatchAtsScoreUpdated(); + function previousWizardStep() { + if (_modelWizardStep === 2) { + _setModelWizardStep(1); return; } - if (!score || typeof score.overall !== "number") { - badge.style.display = "none"; - _dispatchAtsScoreUpdated(); + if (_modelWizardStep === 3) { + _setModelWizardStep(2); return; } - const overall = Math.round(score.overall); - valueEl.textContent = `${overall}%`; - badge.style.display = "flex"; - badge.classList.remove("score-high", "score-medium", "score-low"); - if (overall >= 75) { - badge.classList.add("score-high"); - } else if (overall >= 50) { - badge.classList.add("score-medium"); - } else { - badge.classList.add("score-low"); + if (_modelWizardStep === 4) { + _setModelWizardStep(3); } - badge.setAttribute("aria-label", `ATS match score: ${overall}% (${score.basis || "review"})`); - _dispatchAtsScoreUpdated(); } - async function refreshAtsScore2(basis = "review_checkpoint") { - const sessionId2 = globalThis.stateManager?.getSessionId?.(); - if (!sessionId2) return; + async function _refreshModelCatalogForSelection() { + if (!_modelData) return; + if (_catalogRefreshing) { + _catalogRefreshPending = true; + return; + } + _catalogRefreshing = true; try { - const res = await fetch("/api/cv/ats-score", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: sessionId2, basis }) - }); - if (!res.ok) return; - const data = await res.json(); - if (data.ok && data.ats_score) { - globalThis.stateManager?.setAtsScore?.(data.ats_score); - updateAtsBadge2(data.ats_score); + const selected = Array.from(_selectedModelProviders); + if (!selected.length) { + _selectedModelProviders = /* @__PURE__ */ new Set([_modelData.provider]); } - } catch (_e2) { + const providersParam = encodeURIComponent(Array.from(_selectedModelProviders).join(",")); + const catalog = await apiCall2("GET", `/api/model-catalog?providers=${providersParam}`); + _modelData.all_models = catalog.all_models || []; + _modelData.pricing_updated_at = catalog.pricing_updated_at || _modelData.pricing_updated_at; + _modelData.pricing_source = catalog.pricing_source || _modelData.pricing_source; + if (Array.isArray(catalog.providers) && catalog.providers.length) { + _modelData.providers = catalog.providers; + } + if (Array.isArray(catalog.list_models_capable)) { + _modelData.list_models_capable = catalog.list_models_capable; + } + } catch (error) { + log4.warn("Could not refresh model catalog for selected providers:", error); + } finally { + _catalogRefreshing = false; } - } - var _atsRefreshTimer = null; - function scheduleAtsRefresh2(basis = "review_checkpoint") { - clearTimeout(_atsRefreshTimer); - _atsRefreshTimer = setTimeout(() => refreshAtsScore2(basis), 600); - } - function _refreshSummaryFromState() { - const score = globalThis.stateManager?.getAtsScore?.(); - if (score && typeof score.overall === "number") { - _updateAtsSummary(score); + _renderProviderSelector(); + _buildModelTable(); + if (_catalogRefreshPending) { + _catalogRefreshPending = false; + await _refreshModelCatalogForSelection(); } } - if (typeof window !== "undefined") { - window.addEventListener("cvbuilder:generation-state-changed", () => { - _refreshSummaryFromState(); - }); - } - if (typeof document !== "undefined") { - const positionTitleEl = document.getElementById("position-title"); - if (positionTitleEl && typeof MutationObserver !== "undefined") { - const titleObserver = new MutationObserver(() => { - _refreshSummaryFromState(); - }); - titleObserver.observe(positionTitleEl, { - childList: true, - characterData: true, - subtree: true - }); + async function openModelModal() { + const overlay = document.getElementById("model-modal-overlay"); + if (!overlay) return; + if (!_modelData) { + await loadModelSelector(); } - } - - // web/message-queue.js - var message_queue_exports = {}; - __export(message_queue_exports, { - _flushMessageQueue: () => _flushMessageQueue2, - _messageQueue: () => _messageQueue, - appendFormattedAnalysis: () => appendFormattedAnalysis2, - appendFormattedResponse: () => appendFormattedResponse, - appendLoadingMessage: () => appendLoadingMessage2, - appendMessage: () => appendMessage2, - appendMessageHtml: () => appendMessageHtml, - appendRawHtml: () => appendRawHtml2, - appendRetryMessage: () => appendRetryMessage2, - removeLoadingMessage: () => removeLoadingMessage2 - }); - var log4 = getLogger("message-queue"); - var _messageQueue = []; - function _flushMessageQueue2() { - while (_messageQueue.length) { - const { type, text: text2 } = _messageQueue.shift(); - appendMessage2(type, text2); + await loadProviderInfo(); + if (!_modelWizardSelectedProvider) { + _modelWizardSelectedProvider = _modelData?.provider || null; + } + const successSummary = document.getElementById("model-success-summary"); + if (successSummary) { + successSummary.textContent = "Provider and model are configured and the connection check succeeded."; } + _renderProviderSelector(); + _syncCatalogVisibility(); + _wireGlobalModelSearch(); + _setModelWizardStep(1); + _hideModelWizardBusy(); + overlay.style.display = "flex"; + _focusStack.push(document.activeElement); + setInitialFocus2("model-modal-overlay"); + trapFocus2("model-modal-overlay"); } - function appendLoadingMessage2(text2) { - const conversation = document.getElementById("conversation"); - const message = document.createElement("div"); - message.className = "message system"; - const content = document.createElement("div"); - content.className = "content loading-message"; - content.innerHTML = `
    ${text2}`; - message.appendChild(content); - conversation.appendChild(message); - conversation.scrollTop = conversation.scrollHeight; - return message; + function closeModelModal2() { + const overlay = document.getElementById("model-modal-overlay"); + if (overlay) overlay.style.display = "none"; + _setModelWizardStep(1); + _hideModelWizardBusy(); + if (_copilotAuthPollTimer) { + clearInterval(_copilotAuthPollTimer); + _copilotAuthPollTimer = null; + } + restoreFocus2(); } - function removeLoadingMessage2(messageElement) { - if (messageElement && messageElement.parentNode) { - messageElement.parentNode.removeChild(messageElement); + function _applyModelRowVisualState(tr, isActive) { + tr.classList.toggle("model-row-active", isActive); + tr.style.cssText = isActive ? "background:#eff6ff; font-weight:600; cursor:pointer;" : "cursor:pointer;"; + const model = tr.getAttribute("data-model") || ""; + const modelCell = tr.cells && tr.cells[1]; + if (modelCell) { + modelCell.innerHTML = `${escapeHtml2(model)}` + (isActive ? ' ✓ active' : ""); } } - function appendMessageHtml(type, html2) { - const conversation = document.getElementById("conversation"); - const message = document.createElement("div"); - message.className = `message ${type}`; - const content = document.createElement("div"); - content.className = "content"; - content.innerHTML = html2; - message.appendChild(content); - conversation.appendChild(message); - conversation.scrollTop = conversation.scrollHeight; + function _syncModelTableSelection() { + const tbody = document.getElementById("model-table-body"); + if (!tbody || !_modelData) return; + const activeProvider = _modelData.provider; + const activeModel = _modelData.model; + tbody.querySelectorAll("tr").forEach((tr) => { + const isActive = tr.getAttribute("data-provider") === activeProvider && tr.getAttribute("data-model") === activeModel; + _applyModelRowVisualState(tr, isActive); + }); } - var COLLAPSE_LINE_THRESHOLD = 8; - var COLLAPSE_CHAR_THRESHOLD = 480; - function _makeCollapsibleContent(html2, textStr) { - const lines = textStr.split("\n"); - if (lines.length <= COLLAPSE_LINE_THRESHOLD && textStr.length <= COLLAPSE_CHAR_THRESHOLD) { - return { html: html2, collapsible: false }; + function _buildModelTable() { + const tbody = document.getElementById("model-table-body"); + const thead = document.getElementById("model-table-head"); + if (!tbody || !_modelData) return; + const currentProvider = _modelData.provider; + const currentModel = _modelData.model; + if (thead) { + const thS = "padding:10px 14px; white-space:nowrap;"; + thead.innerHTML = `ProviderModelContext$/1M in$/1M outCopilotSourceNotes`; } - const previewLines = lines.slice(0, COLLAPSE_LINE_THRESHOLD); - const previewHtml = escapeHtml2(previewLines.join("\n")).replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\n/g, "
    "); - const overflow = lines.length - COLLAPSE_LINE_THRESHOLD; - const id = `collapsible-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - const wrapped = ` -
    ${previewHtml}
    - - `; - return { html: wrapped, collapsible: true }; - } - function appendMessage2(type, text2) { - const conversation = document.getElementById("conversation"); - if (!conversation) { - _messageQueue.push({ type, text: text2 }); - return null; - } - const message = document.createElement("div"); - message.className = `message ${type}`; - const content = document.createElement("div"); - content.className = "content"; - const textStr = typeof text2 === "string" ? text2 : JSON.stringify(text2, null, 2); - const renderedHtml = escapeHtml2(textStr).replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\n/g, "
    "); - if (type === "user" || type === "assistant") { - const { html: html2, collapsible } = _makeCollapsibleContent(renderedHtml, textStr); - content.innerHTML = html2; - if (collapsible) content.classList.add("collapsible-msg"); - } else { - content.innerHTML = renderedHtml; - } - message.appendChild(content); - const optionsMatch = textStr.match(/\(([^)]+\/[^)]+)\)\s*$/); - if (optionsMatch && type === "assistant") { - const options = optionsMatch[1].split("/").map((opt) => opt.trim()); - const buttonContainer = document.createElement("div"); - buttonContainer.style.cssText = "margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;"; - options.forEach((option) => { - const btn = document.createElement("button"); - btn.textContent = option; - btn.className = "action-btn"; - btn.style.cssText = "padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer;"; - btn.onclick = () => { - document.getElementById("message-input").value = option; - if (typeof sendMessage === "function") sendMessage(); - }; - buttonContainer.appendChild(btn); - }); - message.appendChild(buttonContainer); - } - conversation.appendChild(message); - conversation.scrollTop = conversation.scrollHeight; - return message; - } - function appendRetryMessage2(text2, retryFn, retryLabel = "Retry", cancelFn = null) { - const conversation = document.getElementById("conversation"); - const message = document.createElement("div"); - message.className = "message system"; - const content = document.createElement("div"); - content.className = "content"; - const escaped = escapeHtml2(text2); - content.innerHTML = escaped.replace(/\*\*(.+?)\*\*/g, "$1").replace(/\n/g, "
    "); - message.appendChild(content); - if (typeof retryFn === "function") { - const btn = document.createElement("button"); - btn.textContent = retryLabel; - btn.style.cssText = "margin-top:8px;padding:6px 14px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem;display:inline-block;margin-right:8px;"; - btn.onclick = () => { - message.remove(); - retryFn(); - }; - message.appendChild(btn); } - if (typeof cancelFn === "function") { - const cancelBtn = document.createElement("button"); - cancelBtn.textContent = "Stop retries"; - cancelBtn.style.cssText = "margin-top:8px;padding:6px 14px;background:#6b7280;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem;display:inline-block;"; - cancelBtn.onclick = () => { - message.remove(); - cancelFn(); - }; - message.appendChild(cancelBtn); - } - conversation.appendChild(message); - conversation.scrollTop = conversation.scrollHeight; - return message; - } - function appendFormattedAnalysis2(result) { - try { - const cleanResult = cleanJsonResponse2(result); - const data = typeof cleanResult === "string" ? JSON.parse(cleanResult) : cleanResult; - if (data && typeof data === "object" && (data.title || data.required_skills)) { - const conversation = document.getElementById("conversation"); - const message = document.createElement("div"); - message.className = "message assistant"; - const content = document.createElement("div"); - content.className = "content job-analysis"; - let html2 = "

    \u{1F4CB} Job Analysis Complete

    "; - if (data.title) html2 += `

    Position: ${data.title} at ${data.company || "Company"}

    `; - if (data.domain) html2 += `

    Domain: ${data.domain}

    `; - if (data.required_skills?.length) { - html2 += "

    \u{1F3AF} Required Skills:

    "; - } - if (data.preferred_skills?.length) { - html2 += "

    \u2B50 Preferred Skills:

    "; - } - if (data.nice_to_have_requirements?.length) { - html2 += "

    \u2728 Nice to Have:

    "; - } - if (data.ats_keywords?.length) { - html2 += '

    \u{1F511} ATS Keywords:

    '; - data.ats_keywords.forEach((kw) => { - html2 += `${kw}`; + let rows = _modelData.all_models && _modelData.all_models.length ? _modelData.all_models.filter((r) => r.model) : (_modelData.available || []).map( + (r) => typeof r === "object" ? { ...r, provider: currentProvider } : { model: r, provider: currentProvider } + ); + _renderQuickModelList(rows); + tbody.innerHTML = ""; + const tdBase = "padding:9px 14px; border-bottom:1px solid #e2e8f0;"; + const fmtCost = (v2) => v2 != null ? "$" + Number(v2).toFixed(v2 < 1 ? 3 : 2) : "\u2014"; + const fmtPriceHint = (source) => { + if (source === "runtime_cache") { + return 'cache'; + } + return 'static'; + }; + const fmtSource = (s) => { + if (s === "list_models") { + return 'list_models'; + } + return 'fallback_static'; + }; + const fmtMult = (v2) => { + if (v2 == null) return "\u2014"; + if (v2 === 0) return 'free'; + return Number(v2).toFixed(v2 % 1 === 0 ? 0 : 2) + "×"; + }; + rows.forEach((item) => { + const provider = item.provider || currentProvider; + const m2 = item.model; + const ctx = item.context_window ? Number(item.context_window).toLocaleString() : "\u2014"; + const source = item.source || "fallback_static"; + const priceSource = item.price_source || "static_baseline"; + const notes = item.notes || ""; + const isSelected = provider === currentProvider && m2 === currentModel; + const tr = document.createElement("tr"); + tr.setAttribute("data-provider", provider); + tr.setAttribute("data-model", m2); + _applyModelRowVisualState(tr, isSelected); + tr.addEventListener("mouseover", () => { + if (!tr.classList.contains("model-row-active")) tr.style.background = "#f8fafc"; + }); + tr.addEventListener("mouseout", () => { + if (!tr.classList.contains("model-row-active")) tr.style.background = ""; + }); + tr.innerHTML = `${escapeHtml2(_providerDisplayLabel(provider))}${escapeHtml2(m2)}${ctx}${fmtCost(item.cost_input)}${fmtPriceHint(priceSource)}${fmtCost(item.cost_output)}${fmtPriceHint(priceSource)}${fmtMult(item.copilot_multiplier)}${fmtSource(source)}${escapeHtml2(notes)}`; + tbody.appendChild(tr); + }); + tbody.onclick = async (event2) => { + const tr = event2.target.closest("tr"); + if (!tr) return; + const provider = tr.getAttribute("data-provider"); + const model = tr.getAttribute("data-model"); + if (!model) return; + tr.style.cssText = "background:#fef3c7; cursor:wait; opacity:0.85;"; + const modelCell = tr.cells && tr.cells[1]; + if (modelCell) { + modelCell.innerHTML = `${escapeHtml2(model)}`; + } + const status = document.getElementById("model-test-status"); + if (status) { + status.style.display = ""; + status.style.color = "#92400e"; + status.innerHTML = ` Switching to ${escapeHtml2(model)}\u2026`; + } + await setModel(model, provider); + }; + if (window.$ && $.fn && $.fn.DataTable) { + _modelDataTable = $("#model-table").DataTable({ + paging: false, + searching: true, + info: false, + orderCellsTop: true, + order: [[0, "asc"], [1, "asc"]], + autoWidth: false, + language: { search: "Filter:" }, + initComplete: function() { + const api = this.api(); + const $thead = $("#model-table thead"); + const hasFilterRow = $thead.find("tr.model-filter-row").length > 0; + if (!hasFilterRow) { + const $filterRow = $(''); + api.columns().every(function(colIdx) { + const title = $(api.column(colIdx).header()).text().trim(); + const $th = $(''); + const $input = $("", { + type: "text", + placeholder: title, + style: "width:100%; padding:4px 6px; border:1px solid #cbd5e1; border-radius:4px; font-size:0.82em;" + }); + $th.append($input); + $filterRow.append($th); + }); + $thead.append($filterRow); + } + api.columns().every(function(colIdx) { + const $input = $("#model-table thead tr.model-filter-row th").eq(colIdx).find("input"); + if (!$input.length) return; + $input.off("click.modelFilter keyup.modelFilter change.modelFilter"); + $input.on("click.modelFilter", function(event2) { + event2.stopPropagation(); + }); + $input.on("keyup.modelFilter change.modelFilter", function() { + const value = this.value; + if (api.column(colIdx).search() !== value) { + api.column(colIdx).search(value).draw(); + } + }); }); - html2 += "

    "; } - content.innerHTML = html2; - message.appendChild(content); - conversation.appendChild(message); - conversation.scrollTop = conversation.scrollHeight; - } else { - appendMessage2("assistant", result); - } - } catch (e) { - log4.error("Analysis display error:", e); - appendMessage2("assistant", result); + }); } + _syncModelTableSelection(); + _updatePricingFooter(); } - function appendFormattedResponse(response) { + async function setModel(model, provider) { + if (!model) return; + _showModelWizardBusy(`Applying model ${model}...`); try { - const data = typeof response === "string" ? JSON.parse(response) : response; - if (data && typeof data === "object" && (data.title || data.required_skills)) { - appendFormattedAnalysis2(response); - } else { - appendMessage2("assistant", response); + const payload = provider ? { model, provider } : { model }; + await apiCall2("POST", "/api/model", payload); + if (_modelData) { + _modelData.model = model; + if (provider) _modelData.provider = provider; + } + const effectiveProvider = _modelData && _modelData.provider || provider; + const label = document.getElementById("model-current-label"); + if (label) { + label.textContent = effectiveProvider ? `${effectiveProvider} \xB7 ${model}` : model; + } + _appendRecentModel(effectiveProvider, model); + _syncModelTableSelection(); + _renderQuickModelList(_modelData && _modelData.all_models || []); + _updateLlmStatusPill2("configured", `${effectiveProvider || "Provider"} configured`); + _saveModelPrefsToStorage({ + currentModelProvider: effectiveProvider || null, + currentModelName: model || null + }); + if (typeof globalThis.updateAuthBadge === "function") { + globalThis.updateAuthBadge({}, effectiveProvider || null); } + await _refreshCopilotAuthStatus(); + await testCurrentModel(); } catch (e) { - appendMessage2("assistant", response); + log4.error("Failed to switch model:", e); + const msg = e.message || String(e); + _syncModelTableSelection(); + const status = document.getElementById("model-test-status"); + if (status) { + status.style.display = ""; + status.style.color = "#dc2626"; + status.textContent = `\u274C ${msg}`; + } + if (typeof appendMessage === "function") { + appendMessage("system", `\u274C Model switch failed: ${msg}`); + } + } finally { + _hideModelWizardBusy(); } } - function appendRawHtml2(html2) { - const conversation = document.getElementById("conversation"); - if (!conversation) return; - conversation.insertAdjacentHTML("beforeend", html2); - conversation.scrollTop = conversation.scrollHeight; - } - - // web/review-table-base.js - var review_table_base_exports = {}; - __export(review_table_base_exports, { - _loadReviewPane: () => _loadReviewPane, - _resolvedExpAction: () => _resolvedExpAction, - _resolvedSkillAction: () => _resolvedSkillAction, - _updatePageEstimate: () => _updatePageEstimate2, - bulkAction: () => bulkAction, - handleActionClick: () => handleActionClick2, - handleCustomizationResponse: () => handleCustomizationResponse2, - loadTabContent: () => loadTabContent, - populateAnalysisTab: () => populateAnalysisTab, - populateReviewTab: () => populateReviewTab, - populateTaglineReviewTab: () => populateTaglineReviewTab, - showTableBasedReview: () => showTableBasedReview2, - switchReviewSubtab: () => switchReviewSubtab, - switchTab: () => switchTab2, - updateInclusionCounts: () => updateInclusionCounts2, - userSelections: () => userSelections2 - }); - var log5 = getLogger("review-table-base"); - var userSelections2 = { - experiences: {}, - // exp_id -> 'emphasize'|'include'|'de-emphasize'|'exclude' - skills: {} - // skill_name -> 'emphasize'|'include'|'de-emphasize'|'exclude' - }; - var pageEstimateTimer = null; - var pageEstimateRequestId = 0; - var _draftInputs = {}; - function _saveDraftInputsForTab(tabName) { - if (!tabName) return; - const content = document.getElementById("document-content"); - if (!content) return; - const saved = {}; - content.querySelectorAll("textarea, input, select").forEach((el) => { - if (!el.id || el.type === "file") return; - saved[el.id] = el.type === "checkbox" || el.type === "radio" ? el.checked : el.value; - }); - if (Object.keys(saved).length > 0) { - _draftInputs[tabName] = saved; - } - } - function _restoreDraftInputsForTab(tabName) { - const cached = _draftInputs[tabName]; - if (!cached) return; - Object.entries(cached).forEach(([id, value]) => { - const el = document.getElementById(id); - if (!el || el.readOnly || el.disabled || el.type === "file") return; - if (el.type === "checkbox" || el.type === "radio") { - el.checked = value; - } else { - el.value = value; + async function testCurrentModel() { + const badge = document.getElementById("model-test-badge"); + const status = document.getElementById("model-test-status"); + const btn = document.getElementById("model-test-btn"); + const SPIN = "\u23F3"; + const OK = "\u2705"; + const FAIL = "\u274C"; + const setRunning = () => { + if (badge) { + badge.textContent = SPIN; + badge.style.display = ""; + badge.title = "Testing\u2026"; } - }); - } - function ensureTabDataState() { - return stateManager2.getAllTabData(); - } - function isReconnectInProgress() { - return stateManager2.isReconnecting(); - } - function updateInclusionCounts2() { + if (status) { + status.innerHTML = `${SPIN} Testing connection\u2026`; + status.style.display = ""; + } + if (btn) { + btn.disabled = true; + btn.textContent = "\u23F3 Testing\u2026"; + } + _updateLlmStatusPill2("connecting", "Connecting\u2026", "\u29D7"); + }; + const setOk = (latencyMs) => { + const tip = `Connected \u2014 ${latencyMs}ms`; + if (badge) { + badge.textContent = OK; + badge.style.display = ""; + badge.title = tip; + } + if (status) { + status.innerHTML = `${OK} ${tip}`; + status.style.color = "#16a34a"; + status.style.display = ""; + } + if (btn) { + btn.disabled = false; + btn.innerHTML = "✓ Test connection"; + } + _updateLlmStatusPill2("connected", `Healthy (${latencyMs}ms)`, "\u2713"); + const successSummary = document.getElementById("model-success-summary"); + if (successSummary) { + successSummary.textContent = `Connection verified in ${latencyMs}ms. You can close the wizard and continue.`; + } + _setModelWizardStep(4); + }; + const setFail = (errMsg) => { + if (badge) { + badge.textContent = FAIL; + badge.style.display = ""; + badge.title = errMsg; + } + if (status) { + status.innerHTML = `${FAIL} Connection failed`; + status.style.color = "#dc2626"; + status.style.display = ""; + } + if (btn) { + btn.disabled = false; + btn.innerHTML = "✓ Test connection"; + } + _updateLlmStatusPill2("error", "Connection failed", "\u26A0", errMsg); + if (_modelWizardStep === 3) { + _setModelWizardStep(2); + } + }; + setRunning(); + _showModelWizardBusy("Testing model connection..."); try { - const expDecs = window._savedDecisions && window._savedDecisions.experience_decisions || userSelections2.experiences || {}; - const skillDecs = window._savedDecisions && window._savedDecisions.skill_decisions || userSelections2.skills || {}; - const achDecs = window._savedDecisions && window._savedDecisions.achievement_decisions || window.achievementDecisions || {}; - const expIncluded = Object.values(expDecs).filter((v2) => v2 !== "exclude").length; - const skillIncluded = Object.values(skillDecs).filter((v2) => v2 !== "exclude").length; - const achIncluded = Object.values(achDecs).filter((v2) => v2 !== "exclude").length; - const expTab = document.getElementById("tab-exp-review"); - const skillTab = document.getElementById("tab-skills-review"); - const achTab = document.getElementById("tab-achievements-review"); - if (expTab) expTab.textContent = `\u{1F4CA} Experiences${expIncluded ? " (" + expIncluded + ")" : ""}`; - if (skillTab) skillTab.textContent = `\u{1F6E0}\uFE0F Skills${skillIncluded ? " (" + skillIncluded + ")" : ""}`; - if (achTab) achTab.textContent = `\u{1F3C6} Achievements${achIncluded ? " (" + achIncluded + ")" : ""}`; + const result = await apiCall2("POST", "/api/model/test"); + if (result.ok) { + setOk(result.latency_ms); + return true; + } + setFail(result.error || "Unknown error"); + return false; } catch (e) { - log5.warn("Failed to update inclusion counts:", e); + setFail(e.message || String(e)); + return false; + } finally { + _hideModelWizardBusy(); } } - function _populateGenerateTab(cvData, content) { - if (!cvData || !cvData.files || !cvData.files.length) { - content.innerHTML = '
    \u{1F4C4}

    Generated CV

    Generate CV to see preview

    '; + function _updatePricingFooter() { + const el = document.getElementById("pricing-updated-label"); + if (!el || !_modelData) return; + const ts = _modelData.pricing_updated_at; + const source = _modelData.pricing_source; + const sourceLabel = source === "openrouter" ? 'OpenRouter' : "static baseline (March 2026)"; + if (!ts) { + el.innerHTML = `Prices: ${sourceLabel}`; return; } - const meta = cvData.metadata || {}; - const role = meta.role || meta.position || ""; - const company = meta.company || ""; - const subtitle = role && company ? `${role} \u2014 ${company}` : role || company || "CV generated"; - const ICONS = { ".pdf": "\u{1F4C4}", ".docx": "\u{1F4DD}", ".html": "\u{1F310}" }; - const LABELS = { - ".pdf": (f) => f.includes("ATS") ? "ATS PDF" : "Human PDF", - ".docx": (f) => f.includes("ATS") ? "ATS DOCX" : f.startsWith("CoverLetter_") ? "Cover Letter" : "Human DOCX", - ".html": () => "HTML" - }; - const downloadableExts = /* @__PURE__ */ new Set([".pdf", ".docx", ".html"]); - const files = (cvData.files || []).filter((f) => { - const ext = f.slice(f.lastIndexOf(".")).toLowerCase(); - return downloadableExts.has(ext) && f !== "job_description.txt"; - }); - const sid = typeof getSessionIdFromURL === "function" ? getSessionIdFromURL() : null; - const sessionParam = sid ? `?session_id=${encodeURIComponent(sid)}` : ""; - let fileRows = ""; - for (const f of files) { - const ext = f.slice(f.lastIndexOf(".")).toLowerCase(); - const icon = ICONS[ext] || "\u{1F4C1}"; - const label = LABELS[ext] ? LABELS[ext](f) : f; - fileRows += ` -
    - ${icon} -
    -
    ${label}
    -
    ${escapeHtml(f)}
    -
    - Download -
    `; + try { + const d = new Date(ts); + const now = /* @__PURE__ */ new Date(); + const h = Math.round((now - d) / 36e5); + const age = h < 1 ? "just now" : h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`; + el.innerHTML = `Prices via ${sourceLabel} · updated ${age}`; + } catch { + el.innerHTML = `Prices: ${sourceLabel}`; } - content.innerHTML = ` -
    -
    - \u2705 -
    -

    CV Generated

    -
    ${escapeHtml(subtitle)}
    -
    -
    -
    - ${fileRows} -
    - -
    `; - } - var _visitedCustomiseTabs = /* @__PURE__ */ new Set(); - var _CUSTOMISE_TABS = /* @__PURE__ */ new Set(["goals", "questions", "exp-review", "ach-editor", "skills-review", "achievements-review", "tagline-review", "summary-review", "publications-review", "ats-score"]); - function _updateVisitedTabIndicators() { - _visitedCustomiseTabs.forEach((name) => { - const el = document.getElementById(`tab-${name}`); - if (el) el.classList.add("tab--visited"); - }); } - function switchTab2(tab) { - if (typeof resetCardFocus === "function") resetCardFocus(); - _saveDraftInputsForTab(stateManager2.getCurrentTab()); - if (typeof getStageForTab === "function" && typeof updateTabBarForStage === "function") { - const tabStage = getStageForTab(tab); - if (tabStage) { - updateTabBarForStage(tabStage); - updateActionButtons(tabStage); - } - } - if (typeof stateManager2?.getPhase === "function" && typeof updateWorkflowStepsClickable === "function") { - updateWorkflowStepsClickable(stateManager2.getPhase()); - } - document.querySelectorAll(".tab").forEach((t) => { - t.classList.remove("active"); - t.setAttribute("aria-selected", "false"); - t.setAttribute("tabindex", "-1"); - }); - const activeTab = document.getElementById(`tab-${tab}`); - if (activeTab) { - activeTab.classList.add("active"); - activeTab.setAttribute("aria-selected", "true"); - activeTab.setAttribute("tabindex", "0"); - const tabpanel = document.getElementById("document-content"); - if (tabpanel) tabpanel.setAttribute("aria-labelledby", `tab-${tab}`); - } - stateManager2.setCurrentTab(tab); - if (_CUSTOMISE_TABS.has(tab)) { - _visitedCustomiseTabs.add(tab); - _updateVisitedTabIndicators(); + async function refreshModelPricing() { + const btn = document.getElementById("pricing-refresh-btn"); + const lbl = document.getElementById("pricing-updated-label"); + if (btn) { + btn.disabled = true; + btn.textContent = "Refreshing\u2026"; } - const announcer = document.getElementById("workflow-stage-announcer"); - if (announcer && activeTab) { - announcer.textContent = ""; - setTimeout(() => { - announcer.textContent = `Now viewing: ${activeTab.textContent.trim()}`; - }, 50); + _showModelWizardBusy("Refreshing model pricing..."); + try { + await apiCall2("POST", "/api/model-pricing/refresh"); + _modelData = await apiCall2("GET", "/api/model"); + _buildModelTable(); + } catch (e) { + if (lbl) lbl.textContent = "Refresh failed"; + log4.error("Pricing refresh failed:", e); + } finally { + _hideModelWizardBusy(); + if (btn) { + btn.disabled = false; + btn.textContent = "\u21BB Refresh prices"; + } } - if (typeof _updateViewingIndicator === "function") _updateViewingIndicator(tab); - const content = document.getElementById("document-content"); - content.classList.toggle("full-width", tab !== "generate"); - loadTabContent(tab); } - async function loadTabContent(tab) { - const content = document.getElementById("document-content"); - const tabData2 = ensureTabDataState(); - try { - switch (tab) { - case "job": - await populateJobTab(); - break; - case "goals": - if (typeof populateGoalsTab === "function") await populateGoalsTab(); - break; - case "analysis": - if (tabData2.analysis) { - populateAnalysisTab(tabData2.analysis); - } else { - content.innerHTML = '
    \u{1F50D}

    Job Analysis

    Click "Analyze Job" to generate analysis

    '; - } - break; - case "questions": - populateQuestionsTab(); - break; - case "customizations": - switchTab2("exp-review"); - return; - case "exp-review": - await populateReviewTab("experiences"); - break; - case "ach-editor": - await buildAchievementsEditor(); - break; - case "skills-review": - await populateReviewTab("skills"); - break; - case "achievements-review": - await populateReviewTab("achievements"); - break; - case "tagline-review": - await populateTaglineReviewTab(); - break; - case "summary-review": - await populateReviewTab("summary"); - break; - case "publications-review": - await populateReviewTab("publications"); - break; - case "ats-score": - if (typeof populateAtsScoreTab === "function") await populateAtsScoreTab(); - break; - case "rewrite": - if (window._rewritePanelCache) { - renderRewritePanel( - window._rewritePanelCache.rewrites, - window._rewritePanelCache.warnings - ); - } else { - content.innerHTML = '
    \u270F\uFE0F

    Rewrites

    Loading rewrites\u2026

    '; - try { - const data = await apiCall("GET", "/api/rewrites"); - if (data.rewrites) { - renderRewritePanel(data.rewrites, data.persuasion_warnings || []); - } else { - content.innerHTML = '
    \u270F\uFE0F

    Rewrites

    Complete customizations to reach this step

    '; - } - } catch (e) { - content.innerHTML = '
    \u270F\uFE0F

    Rewrites

    Complete customizations to reach this step

    '; - } - } - break; - case "editor": - await populateCVEditorTab(); - break; - case "generate": - _populateGenerateTab(tabData2.cv, content); - break; - case "download": - if (tabData2.cv && Object.keys(tabData2.cv).length > 0) { - await populateDownloadTab(tabData2.cv); - } else { - content.innerHTML = '
    \u2B07\uFE0F

    Download

    Generate CV to enable downloads

    '; + function updateWorkflowStepsClickable2(currentPhase) { + const sequentialSteps = [ + "step-job", + "step-analysis", + "step-customizations", + "step-rewrite", + "step-spell", + "step-layout" + ]; + const postLayoutSteps = [ + "step-download", + "step-cover_letter", + "step-screening", + "step-interview_prep", + "step-thank_you", + "step-harvest" + ]; + const phaseToIndex = { + "init": 0, + "job_analysis": 1, + "customization": 2, + "rewrite_review": 3, + "spell_check": 4, + "generation": 5, + "layout_review": 5, + "final_generation": 5, + "refinement": 5, + // Legacy fallbacks (step names sometimes passed on DOMContentLoaded) + "job": 0, + "layout": 5, + "finalise": 5 + }; + const postLayoutUnlocked = ["final_generation", "refinement"].includes(currentPhase); + const currentIdx = phaseToIndex[currentPhase] ?? 0; + function _makeStepClickable(el) { + if (!el || el.classList.contains("clickable")) return; + el.classList.add("clickable"); + el.setAttribute("role", "button"); + el.setAttribute("tabindex", "0"); + if (!el._stepKeyHandler) { + el._stepKeyHandler = (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + el.click(); } - break; - case "spell": - await populateSpellCheckTab(); - break; - case "layout": - initiateLayoutInstructions(); - break; - case "finalise": - await populateFinaliseTab(); - break; - case "harvest": - if (typeof populateHarvestTab === "function") await populateHarvestTab(); - break; - case "interview-prep": - if (typeof populateInterviewPrepTab === "function") await populateInterviewPrepTab(); - break; - case "thank-you": - if (typeof populateThankYouTab === "function") await populateThankYouTab(); - break; - case "master": - await populateMasterTab(); - break; - case "cover-letter": - await populateCoverLetterTab(); - break; - case "screening": - await populateScreeningTab(); - break; + }; + el.addEventListener("keydown", el._stepKeyHandler); } - } catch (error) { - log5.error(`Error loading tab ${tab}:`, error); - const errorMessage = document.createElement("p"); - errorMessage.style.cssText = "padding: 20px; color: #c41e3a;"; - errorMessage.textContent = `Error loading content: ${error.message}`; - content.appendChild(errorMessage); - return; - } - _restoreDraftInputsForTab(tab); - } - async function showTableBasedReview2() { - if (!window.pendingRecommendations) { - appendMessage("assistant", "No recommendations to review. Please generate customizations first."); - return; } - switchTab2("exp-review"); - appendMessage("assistant", "\u2705 Customizations generated! Please review each section in the **Customizations** tab \u2014 Experiences, Achievements, Skills, Summary, and Publications. Select your preferences using the action buttons, then submit your decisions."); - } - var _synonymMapCache = null; - async function _loadSynonymMap() { - if (_synonymMapCache) return _synonymMapCache; - try { - const sessionId2 = stateManager2.getSessionId && stateManager2.getSessionId(); - const url = sessionId2 ? `/api/synonym-map?session_id=${encodeURIComponent(sessionId2)}` : "/api/synonym-map"; - const res = await fetch(url); - if (!res.ok) return _synonymMapCache = {}; - const raw = await res.json(); - const aliasToCanon = {}; - const canonToAliases = {}; - Object.entries(raw).forEach(([alias, canon]) => { - aliasToCanon[alias.toLowerCase()] = canon; - const cl = canon.toLowerCase(); - (canonToAliases[cl] = canonToAliases[cl] || []).push(alias); - }); - _synonymMapCache = { aliasToCanon, canonToAliases }; - } catch (_2) { - _synonymMapCache = {}; + function _makeStepInert(el) { + if (!el) return; + el.classList.remove("clickable"); + el.removeAttribute("role"); + el.setAttribute("tabindex", "-1"); + if (el._stepKeyHandler) { + el.removeEventListener("keydown", el._stepKeyHandler); + delete el._stepKeyHandler; + } } - return _synonymMapCache; - } - function _kwSynonymAnnotation(kw, synMap) { - if (!synMap || !synMap.aliasToCanon && !synMap.canonToAliases) return ""; - const kl = kw.toLowerCase(); - if (synMap.aliasToCanon[kl]) return ` = ${synMap.aliasToCanon[kl]}`; - const aliases = synMap.canonToAliases && synMap.canonToAliases[kl]; - if (aliases && aliases.length) return ` (${aliases.join(", ")})`; - return ""; - } - async function populateAnalysisTab(result) { - const content = document.getElementById("document-content"); - try { - let data; - if (result !== null && typeof result === "object") { - data = result; + sequentialSteps.forEach((id, idx) => { + const el = document.getElementById(id); + if (!el) return; + if (idx <= currentIdx || postLayoutUnlocked) { + _makeStepClickable(el); } else { - const cleanResult = cleanJsonResponse(result); - data = typeof cleanResult === "string" ? JSON.parse(cleanResult) : cleanResult; - } - stateManager2.setTabData("analysis", result); - saveTabData(); - let html2 = '
    '; - html2 += '
    '; - html2 += `

    ${data.title || "Role"}

    `; - if (data.company) html2 += `

    \u{1F3E2} ${data.company}

    `; - html2 += '
    '; - if (data.domain) { - const conf = data.domain_confidence; - let confLabel = ""; - let confTitle = ""; - if (typeof conf === "number") { - if (conf >= 0.8) { - confLabel = ""; - confTitle = `Domain confidence: High (${Math.round(conf * 100)}%)`; - } else if (conf >= 0.6) { - confLabel = " \u26A0"; - confTitle = `Domain confidence: Medium (${Math.round(conf * 100)}%) \u2014 verify this is correct`; - } else { - confLabel = " \u26A0"; - confTitle = `Domain confidence: Low (${Math.round(conf * 100)}%) \u2014 the JD spans multiple domains; consider overriding`; - } - } - html2 += `\u{1F52C} ${escapeHtml(data.domain)}${confLabel}`; + _makeStepInert(el); } - if (data.role_level) html2 += `\u{1F4CA} ${data.role_level}`; - if (data.suggested_summary) html2 += `\u{1F4AC} ${data.suggested_summary}`; - html2 += "
    "; - const requiredSkills = Array.isArray(data.required_skills) ? data.required_skills : []; - const masterSkills = window._masterSkills || []; - if (requiredSkills.length > 0 && masterSkills.length > 0) { - const missing = requiredSkills.filter( - (skill) => !masterSkills.some((ms) => ms.includes(skill.toLowerCase()) || skill.toLowerCase().includes(ms)) - ); - if (missing.length > 0) { - html2 += `
    \u26A0 ${missing.length} required skill${missing.length > 1 ? "s" : ""} not found in your master CV: ${missing.join(", ")}
    `; - } + }); + postLayoutSteps.forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + if (postLayoutUnlocked) { + _makeStepClickable(el); + } else { + _makeStepInert(el); } - if (requiredSkills.length > 0) { - html2 += '

    \u{1F3AF} Required Skills

    '; - requiredSkills.forEach((skill) => { - const isMissing = masterSkills.length > 0 && !masterSkills.some( - (ms) => ms.includes(skill.toLowerCase()) || skill.toLowerCase().includes(ms) - ); - html2 += `${skill}${isMissing ? ' (not in master CV)' : ""}`; - }); - html2 += "
    "; - } - const preferred = [ - ...Array.isArray(data.preferred_skills) ? data.preferred_skills : [], - ...Array.isArray(data.nice_to_have_requirements) ? data.nice_to_have_requirements : [] - ]; - if (preferred.length > 0) { - html2 += '

    \u2B50 Preferred / Nice-to-Have

    "; - } - const atsKws = Array.isArray(data.ats_keywords) ? data.ats_keywords : []; - if (atsKws.length > 0) { - const synMap = await _loadSynonymMap(); - html2 += '

    \u{1F511} ATS Keywords (higher rank = higher priority)

    '; - atsKws.forEach((kw, idx) => { - const annotation = _kwSynonymAnnotation(kw, synMap); - const titleAttr = annotation ? ` title="Synonym: ${escapeHtml(kw + annotation)}"` : ""; - const annotHtml = annotation ? `${escapeHtml(annotation)}` : ""; - html2 += `#${idx + 1}${escapeHtml(kw)}${annotHtml}`; - }); - html2 += "
    "; - } - const culture = Array.isArray(data.culture_indicators) ? data.culture_indicators : []; - if (culture.length > 0) { - html2 += '

    \u{1F3E2} Culture Indicators

    "; - } - const mustHave = Array.isArray(data.must_have_requirements) ? data.must_have_requirements : []; - if (mustHave.length > 0) { - html2 += '

    \u2705 Must-Have Requirements

    "; + }); + const allStepIds = [...sequentialSteps, ...postLayoutSteps]; + allStepIds.forEach((id) => { + const el = document.getElementById(id); + if (el) el.removeAttribute("aria-current"); + }); + const activeStepId = postLayoutUnlocked ? "step-download" : sequentialSteps[currentIdx]; + if (activeStepId) { + const activeEl = document.getElementById(activeStepId); + if (activeEl) activeEl.setAttribute("aria-current", "step"); + } + } + document.addEventListener("DOMContentLoaded", () => { + loadModelSelector(); + const tabBar = document.getElementById("tab-bar"); + const leftBtn = document.getElementById("tab-scroll-left"); + const rightBtn = document.getElementById("tab-scroll-right"); + if (tabBar && leftBtn && rightBtn) { + leftBtn.addEventListener("click", () => { + tabBar.scrollBy({ left: -160, behavior: "smooth" }); + }); + rightBtn.addEventListener("click", () => { + tabBar.scrollBy({ left: 160, behavior: "smooth" }); + }); + tabBar.addEventListener("scroll", updateTabScrollButtons); + new ResizeObserver(updateTabScrollButtons).observe(tabBar); + } + updateTabBarForStage2("job"); + updateWorkflowStepsClickable2("job"); + if (typeof stateManager2?.onPhaseChange === "function") { + stateManager2.onPhaseChange((phase) => { + updateWorkflowStepsClickable2(phase); + }); + } + }); + + // web/layout-instruction.js + var layout_instruction_exports = {}; + __export(layout_instruction_exports, { + addToInstructionHistory: () => addToInstructionHistory, + applyAcceptedProposals: () => applyAcceptedProposals, + completeLayoutReview: () => completeLayoutReview, + confirmLayoutReview: () => confirmLayoutReview, + displayLayoutPreview: () => displayLayoutPreview, + generateFinalOutputs: () => generateFinalOutputs, + handleLayoutPrimaryAction: () => handleLayoutPrimaryAction2, + initiateLayoutInstructions: () => initiateLayoutInstructions2, + loadLayoutInstructionHistory: () => loadLayoutInstructionHistory, + pxToPt: () => pxToPt, + renderContentProposals: () => renderContentProposals, + renderDirtyPhasesCallout: () => renderDirtyPhasesCallout, + renderInstructionHistory: () => renderInstructionHistory, + renderLayoutPreviewStatus: () => renderLayoutPreviewStatus, + renderPreviewOutputStatus: () => renderPreviewOutputStatus, + showConfirmationMessage: () => showConfirmationMessage, + showProcessing: () => showProcessing, + submitContentProposal: () => submitContentProposal, + submitLayoutInstruction: () => submitLayoutInstruction, + submitSmartInstruction: () => submitSmartInstruction, + undoInstruction: () => undoInstruction + }); + + // web/ats-refinement.js + var ats_refinement_exports = {}; + __export(ats_refinement_exports, { + formatAtsScoreSummary: () => formatAtsScoreSummary, + refreshAtsScore: () => refreshAtsScore2, + scheduleAtsRefresh: () => scheduleAtsRefresh2, + updateAtsBadge: () => updateAtsBadge2 + }); + function _dispatchAtsScoreUpdated() { + document.dispatchEvent(new CustomEvent("ats-score-updated")); + } + function _safeText(value) { + return typeof value === "string" ? value.trim() : ""; + } + function _coerceObject(value) { + return value && typeof value === "object" ? value : {}; + } + function _formatDateLabel(value) { + const text2 = _safeText(value); + if (!text2) return ""; + if (/^\d{4}-\d{2}-\d{2}$/.test(text2)) { + const [year, month, day] = text2.split("-"); + return `${month}/${day}/${year}`; + } + const parsed = new Date(text2); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString().slice(0, 10); + } + return text2; + } + function _getPageLengthLabel() { + const generationState2 = _coerceObject(globalThis.stateManager?.getGenerationState?.()); + const exact = Number(generationState2.pageCountExact); + if (Number.isFinite(exact) && exact > 0) { + return `Length ${exact} page${exact === 1 ? "" : "s"}`; + } + const estimate = Number(generationState2.pageCountEstimate); + if (Number.isFinite(estimate) && estimate > 0) { + return `Length ${estimate.toFixed(1)} pages est`; + } + return ""; + } + function _getJobSummaryLabel() { + const intake = _coerceObject(window._statusIntake); + const analysis = _coerceObject(globalThis.stateManager?.getTabData?.("analysis")); + const positionFallback = _safeText(document.getElementById("position-title")?.textContent || ""); + const role = _safeText( + intake.role || analysis.job_title || analysis.title || analysis.position_name || "" + ); + const company = _safeText(intake.company || analysis.company_name || analysis.company || ""); + const dateApplied = _formatDateLabel( + intake.date_applied || analysis.date_applied || analysis.application_date || "" + ); + const primary = role && company ? `${role} @ ${company}` : role || company || positionFallback; + if (!primary) return ""; + if (!dateApplied) return primary; + return `${primary} (${dateApplied})`; + } + function _keywordCount(keywords, type, statuses = ["matched", "partial"]) { + return keywords.filter((keyword) => keyword.type === type && statuses.includes(keyword.status)).length; + } + function _buildSummaryDetail(score, keywords) { + const missingHard = keywords.filter((keyword) => keyword.type === "hard" && keyword.status === "missing").map((keyword) => keyword.keyword).filter(Boolean); + if (missingHard.length > 0) { + const preview = missingHard.slice(0, 2).join(", "); + const suffix = missingHard.length > 2 ? ` +${missingHard.length - 2} more` : ""; + return `Missing hard: ${preview}${suffix}`; + } + const exact = keywords.filter((keyword) => keyword.match_type === "exact" || keyword.match_type === "synonym").length; + const partial = keywords.filter((keyword) => keyword.match_type === "partial").length; + const topSections = Object.entries(score.section_scores || {}).filter(([, value]) => typeof value === "number" && value > 0).sort((left, right) => right[1] - left[1]).slice(0, 2).map(([section, value]) => `${section} ${Math.round(value)}%`); + if (topSections.length > 0) { + return `Top sections: ${topSections.join(" \u2022 ")}`; + } + if (exact > 0 || partial > 0) { + return `Match quality: ${exact} exact \u2022 ${partial} partial`; + } + return `Basis: ${score.basis || "review"}`; + } + function formatAtsScoreSummary(score) { + const keywords = Array.isArray(score?.keyword_status) ? score.keyword_status : []; + const overall = typeof score?.overall === "number" ? Math.round(score.overall) : null; + if (overall === null) { + return { overall: null, line: "", detail: "", keywords }; + } + if (keywords.length === 0) { + return { + overall, + line: `Overall ${overall}%`, + detail: `Basis: ${score.basis || "review"}`, + keywords + }; + } + const hardTotal = keywords.filter((keyword) => keyword.type === "hard").length; + const softTotal = keywords.filter((keyword) => keyword.type === "soft").length; + const bonusTotal = keywords.filter((keyword) => keyword.type === "bonus").length; + const summaryParts = []; + if (hardTotal > 0) { + summaryParts.push(`Hard ${_keywordCount(keywords, "hard")}/${hardTotal}`); + } + if (softTotal > 0) { + summaryParts.push(`Soft ${_keywordCount(keywords, "soft")}/${softTotal}`); + } + if (bonusTotal > 0) { + summaryParts.push(`Bonus ${_keywordCount(keywords, "bonus")}/${bonusTotal}`); + } + return { + overall, + line: summaryParts.join(" \u2022 ") || `Overall ${overall}%`, + detail: _buildSummaryDetail(score, keywords), + keywords + }; + } + function _updateAtsSummary(score) { + const header = document.getElementById("ats-score-header"); + const summary = document.getElementById("ats-score-summary"); + const summaryLine = document.getElementById("ats-score-summary-line"); + const summaryDetail = document.getElementById("ats-score-summary-detail"); + if (!header || !summary || !summaryLine || !summaryDetail) return; + const keywords = Array.isArray(score?.keyword_status) ? score.keyword_status : []; + const hasScore = score && typeof score.overall === "number"; + const pageLengthLabel = _getPageLengthLabel(); + const jobSummaryLabel = _getJobSummaryLabel(); + if (!hasScore) { + header.style.display = "none"; + summary.style.display = "none"; + summaryLine.textContent = ""; + summaryDetail.textContent = ""; + return; + } + header.style.display = "flex"; + const summaryContent = formatAtsScoreSummary(score); + const lineParts = [summaryContent.line, pageLengthLabel].filter(Boolean); + const detailParts = [jobSummaryLabel, summaryContent.detail].filter(Boolean); + if (lineParts.length === 0 && detailParts.length === 0 && keywords.length === 0) { + summary.style.display = "none"; + summaryLine.textContent = ""; + summaryDetail.textContent = ""; + return; + } + summaryLine.textContent = lineParts.join(" \u2022 "); + summaryDetail.textContent = detailParts.join(" \u2022 "); + summary.style.display = "flex"; + } + function updateAtsBadge2(score) { + _updateAtsSummary(score); + const badge = document.getElementById("ats-score-badge"); + const valueEl = document.getElementById("ats-score-value"); + if (!badge || !valueEl) { + _dispatchAtsScoreUpdated(); + return; + } + if (!score || typeof score.overall !== "number") { + badge.style.display = "none"; + _dispatchAtsScoreUpdated(); + return; + } + const overall = Math.round(score.overall); + valueEl.textContent = `${overall}%`; + badge.style.display = "flex"; + badge.classList.remove("score-high", "score-medium", "score-low"); + if (overall >= 75) { + badge.classList.add("score-high"); + } else if (overall >= 50) { + badge.classList.add("score-medium"); + } else { + badge.classList.add("score-low"); + } + badge.setAttribute("aria-label", `ATS match score: ${overall}% (${score.basis || "review"})`); + _dispatchAtsScoreUpdated(); + } + async function refreshAtsScore2(basis = "review_checkpoint") { + const sessionId2 = globalThis.stateManager?.getSessionId?.(); + if (!sessionId2) return; + try { + const res = await fetch("/api/cv/ats-score", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId2, basis }) + }); + if (!res.ok) return; + const data = await res.json(); + if (data.ok && data.ats_score) { + globalThis.stateManager?.setAtsScore?.(data.ats_score); + updateAtsBadge2(data.ats_score); } - html2 += ``; - html2 += "
    "; - content.innerHTML = html2; - } catch (e) { - log5.error("Analysis parsing error:", e, "Original result:", result); - content.innerHTML = `
    \u274C

    Analysis Error

    Could not parse analysis results: ${escapeHtml(e.message)}

    Debug Info
    ${escapeHtml(JSON.stringify(result, null, 2))}
    `; + } catch (_e2) { } } - async function handleCustomizationResponse2(response) { - try { - const data = response !== null && typeof response === "object" ? response : extractFirstJsonObject(response); - if (!data) { - appendMessage("assistant", typeof response === "string" ? response : ""); - return; - } - if (data && (data.recommended_experiences || data.recommended_skills)) { - stateManager2.setTabData("customizations", data); - window.pendingRecommendations = data; - saveTabData(); - if (!isReconnectInProgress()) { - appendMessage("assistant", "\u2705 Customizations generated! Please review each section in the **Customizations** tab \u2014 Experiences, Achievements, Skills, Summary, and Publications. Select your preferences using the action buttons, then submit your decisions."); - switchTab2("exp-review"); - } - } else if (!isReconnectInProgress()) { - appendMessage("assistant", response); - } - } catch (e) { - log5.error("Customization response error:", e); - if (!isReconnectInProgress()) { - appendMessage("assistant", response); - } + var _atsRefreshTimer = null; + function scheduleAtsRefresh2(basis = "review_checkpoint") { + clearTimeout(_atsRefreshTimer); + _atsRefreshTimer = setTimeout(() => refreshAtsScore2(basis), 600); + } + function _refreshSummaryFromState() { + const score = globalThis.stateManager?.getAtsScore?.(); + if (score && typeof score.overall === "number") { + _updateAtsSummary(score); } } - async function populateReviewTab(pane) { - const content = document.getElementById("document-content"); - const customizations = stateManager2.getTabData("customizations"); - if (!window.pendingRecommendations || !customizations) { - content.innerHTML = '
    \u2699\uFE0F

    Review Customizations

    Click "Recommend Customizations" to generate recommendations.

    '; - return; + if (typeof window !== "undefined") { + window.addEventListener("cvbuilder:generation-state-changed", () => { + _refreshSummaryFromState(); + }); + } + if (typeof document !== "undefined") { + const positionTitleEl = document.getElementById("position-title"); + if (positionTitleEl && typeof MutationObserver !== "undefined") { + const titleObserver = new MutationObserver(() => { + _refreshSummaryFromState(); + }); + titleObserver.observe(positionTitleEl, { + childList: true, + characterData: true, + subtree: true + }); } - const paneConfig = { - experiences: { title: "", desc: "Sorted by date (most recent first). Click action buttons to override recommendations.", container: "experience-table-container" }, - skills: { title: "\u{1F6E0}\uFE0F Skills", desc: "Sorted by relevance. Select how to feature each skill.", container: "skills-table-container" }, - achievements: { title: "\u{1F3C6} Achievements", desc: "Select how to feature each key achievement. AI recommendations are pre-selected.", container: "achievements-table-container" }, - summary: { title: "\u{1F4DD} Professional Summary", desc: "Select which professional summary to use. The AI's recommendation is pre-selected.", container: "summary-focus-container" }, - publications: { title: "\u{1F4C4} Publications", desc: "All publications ranked by relevance. Accept or reject each for your CV.", container: "publications-table-container" } - }; - const cfg = paneConfig[pane] || {}; - const headerHtml = pane === "experiences" ? ` -

    \u2699\uFE0F Review Customization Recommendations

    -

    Review the AI's recommendations. Use the action buttons to adjust each item, then save your decisions before generating the CV.

    -
    - \u{1F4C4} - Estimated length: calculating\u2026 -
    -
    - ` : cfg.title ? `

    ${cfg.title}

    ` : ""; - const navBack = { - skills: ``, - achievements: ``, - publications: `` - }; - const navContinue = { - experiences: ``, - skills: ``, - achievements: ``, - publications: `` - }; - const navHtml = pane === "summary" ? "" : ` - `; - content.innerHTML = ` - ${headerHtml} - ${cfg.desc ? `

    ${escapeHtml(cfg.desc)}

    ` : ""} -
    - ${navHtml} - `; - window._activeReviewPane = pane; - switch (pane) { - case "experiences": - await buildExperienceReviewTable(); - _updatePageEstimate2(); - break; - case "skills": - await buildSkillsReviewTable(); - break; - case "achievements": - await buildAchievementsReviewTable(); - break; - case "summary": - await buildSummaryFocusSection(); - break; - case "publications": - await buildPublicationsReviewTable(); - break; + } + + // web/message-queue.js + var message_queue_exports = {}; + __export(message_queue_exports, { + _flushMessageQueue: () => _flushMessageQueue2, + _messageQueue: () => _messageQueue, + appendFormattedAnalysis: () => appendFormattedAnalysis2, + appendFormattedResponse: () => appendFormattedResponse, + appendLoadingMessage: () => appendLoadingMessage2, + appendMessage: () => appendMessage2, + appendMessageHtml: () => appendMessageHtml, + appendRawHtml: () => appendRawHtml2, + appendRetryMessage: () => appendRetryMessage2, + removeLoadingMessage: () => removeLoadingMessage2 + }); + var log5 = getLogger("message-queue"); + var _messageQueue = []; + function _flushMessageQueue2() { + while (_messageQueue.length) { + const { type, text: text2 } = _messageQueue.shift(); + appendMessage2(type, text2); + } + } + function appendLoadingMessage2(text2) { + const conversation = document.getElementById("conversation"); + const message = document.createElement("div"); + message.className = "message system"; + const content = document.createElement("div"); + content.className = "content loading-message"; + content.innerHTML = `
    ${text2}`; + message.appendChild(content); + conversation.appendChild(message); + conversation.scrollTop = conversation.scrollHeight; + return message; + } + function removeLoadingMessage2(messageElement) { + if (messageElement && messageElement.parentNode) { + messageElement.parentNode.removeChild(messageElement); + } + } + function appendMessageHtml(type, html2) { + const conversation = document.getElementById("conversation"); + const message = document.createElement("div"); + message.className = `message ${type}`; + const content = document.createElement("div"); + content.className = "content"; + content.innerHTML = html2; + message.appendChild(content); + conversation.appendChild(message); + conversation.scrollTop = conversation.scrollHeight; + } + var COLLAPSE_LINE_THRESHOLD = 8; + var COLLAPSE_CHAR_THRESHOLD = 480; + function _makeCollapsibleContent(html2, textStr) { + const lines = textStr.split("\n"); + if (lines.length <= COLLAPSE_LINE_THRESHOLD && textStr.length <= COLLAPSE_CHAR_THRESHOLD) { + return { html: html2, collapsible: false }; + } + const previewLines = lines.slice(0, COLLAPSE_LINE_THRESHOLD); + const previewHtml = escapeHtml2(previewLines.join("\n")).replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\n/g, "
    "); + const overflow = lines.length - COLLAPSE_LINE_THRESHOLD; + const id = `collapsible-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const wrapped = ` +
    ${previewHtml}
    + + `; + return { html: wrapped, collapsible: true }; + } + function appendMessage2(type, text2) { + const conversation = document.getElementById("conversation"); + if (!conversation) { + _messageQueue.push({ type, text: text2 }); + return null; + } + const message = document.createElement("div"); + message.className = `message ${type}`; + const content = document.createElement("div"); + content.className = "content"; + const textStr = typeof text2 === "string" ? text2 : JSON.stringify(text2, null, 2); + const renderedHtml = escapeHtml2(textStr).replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\n/g, "
    "); + if (type === "user" || type === "assistant") { + const { html: html2, collapsible } = _makeCollapsibleContent(renderedHtml, textStr); + content.innerHTML = html2; + if (collapsible) content.classList.add("collapsible-msg"); + } else { + content.innerHTML = renderedHtml; + } + message.appendChild(content); + const optionsMatch = textStr.match(/\(([^)]+\/[^)]+)\)\s*$/); + if (optionsMatch && type === "assistant") { + const options = optionsMatch[1].split("/").map((opt) => opt.trim()); + const buttonContainer = document.createElement("div"); + buttonContainer.style.cssText = "margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;"; + options.forEach((option) => { + const btn = document.createElement("button"); + btn.textContent = option; + btn.className = "action-btn"; + btn.style.cssText = "padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer;"; + btn.onclick = () => { + document.getElementById("message-input").value = option; + if (typeof sendMessage === "function") sendMessage(); + }; + buttonContainer.appendChild(btn); + }); + message.appendChild(buttonContainer); + } + conversation.appendChild(message); + conversation.scrollTop = conversation.scrollHeight; + return message; + } + function appendRetryMessage2(text2, retryFn, retryLabel = "Retry", cancelFn = null) { + const conversation = document.getElementById("conversation"); + const message = document.createElement("div"); + message.className = "message system"; + const content = document.createElement("div"); + content.className = "content"; + const escaped = escapeHtml2(text2); + content.innerHTML = escaped.replace(/\*\*(.+?)\*\*/g, "$1").replace(/\n/g, "
    "); + message.appendChild(content); + if (typeof retryFn === "function") { + const btn = document.createElement("button"); + btn.textContent = retryLabel; + btn.style.cssText = "margin-top:8px;padding:6px 14px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem;display:inline-block;margin-right:8px;"; + btn.onclick = () => { + message.remove(); + retryFn(); + }; + message.appendChild(btn); } - } - window._activeReviewPane = "experiences"; - async function switchReviewSubtab(pane) { - document.querySelectorAll(".review-subtab").forEach((btn) => { - btn.classList.toggle("active", btn.dataset.pane === pane); - }); - document.querySelectorAll(".review-pane").forEach((p) => p.style.display = "none"); - const target = document.getElementById(`review-pane-${pane}`); - if (target) target.style.display = "block"; - window._activeReviewPane = pane; - if (!window._reviewPaneLoaded || !window._reviewPaneLoaded[pane]) { - await _loadReviewPane(pane); + if (typeof cancelFn === "function") { + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "Stop retries"; + cancelBtn.style.cssText = "margin-top:8px;padding:6px 14px;background:#6b7280;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem;display:inline-block;"; + cancelBtn.onclick = () => { + message.remove(); + cancelFn(); + }; + message.appendChild(cancelBtn); } + conversation.appendChild(message); + conversation.scrollTop = conversation.scrollHeight; + return message; } - async function _loadReviewPane(pane) { - if (!window._reviewPaneLoaded) window._reviewPaneLoaded = {}; + function appendFormattedAnalysis2(result) { try { - switch (pane) { - case "experiences": - await buildExperienceReviewTable(); - break; - case "skills": - await buildSkillsReviewTable(); - break; - case "achievements": - await buildAchievementsReviewTable(); - break; - case "summary": - await buildSummaryFocusSection(); - break; - case "publications": - await buildPublicationsReviewTable(); - break; - default: - return; - } - window._reviewPaneLoaded[pane] = true; - } catch (error) { - delete window._reviewPaneLoaded[pane]; - throw error; - } - } - function _updatePageEstimate2() { - const widget = document.getElementById("page-estimate-widget"); - if (!widget) return; - const label = document.getElementById("pe-label"); - const fill = document.getElementById("pe-fill"); - const icon = document.getElementById("pe-icon"); - if (label) label.textContent = "Updating server layout estimate\u2026"; - if (fill) fill.style.width = "0%"; - if (icon) icon.textContent = "\u23F3"; - const payload = { - experience_decisions: { - ...window._savedDecisions && window._savedDecisions.experience_decisions || {}, - ...userSelections2.experiences || {} - }, - skill_decisions: { - ...window._savedDecisions && window._savedDecisions.skill_decisions || {}, - ...userSelections2.skills || {} - }, - achievement_decisions: window._savedDecisions && window._savedDecisions.achievement_decisions || window.achievementDecisions || {}, - publication_decisions: window._savedDecisions && window._savedDecisions.publication_decisions || window.publicationDecisions || {}, - summary_focus_override: window.selectedSummaryKey || null - }; - const requestId = ++pageEstimateRequestId; - clearTimeout(pageEstimateTimer); - pageEstimateTimer = setTimeout(async () => { - try { - const response = await apiCall("POST", "/api/cv/layout-estimate", payload); - if (!response.ok) throw new Error(response.error || "estimate failed"); - if (requestId !== pageEstimateRequestId) return; - const pages = Number(response.page_count_estimate || 0); - const pct = Math.min(100, pages / 3 * 100); - const confidence = Number(response.page_count_confidence || 0); - let cls = "ok"; - let colour = "#22c55e"; - let iconText = response.page_count_exact != null ? "\u2705" : "\u{1F4C4}"; - if (response.page_length_warning) { - cls = pages > 3 ? "over" : "warn"; - colour = pages > 3 ? "#ef4444" : "#f59e0b"; - iconText = pages > 3 ? "\u{1F6A8}" : "\u26A0\uFE0F"; + const cleanResult = cleanJsonResponse2(result); + const data = typeof cleanResult === "string" ? JSON.parse(cleanResult) : cleanResult; + if (data && typeof data === "object" && (data.title || data.required_skills)) { + const conversation = document.getElementById("conversation"); + const message = document.createElement("div"); + message.className = "message assistant"; + const content = document.createElement("div"); + content.className = "content job-analysis"; + let html2 = "

    \u{1F4CB} Job Analysis Complete

    "; + if (data.title) html2 += `

    Position: ${data.title} at ${data.company || "Company"}

    `; + if (data.domain) html2 += `

    Domain: ${data.domain}

    `; + if (data.required_skills?.length) { + html2 += "

    \u{1F3AF} Required Skills:

    "; } - const sourceLabel = response.page_count_exact != null ? `Exact: ${response.page_count_exact} page${response.page_count_exact === 1 ? "" : "s"}` : `Estimated: ${pages.toFixed(1)} pages`; - const confidenceLabel = response.page_count_exact != null ? "" : ` (${Math.round(confidence * 100)}% confidence)`; - const contributorText = Array.isArray(response.contributors) && response.contributors.length > 0 ? ` ${response.contributors[0]}.` : ""; - const recheckText = response.page_count_needs_exact_recheck && response.page_count_exact == null ? " Exact recheck recommended." : ""; - widget.className = `page-estimate ${cls}`; - if (label) { - label.textContent = `${sourceLabel}${confidenceLabel}.${contributorText}${recheckText}`.trim(); + if (data.preferred_skills?.length) { + html2 += "

    \u2B50 Preferred Skills:

    "; } - if (fill) { - fill.style.width = `${pct}%`; - fill.style.background = colour; + if (data.nice_to_have_requirements?.length) { + html2 += "

    \u2728 Nice to Have:

    "; } - if (icon) icon.textContent = iconText; - stateManager2.setGenerationState({ - pageCountEstimate: response.page_count_estimate ?? null, - pageCountExact: response.page_count_exact ?? null, - pageCountConfidence: response.page_count_confidence ?? null, - pageCountSource: response.page_count_source || null, - pageNeedsExactRecheck: Boolean(response.page_count_needs_exact_recheck), - pageWarning: Boolean(response.page_length_warning) - }); - } catch (error) { - if (requestId !== pageEstimateRequestId) return; - widget.className = "page-estimate warn"; - if (label) label.textContent = `Layout estimate unavailable: ${error.message}`; - if (fill) { - fill.style.width = "0%"; - fill.style.background = "#f59e0b"; + if (data.ats_keywords?.length) { + html2 += '

    \u{1F511} ATS Keywords:

    '; + data.ats_keywords.forEach((kw) => { + html2 += `${kw}`; + }); + html2 += "

    "; } - if (icon) icon.textContent = "\u26A0\uFE0F"; + content.innerHTML = html2; + message.appendChild(content); + conversation.appendChild(message); + conversation.scrollTop = conversation.scrollHeight; + } else { + appendMessage2("assistant", result); } - }, 250); - } - function handleActionClick2(itemId, action, type) { - const row = type === "experience" ? document.querySelector(`tr[data-exp-id="${itemId}"]`) : document.querySelector(`tr[data-skill="${itemId}"]`); - const buttons = row.querySelectorAll(".icon-btn"); - buttons.forEach((btn) => { - btn.classList.remove("active"); - if (btn.hasAttribute("aria-pressed")) btn.setAttribute("aria-pressed", "false"); - }); - const clickedBtn = row.querySelector(`[data-action="${action}"]`); - clickedBtn.classList.add("active"); - if (clickedBtn.hasAttribute("aria-pressed")) clickedBtn.setAttribute("aria-pressed", "true"); - if (type === "experience") { - userSelections2.experiences[itemId] = action; - window._explicitlyReviewed = window._explicitlyReviewed || { experiences: /* @__PURE__ */ new Set(), skills: /* @__PURE__ */ new Set() }; - window._explicitlyReviewed.experiences.add(itemId); - } else { - userSelections2.skills[itemId] = action; - window._explicitlyReviewed = window._explicitlyReviewed || { experiences: /* @__PURE__ */ new Set(), skills: /* @__PURE__ */ new Set() }; - window._explicitlyReviewed.skills.add(itemId); + } catch (e) { + log5.error("Analysis display error:", e); + appendMessage2("assistant", result); } - _updatePageEstimate2(); } - function bulkAction(action, type) { - const tableId = type === "experience" ? "#experience-review-table" : "#skills-review-table"; - const data = window.pendingRecommendations || {}; - const dt = $.fn.DataTable.isDataTable(tableId) ? $(tableId).DataTable() : null; - const rows = dt ? dt.rows({ search: "applied" }).nodes().toArray() : Array.from(document.querySelectorAll(`${tableId} tbody tr`)); - rows.forEach((row) => { - const expId = row.dataset.expId; - const skillId = row.dataset.skill; - const itemId = expId || skillId; - if (!itemId) return; - let resolvedAction = action; - if (action === "recommended") { - resolvedAction = type === "experience" ? _resolvedExpAction(itemId, data) : _resolvedSkillAction(itemId, data); - } - row.querySelectorAll(".icon-btn").forEach((btn) => { - btn.classList.remove("active"); - if (btn.hasAttribute("aria-pressed")) btn.setAttribute("aria-pressed", "false"); - }); - const target = row.querySelector(`[data-action="${resolvedAction}"]`); - if (target) { - target.classList.add("active"); - if (target.hasAttribute("aria-pressed")) target.setAttribute("aria-pressed", "true"); - } - if (type === "experience") { - userSelections2.experiences[itemId] = resolvedAction; + function appendFormattedResponse(response) { + try { + const data = typeof response === "string" ? JSON.parse(response) : response; + if (data && typeof data === "object" && (data.title || data.required_skills)) { + appendFormattedAnalysis2(response); } else { - userSelections2.skills[itemId] = resolvedAction; + appendMessage2("assistant", response); } - }); - _updatePageEstimate2(); - } - function _resolvedExpAction(expId, data) { - const rec = getExperienceRecommendation(expId, data); - if (rec === "Emphasize") return "emphasize"; - if (rec === "Include") return "include"; - if (rec === "De-emphasize") return "de-emphasize"; - return "exclude"; + } catch (e) { + appendMessage2("assistant", response); + } } - function _resolvedSkillAction(skillName, data) { - const rec = getSkillRecommendation(skillName, data); - if (rec === "Emphasize") return "emphasize"; - if (rec === "Include") return "include"; - if (rec === "De-emphasize") return "de-emphasize"; - return "exclude"; + function appendRawHtml2(html2) { + const conversation = document.getElementById("conversation"); + if (!conversation) return; + conversation.insertAdjacentHTML("beforeend", html2); + conversation.scrollTop = conversation.scrollHeight; } - async function populateTaglineReviewTab() { + + // web/review-table-base.js + var review_table_base_exports = {}; + __export(review_table_base_exports, { + _loadReviewPane: () => _loadReviewPane, + _resolvedExpAction: () => _resolvedExpAction, + _resolvedSkillAction: () => _resolvedSkillAction, + _updatePageEstimate: () => _updatePageEstimate2, + bulkAction: () => bulkAction, + handleActionClick: () => handleActionClick2, + handleCustomizationResponse: () => handleCustomizationResponse2, + loadTabContent: () => loadTabContent, + populateAnalysisTab: () => populateAnalysisTab, + populateReviewTab: () => populateReviewTab, + populateTaglineReviewTab: () => populateTaglineReviewTab, + showTableBasedReview: () => showTableBasedReview2, + switchReviewSubtab: () => switchReviewSubtab, + switchTab: () => switchTab2, + updateInclusionCounts: () => updateInclusionCounts2, + userSelections: () => userSelections2 + }); + var log6 = getLogger("review-table-base"); + var userSelections2 = { + experiences: {}, + // exp_id -> 'emphasize'|'include'|'de-emphasize'|'exclude' + skills: {} + // skill_name -> 'emphasize'|'include'|'de-emphasize'|'exclude' + }; + var pageEstimateTimer = null; + var pageEstimateRequestId = 0; + var _draftInputs = {}; + function _saveDraftInputsForTab(tabName) { + if (!tabName) return; const content = document.getElementById("document-content"); if (!content) return; - content.innerHTML = ` -

    \u{1F3F7}\uFE0F Applicant Tagline

    -
    - `; - if (typeof buildTaglineReviewSection === "function") { - await buildTaglineReviewSection(); + const saved = {}; + content.querySelectorAll("textarea, input, select").forEach((el) => { + if (!el.id || el.type === "file") return; + saved[el.id] = el.type === "checkbox" || el.type === "radio" ? el.checked : el.value; + }); + if (Object.keys(saved).length > 0) { + _draftInputs[tabName] = saved; } } - - // web/workflow-steps.js - var workflow_steps_exports = {}; - __export(workflow_steps_exports, { - _ACTION_LABELS: () => _ACTION_LABELS, - _STEP_DISPLAY: () => _STEP_DISPLAY, - _STEP_ORDER: () => _STEP_ORDER, - _applyBulletOrder: () => _applyBulletOrder, - _executeReRunPhase: () => _executeReRunPhase, - _getStepTooltip: () => _getStepTooltip, - _highlightChangedItems: () => _highlightChangedItems, - _markChanged: () => _markChanged, - _showAnalysisClarificationAmendModal: () => _showAnalysisClarificationAmendModal, - _showReRunConfirmModal: () => _showReRunConfirmModal, - _updateBulletArrows: () => _updateBulletArrows, - _updateViewingIndicator: () => _updateViewingIndicator2, - applyDirtyPhaseNavigationState: () => applyDirtyPhaseNavigationState, - applyLayoutFreshnessNavigationState: () => applyLayoutFreshnessNavigationState, - backToPhase: () => backToPhase, - confirmReRunPhase: () => confirmReRunPhase2, - handleStepClick: () => handleStepClick, - moveBullet: () => moveBullet, - reRunPhase: () => reRunPhase, - resetBulletOrder: () => resetBulletOrder, - saveBulletOrder: () => saveBulletOrder, - showBulletReorder: () => showBulletReorder2, - updateWorkflowSteps: () => updateWorkflowSteps - }); - var log6 = getLogger("workflow-steps"); - function _findExperienceRecommendationRecord(expId) { - const data = globalThis.window?.pendingRecommendations; - if (!data || !Array.isArray(data.experience_recommendations)) return null; - return data.experience_recommendations.find((rec) => String(rec?.id || rec?.experience_id || "") === String(expId)) || null; - } - var _STEP_ORDER = [ - "job", - "analysis", - "customizations", - "rewrite", - "spell", - "layout", - "download", - "cover_letter", - "screening", - "interview_prep", - "thank_you", - "harvest" - ]; - var _STEP_DISPLAY = { - job: "Job Input", - analysis: "Job Analysis", - customizations: "Customisations", - rewrite: "Rewrite Review", - spell: "Spell Check", - layout: "Layout Review", - download: "File Review", - cover_letter: "Cover Letter", - screening: "Screening", - interview_prep: "Interview Prep", - thank_you: "Thank You", - harvest: "Harvest" - }; - var _ACTION_LABELS = { - recommend_customizations: "Selecting experiences & skills\u2026", - generate_cv: "Generating CV\u2026" - }; - var _NAV_TAB_LABELS = { - download: "\u2B07\uFE0F File Review" - }; - function applyLayoutFreshnessNavigationState() { - const freshness = stateManager2.getLayoutFreshness(); - const generationState2 = stateManager2.getGenerationState(); - const layoutStep = document.getElementById("step-layout"); - if (layoutStep) { - layoutStep.classList.remove("stale", "stale-critical"); - const rerun = layoutStep.querySelector(".step-rerun")?.outerHTML || ""; - const refineBadge = layoutStep.querySelector(".step-inline-badge")?.outerHTML || ""; - const staleBadge = freshness.isStale ? ' Outdated' : ""; - if (freshness.isStale) { - layoutStep.classList.add("stale"); - if (freshness.isCritical) layoutStep.classList.add("stale-critical"); - } - layoutStep.innerHTML = `\u{1F3A8} Layout Review${staleBadge}${refineBadge ? ` ${refineBadge}` : ""}${rerun ? ` ${rerun}` : ""}`; - } - const showDownstreamBadge = freshness.isStale && Boolean( - generationState2.finalGeneratedAt || generationState2.phase === "final_complete" - ); - Object.entries(_NAV_TAB_LABELS).forEach(([tab, label]) => { - const tabEl = document.getElementById(`tab-${tab}`); - if (!tabEl) return; - tabEl.classList.remove("tab-stale", "tab-stale-critical"); - if (showDownstreamBadge) { - tabEl.classList.add("tab-stale"); - if (freshness.isCritical) tabEl.classList.add("tab-stale-critical"); - tabEl.innerHTML = `${label} Outdated`; + function _restoreDraftInputsForTab(tabName) { + const cached = _draftInputs[tabName]; + if (!cached) return; + Object.entries(cached).forEach(([id, value]) => { + const el = document.getElementById(id); + if (!el || el.readOnly || el.disabled || el.type === "file") return; + if (el.type === "checkbox" || el.type === "radio") { + el.checked = value; } else { - tabEl.textContent = label; + el.value = value; } }); } - async function backToPhase(step, feedback) { + function ensureTabDataState() { + return stateManager2.getAllTabData(); + } + function isReconnectInProgress() { + return stateManager2.isReconnecting(); + } + function updateInclusionCounts2() { try { - const body = feedback ? { phase: step, feedback } : { phase: step }; - const res = await fetch("/api/back-to-phase", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body) - }); - const data = await res.json(); - if (!res.ok || !data.ok) { - appendRetryMessage("\u26A0 Could not navigate back: " + (data.error || "Unknown error"), () => backToPhase(step, feedback)); - return; - } - appendMessage("assistant", `\u21BB Navigating back to ${step}. Prior decisions and approvals are preserved.`); - if (feedback) appendMessage("system", `Refinement feedback queued: "${feedback}"`); - await fetchStatus(); - const tabMap = { - job: null, - analysis: "analysis", - customizations: "goals", - rewrite: "rewrite", - spell: "spell" - }; - const resolvedTab = tabMap[step] || tabMap[data.phase] || null; - if (resolvedTab) switchTab(resolvedTab); - } catch (err) { - appendRetryMessage("\u26A0 Network error in backToPhase: " + err.message, () => backToPhase(step)); + const expDecs = window._savedDecisions && window._savedDecisions.experience_decisions || userSelections2.experiences || {}; + const skillDecs = window._savedDecisions && window._savedDecisions.skill_decisions || userSelections2.skills || {}; + const achDecs = window._savedDecisions && window._savedDecisions.achievement_decisions || window.achievementDecisions || {}; + const expIncluded = Object.values(expDecs).filter((v2) => v2 !== "exclude").length; + const skillIncluded = Object.values(skillDecs).filter((v2) => v2 !== "exclude").length; + const achIncluded = Object.values(achDecs).filter((v2) => v2 !== "exclude").length; + const expTab = document.getElementById("tab-exp-review"); + const skillTab = document.getElementById("tab-skills-review"); + const achTab = document.getElementById("tab-achievements-review"); + if (expTab) expTab.textContent = `\u{1F4CA} Experiences${expIncluded ? " (" + expIncluded + ")" : ""}`; + if (skillTab) skillTab.textContent = `\u{1F6E0}\uFE0F Skills${skillIncluded ? " (" + skillIncluded + ")" : ""}`; + if (achTab) achTab.textContent = `\u{1F3C6} Achievements${achIncluded ? " (" + achIncluded + ")" : ""}`; + } catch (e) { + log6.warn("Failed to update inclusion counts:", e); } } - function _showReRunConfirmModal(step, mode, onConfirm) { - const stepIdx = _STEP_ORDER.indexOf(step); - const downstream = _STEP_ORDER.slice(stepIdx + 1).filter((s) => { - const el = document.getElementById(`step-${s}`); - return el && el.classList.contains("completed"); + function _populateGenerateTab(cvData, content) { + if (!cvData || !cvData.files || !cvData.files.length) { + content.innerHTML = '
    \u{1F4C4}

    Generated CV

    Generate CV to see preview

    '; + return; + } + const meta = cvData.metadata || {}; + const role = meta.role || meta.position || ""; + const company = meta.company || ""; + const subtitle = role && company ? `${role} \u2014 ${company}` : role || company || "CV generated"; + const ICONS = { ".pdf": "\u{1F4C4}", ".docx": "\u{1F4DD}", ".html": "\u{1F310}" }; + const LABELS = { + ".pdf": (f) => f.includes("ATS") ? "ATS PDF" : "Human PDF", + ".docx": (f) => f.includes("ATS") ? "ATS DOCX" : f.startsWith("CoverLetter_") ? "Cover Letter" : "Human DOCX", + ".html": () => "HTML" + }; + const downloadableExts = /* @__PURE__ */ new Set([".pdf", ".docx", ".html"]); + const files = (cvData.files || []).filter((f) => { + const ext = f.slice(f.lastIndexOf(".")).toLowerCase(); + return downloadableExts.has(ext) && f !== "job_description.txt"; }); - const stepLabel = _STEP_DISPLAY[step] || step; - const title = mode === "rerun" ? `\u21BB Re-run ${stepLabel}?` : `\u2190 Navigate back to ${stepLabel}?`; - const bodyText = mode === "rerun" ? "The following stages will see updated inputs and may show changed recommendations:" : "You are navigating back past the following completed stages:"; - const note = "All existing approvals and rewrites are preserved as context."; - const listHtml = downstream.map((s) => `
  • ${_STEP_DISPLAY[s] || s}
  • `).join(""); - const overlay = document.createElement("div"); - overlay.id = "rerun-confirm-overlay"; - overlay.style.cssText = `position:fixed;inset:0;background:rgba(0,0,0,0.5); - z-index:10000;display:flex;align-items:center;justify-content:center;`; - overlay.innerHTML = ` -
    -

    - ${escapeHtml(title)}

    -

    ${escapeHtml(bodyText)}

    - -

    ${escapeHtml(note)}

    -
    - - + const sid = typeof getSessionIdFromURL === "function" ? getSessionIdFromURL() : null; + const sessionParam = sid ? `?session_id=${encodeURIComponent(sid)}` : ""; + let fileRows = ""; + for (const f of files) { + const ext = f.slice(f.lastIndexOf(".")).toLowerCase(); + const icon = ICONS[ext] || "\u{1F4C1}"; + const label = LABELS[ext] ? LABELS[ext](f) : f; + fileRows += ` +
    + ${icon} +
    +
    ${label}
    +
    ${escapeHtml(f)}
    +
    + Download +
    `; + } + content.innerHTML = ` +
    +
    + \u2705 +
    +

    CV Generated

    +
    ${escapeHtml(subtitle)}
    +
    +
    +
    + ${fileRows} +
    +
    `; - document.body.appendChild(overlay); - _focusedElementBeforeModal = document.activeElement; - if (typeof trapFocus === "function") trapFocus("rerun-confirm-overlay"); - document.getElementById("rerun-proceed-btn").focus(); - const close = () => { - overlay.remove(); - restoreFocus(); - }; - document.getElementById("rerun-cancel-btn").addEventListener("click", close); - document.getElementById("rerun-proceed-btn").addEventListener("click", () => { - close(); - onConfirm(); - }); - overlay.addEventListener("click", (e) => { - if (e.target === overlay) close(); - }); - overlay.addEventListener("keydown", (e) => { - if (e.key === "Escape") close(); - }); - } - function confirmReRunPhase2(step) { - _showReRunConfirmModal(step, "rerun", () => backToPhase(step)); - } - function _getStepTooltip(step, isActive, isViewing, isBrowsingAway, isCompleted, isStale, isStaleCritical) { - if (isStaleCritical) return isViewing ? "Critical changes \u2014 review required. Click \u21BB to rerun." : "Critical changes \u2014 review required."; - if (isStale) return isViewing ? "Results may be outdated. Click \u21BB to rerun." : "Results may be outdated."; - if (isBrowsingAway) return "Active step \u2014 click to return"; - if (isActive && isViewing) return "Current step"; - if (isCompleted && isViewing) return "Click \u21BB to rerun from here"; - if (isCompleted) return "Click to view"; - return ""; } - function _updateViewingIndicator2(tabName) { - const tabToStep = { - "job": "job", - "analysis": "analysis", - "questions": "analysis", - "exp-review": "customizations", - "rewrite": "rewrite", - "spell": "spell", - "layout": "layout", - "final_generate": "download", - "download": "download", - "cover-letter": "cover_letter", - "screening": "screening", - "interview-prep": "interview_prep", - "thank-you": "thank_you", - "harvest": "harvest" - }; - const viewedStep = tabToStep[tabName] || null; - let activeStep = null; - _STEP_ORDER.forEach((step) => { - const el = document.getElementById(`step-${step}`); - if (el && el.classList.contains("active")) activeStep = step; + var _visitedCustomiseTabs = /* @__PURE__ */ new Set(); + var _CUSTOMISE_TABS = /* @__PURE__ */ new Set(["goals", "questions", "exp-review", "ach-editor", "skills-review", "achievements-review", "tagline-review", "summary-review", "publications-review", "ats-score"]); + function _updateVisitedTabIndicators() { + _visitedCustomiseTabs.forEach((name) => { + const el = document.getElementById(`tab-${name}`); + if (el) el.classList.add("tab--visited"); }); - _STEP_ORDER.forEach((step) => { - const el = document.getElementById(`step-${step}`); - if (!el) return; - const isViewing = step === viewedStep; - const isActive = step === activeStep; - const isBrowsingAway = isActive && !!viewedStep && viewedStep !== activeStep; - const isCompleted = el.classList.contains("completed"); - const isStale = el.classList.contains("stale"); - const isStaleCritical = el.classList.contains("stale-critical"); - el.classList.toggle("viewing", isViewing); - el.classList.toggle("browsing-away", isBrowsingAway); - const tooltipText = _getStepTooltip(step, isActive, isViewing, isBrowsingAway, isCompleted, isStale, isStaleCritical); - if (tooltipText) { - el.setAttribute("data-bs-toggle", "tooltip"); - el.setAttribute("data-bs-placement", "bottom"); - el.setAttribute("data-bs-title", tooltipText); - if (typeof bootstrap !== "undefined") { - const tip = bootstrap.Tooltip.getInstance(el); - if (tip) tip.setContent({ ".tooltip-inner": tooltipText }); - else new bootstrap.Tooltip(el); - } - } else { - el.removeAttribute("data-bs-toggle"); - el.removeAttribute("data-bs-title"); - if (typeof bootstrap !== "undefined") { - const tip = bootstrap.Tooltip.getInstance(el); - if (tip) tip.dispose(); - } + } + function switchTab2(tab) { + if (typeof resetCardFocus === "function") resetCardFocus(); + _saveDraftInputsForTab(stateManager2.getCurrentTab()); + let _switchTabStage = null; + if (typeof getStageForTab === "function" && typeof updateTabBarForStage === "function") { + _switchTabStage = getStageForTab(tab); + if (_switchTabStage) { + updateTabBarForStage(_switchTabStage); + updateActionButtons(_switchTabStage); } + } + if (typeof stateManager2?.getPhase === "function" && typeof updateWorkflowStepsClickable === "function") { + updateWorkflowStepsClickable(stateManager2.getPhase()); + } + if (typeof toggleEarlyPreviewPanel === "function") toggleEarlyPreviewPanel(tab); + document.querySelectorAll(".tab").forEach((t) => { + t.classList.remove("active"); + t.setAttribute("aria-selected", "false"); + t.setAttribute("tabindex", "-1"); }); - } - async function _showAnalysisClarificationAmendModal(onProceed) { - const questions = window.postAnalysisQuestions || []; - const answers = window.questionAnswers || {}; - if (!questions.length) { - await onProceed(); - return; + const activeTab = document.getElementById(`tab-${tab}`); + if (activeTab) { + activeTab.classList.add("active"); + activeTab.setAttribute("aria-selected", "true"); + activeTab.setAttribute("tabindex", "0"); + const tabpanel = document.getElementById("document-content"); + if (tabpanel) tabpanel.setAttribute("aria-labelledby", `tab-${tab}`); } - const overlay = document.createElement("div"); - overlay.id = "clar-amend-overlay"; - overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;overflow-y:auto;"; - const formRows = questions.map((q2, i) => { - const qtype = q2.type || `clarification_${i + 1}`; - const qtext = escapeHtml(q2.question || ""); - const curAns = escapeHtml(answers[qtype] || ""); - const choices = Array.isArray(q2.choices) ? q2.choices : []; - if (choices.length) { - const radios = choices.map((c) => ` - `).join(""); - return ` -
    - - ${radios} -
    `; + stateManager2.setCurrentTab(tab); + if (_CUSTOMISE_TABS.has(tab)) { + _visitedCustomiseTabs.add(tab); + _updateVisitedTabIndicators(); + } + const announcer = document.getElementById("workflow-stage-announcer"); + if (announcer && activeTab) { + const stageLabel = _switchTabStage ? _STEP_DISPLAY[_switchTabStage] : null; + const tabLabel = activeTab.textContent.trim(); + const message = stageLabel ? `Now viewing: ${stageLabel} \u2014 ${tabLabel}` : `Now viewing: ${tabLabel}`; + announcer.textContent = ""; + setTimeout(() => { + announcer.textContent = message; + }, 50); + } + if (typeof _updateViewingIndicator === "function") _updateViewingIndicator(tab); + const content = document.getElementById("document-content"); + content.classList.toggle("full-width", tab !== "generate"); + loadTabContent(tab); + } + async function loadTabContent(tab) { + const content = document.getElementById("document-content"); + const tabData2 = ensureTabDataState(); + try { + switch (tab) { + case "job": + await populateJobTab(); + break; + case "goals": + if (typeof populateGoalsTab === "function") await populateGoalsTab(); + break; + case "analysis": + if (tabData2.analysis) { + populateAnalysisTab(tabData2.analysis); + } else { + content.innerHTML = '
    \u{1F50D}

    Job Analysis

    Click "Analyze Job" to generate analysis

    '; + } + break; + case "questions": + populateQuestionsTab(); + break; + case "customizations": + switchTab2("exp-review"); + return; + case "exp-review": + await populateReviewTab("experiences"); + break; + case "ach-editor": + await buildAchievementsEditor(); + break; + case "skills-review": + await populateReviewTab("skills"); + break; + case "achievements-review": + await populateReviewTab("achievements"); + break; + case "tagline-review": + await populateTaglineReviewTab(); + break; + case "summary-review": + await populateReviewTab("summary"); + break; + case "publications-review": + await populateReviewTab("publications"); + break; + case "ats-score": + if (typeof populateAtsScoreTab === "function") await populateAtsScoreTab(); + break; + case "rewrite": + if (window._rewritePanelCache) { + renderRewritePanel( + window._rewritePanelCache.rewrites, + window._rewritePanelCache.warnings + ); + } else { + content.innerHTML = '
    \u270F\uFE0F

    Rewrites

    Loading rewrites\u2026

    '; + try { + const data = await apiCall("GET", "/api/rewrites"); + if (data.rewrites) { + renderRewritePanel(data.rewrites, data.persuasion_warnings || []); + } else { + content.innerHTML = '
    \u270F\uFE0F

    Rewrites

    Complete customizations to reach this step

    '; + } + } catch (e) { + content.innerHTML = '
    \u270F\uFE0F

    Rewrites

    Complete customizations to reach this step

    '; + } + } + break; + case "editor": + await populateCVEditorTab(); + break; + case "generate": + _populateGenerateTab(tabData2.cv, content); + break; + case "download": + if (tabData2.cv && Object.keys(tabData2.cv).length > 0) { + await populateDownloadTab(tabData2.cv); + } else { + content.innerHTML = '
    \u2B07\uFE0F

    Download

    Generate CV to enable downloads

    '; + } + break; + case "spell": + await populateSpellCheckTab(); + break; + case "layout": + initiateLayoutInstructions(); + break; + case "finalise": + await populateFinaliseTab(); + break; + case "harvest": + if (typeof populateHarvestTab === "function") await populateHarvestTab(); + break; + case "interview-prep": + if (typeof populateInterviewPrepTab === "function") await populateInterviewPrepTab(); + break; + case "thank-you": + if (typeof populateThankYouTab === "function") await populateThankYouTab(); + break; + case "master": + await populateMasterTab(); + break; + case "cover-letter": + await populateCoverLetterTab(); + break; + case "screening": + await populateScreeningTab(); + break; } - return ` -
    - - -
    `; - }).join(""); - overlay.innerHTML = ` -
    -

    \u21BB Amend Clarification Answers

    -

    - Update your answers before rerunning analysis, or keep them as-is. -

    -
    - ${formRows} -
    -
    - - - -
    -
    `; - document.body.appendChild(overlay); - if (typeof _focusedElementBeforeModal !== "undefined") window._focusedElementBeforeModal = document.activeElement; - if (typeof trapFocus === "function") trapFocus("clar-amend-overlay"); - overlay.querySelector("#clar-amend-save").focus(); - function closeModal2() { - overlay.remove(); - if (typeof restoreFocus === "function") restoreFocus(); + } catch (error) { + log6.error(`Error loading tab ${tab}:`, error); + const errorMessage = document.createElement("p"); + errorMessage.style.cssText = "padding: 20px; color: #c41e3a;"; + errorMessage.textContent = `Error loading content: ${error.message}`; + content.appendChild(errorMessage); + return; } - overlay.querySelector("#clar-amend-cancel").addEventListener("click", closeModal2); - overlay.addEventListener("click", (e) => { - if (e.target === overlay) closeModal2(); - }); - overlay.addEventListener("keydown", (e) => { - if (e.key === "Escape") closeModal2(); - }); - overlay.querySelector("#clar-amend-keep").addEventListener("click", () => { - closeModal2(); - onProceed(); - }); - overlay.querySelector("#clar-amend-save").addEventListener("click", async () => { - const form = document.getElementById("clar-amend-form"); - const updated = {}; - questions.forEach((q2, i) => { - const qtype = q2.type || `clarification_${i + 1}`; - const el = form.querySelector(`[name="clar-${qtype}"]`); - if (!el) return; - if (el.type === "radio") { - const checked = form.querySelector(`[name="clar-${qtype}"]:checked`); - if (checked) updated[qtype] = checked.value; - } else { - const val = el.value.trim(); - if (val) updated[qtype] = val; - } - }); - closeModal2(); - try { - await fetch("/api/post-analysis-responses", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ questions, answers: updated }) - }); - window.questionAnswers = updated; - } catch (_e2) { - } - await onProceed(); - }); + _restoreDraftInputsForTab(tab); } - async function reRunPhase(step) { - if (step === "analysis") { - await _showAnalysisClarificationAmendModal(() => _executeReRunPhase("analysis")); + async function showTableBasedReview2() { + if (!window.pendingRecommendations) { + appendMessage("assistant", "No recommendations to review. Please generate customizations first."); return; } - await _executeReRunPhase(step); + switchTab2("exp-review"); + appendMessage("assistant", "\u2705 Customizations generated! Please review each section in the **Customizations** tab \u2014 Experiences, Achievements, Skills, Summary, and Publications. Select your preferences using the action buttons, then submit your decisions."); } - async function _executeReRunPhase(step) { - const loadingMsg = appendLoadingMessage(`\u21BB Re-running ${step}\u2026`); - setLoading(true, `Re-running ${step}\u2026`); + var _synonymMapCache = null; + async function _loadSynonymMap() { + if (_synonymMapCache) return _synonymMapCache; try { - const res = await fetch("/api/re-run-phase", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ phase: step }) + const sessionId2 = stateManager2.getSessionId && stateManager2.getSessionId(); + const url = sessionId2 ? `/api/synonym-map?session_id=${encodeURIComponent(sessionId2)}` : "/api/synonym-map"; + const res = await fetch(url); + if (!res.ok) return _synonymMapCache = {}; + const raw = await res.json(); + const aliasToCanon = {}; + const canonToAliases = {}; + Object.entries(raw).forEach(([alias, canon]) => { + aliasToCanon[alias.toLowerCase()] = canon; + const cl = canon.toLowerCase(); + (canonToAliases[cl] = canonToAliases[cl] || []).push(alias); }); - const data = await res.json(); - removeLoadingMessage(loadingMsg); - setLoading(false); - if (!res.ok || !data.ok) { - appendRetryMessage("\u26A0 Re-run failed: " + (data.error || "Unknown error"), () => reRunPhase(step)); - return; - } - appendMessage("assistant", `\u2705 ${step} re-run complete. Review the updated results \u2014 changed items are highlighted.`); - await fetchStatus(); - if (step === "spell") window._spellCheckCache = null; - if (step === "rewrite") window._rewritePanelCache = null; - const tabMap = { - analysis: "analysis", - customizations: "goals", - rewrite: "rewrite", - spell: "spell" - }; - if (tabMap[step]) switchTab(tabMap[step]); - if (data.prior_output && data.new_output) { - setTimeout(() => _highlightChangedItems(step, data.prior_output, data.new_output), 300); - } - } catch (err) { - removeLoadingMessage(loadingMsg); - setLoading(false); - appendRetryMessage("\u26A0 Network error in reRunPhase: " + err.message, () => reRunPhase(step)); + _synonymMapCache = { aliasToCanon, canonToAliases }; + } catch (_2) { + _synonymMapCache = {}; } + return _synonymMapCache; } - function _highlightChangedItems(step, priorOutput, newOutput) { - if (step === "rewrite") { - const priorIds = new Set((priorOutput.pending_rewrites || []).map((r) => String(r.id))); - const newItems = newOutput.pending_rewrites || []; - for (const item of newItems) { - const id = String(item.id || ""); - const cardId = id.replace(/[^a-zA-Z0-9_-]/g, "_"); - const el = document.getElementById(`rw-card-${cardId}`); - if (!el) continue; - const isNew = !priorIds.has(id); - const priorItem = (priorOutput.pending_rewrites || []).find((r) => String(r.id) === id); - const changed = isNew || priorItem && priorItem.proposed !== item.proposed; - if (changed) _markChanged(el); + function _kwSynonymAnnotation(kw, synMap) { + if (!synMap || !synMap.aliasToCanon && !synMap.canonToAliases) return ""; + const kl = kw.toLowerCase(); + if (synMap.aliasToCanon[kl]) return ` = ${synMap.aliasToCanon[kl]}`; + const aliases = synMap.canonToAliases && synMap.canonToAliases[kl]; + if (aliases && aliases.length) return ` (${aliases.join(", ")})`; + return ""; + } + async function populateAnalysisTab(result) { + const content = document.getElementById("document-content"); + try { + let data; + if (result !== null && typeof result === "object") { + data = result; + } else { + const cleanResult = cleanJsonResponse(result); + data = typeof cleanResult === "string" ? JSON.parse(cleanResult) : cleanResult; } - return; - } - if (step === "customizations") { - const priorExpIds = new Set( - (priorOutput.customizations?.experience_recommendations || []).map((r) => String(r.id)) - ); - const newExpRecs = newOutput.customizations?.experience_recommendations || []; - for (const rec of newExpRecs) { - const id = String(rec.id || ""); - const el = document.querySelector(`tr[data-exp-id="${CSS.escape(id)}"]`); - if (!el) continue; - const prior = (priorOutput.customizations?.experience_recommendations || []).find((r) => String(r.id) === id); - if (!priorExpIds.has(id) || prior && prior.recommendation !== rec.recommendation) { - _markChanged(el); + stateManager2.setTabData("analysis", result); + saveTabData(); + let html2 = '
    '; + html2 += '
    '; + html2 += `

    ${data.title || "Role"}

    `; + if (data.company) html2 += `

    \u{1F3E2} ${data.company}

    `; + html2 += '
    '; + if (data.domain) { + const conf = data.domain_confidence; + let confLabel = ""; + let confTitle = ""; + if (typeof conf === "number") { + if (conf >= 0.8) { + confLabel = ""; + confTitle = `Domain confidence: High (${Math.round(conf * 100)}%)`; + } else if (conf >= 0.6) { + confLabel = " \u26A0"; + confTitle = `Domain confidence: Medium (${Math.round(conf * 100)}%) \u2014 verify this is correct`; + } else { + confLabel = " \u26A0"; + confTitle = `Domain confidence: Low (${Math.round(conf * 100)}%) \u2014 the JD spans multiple domains; consider overriding`; + } + } + html2 += `\u{1F52C} ${escapeHtml(data.domain)}${confLabel}`; + } + if (data.role_level) html2 += `\u{1F4CA} ${data.role_level}`; + if (data.suggested_summary) html2 += `\u{1F4AC} ${data.suggested_summary}`; + html2 += "
    "; + const requiredSkills = Array.isArray(data.required_skills) ? data.required_skills : []; + const masterSkills = window._masterSkills || []; + if (requiredSkills.length > 0 && masterSkills.length > 0) { + const missing = requiredSkills.filter( + (skill) => !masterSkills.some((ms) => ms.includes(skill.toLowerCase()) || skill.toLowerCase().includes(ms)) + ); + if (missing.length > 0) { + html2 += `
    \u26A0 ${missing.length} required skill${missing.length > 1 ? "s" : ""} not found in your master CV: ${missing.join(", ")}
    `; } } - const priorSkills = new Set( - (priorOutput.customizations?.skill_recommendations || []).map((r) => (r.skill || "").toLowerCase()) - ); - const newSkillRecs = newOutput.customizations?.skill_recommendations || []; - for (const rec of newSkillRecs) { - const name = (rec.skill || "").toLowerCase(); - const el = document.querySelector(`tr[data-skill="${CSS.escape(name)}"]`); - if (!el) continue; - const prior = (priorOutput.customizations?.skill_recommendations || []).find( - (r) => (r.skill || "").toLowerCase() === name - ); - if (!priorSkills.has(name) || prior && prior.recommendation !== rec.recommendation) { - _markChanged(el); - } + if (requiredSkills.length > 0) { + html2 += '

    \u{1F3AF} Required Skills

    '; + requiredSkills.forEach((skill) => { + const isMissing = masterSkills.length > 0 && !masterSkills.some( + (ms) => ms.includes(skill.toLowerCase()) || skill.toLowerCase().includes(ms) + ); + html2 += `${skill}${isMissing ? ' (not in master CV)' : ""}`; + }); + html2 += "
    "; + } + const preferred = [ + ...Array.isArray(data.preferred_skills) ? data.preferred_skills : [], + ...Array.isArray(data.nice_to_have_requirements) ? data.nice_to_have_requirements : [] + ]; + if (preferred.length > 0) { + html2 += '

    \u2B50 Preferred / Nice-to-Have

      '; + preferred.forEach((item) => { + html2 += `
    • ${item}
    • `; + }); + html2 += "
    "; + } + const atsKws = Array.isArray(data.ats_keywords) ? data.ats_keywords : []; + if (atsKws.length > 0) { + const synMap = await _loadSynonymMap(); + html2 += '

    \u{1F511} ATS Keywords (higher rank = higher priority)

    '; + atsKws.forEach((kw, idx) => { + const annotation = _kwSynonymAnnotation(kw, synMap); + const titleAttr = annotation ? ` title="Synonym: ${escapeHtml(kw + annotation)}"` : ""; + const annotHtml = annotation ? `${escapeHtml(annotation)}` : ""; + html2 += `#${idx + 1}${escapeHtml(kw)}${annotHtml}`; + }); + html2 += "
    "; + } + const culture = Array.isArray(data.culture_indicators) ? data.culture_indicators : []; + if (culture.length > 0) { + html2 += '

    \u{1F3E2} Culture Indicators

      '; + culture.forEach((c) => { + html2 += `
    • ${c}
    • `; + }); + html2 += "
    "; + } + const mustHave = Array.isArray(data.must_have_requirements) ? data.must_have_requirements : []; + if (mustHave.length > 0) { + html2 += '

    \u2705 Must-Have Requirements

      '; + mustHave.forEach((r) => { + html2 += `
    • ${r}
    • `; + }); + html2 += "
    "; } - return; + html2 += ``; + html2 += "
    "; + content.innerHTML = html2; + } catch (e) { + log6.error("Analysis parsing error:", e, "Original result:", result); + content.innerHTML = `
    \u274C

    Analysis Error

    Could not parse analysis results: ${escapeHtml(e.message)}

    Debug Info
    ${escapeHtml(JSON.stringify(result, null, 2))}
    `; } } - function _markChanged(el) { - el.setAttribute("data-changed", "true"); - setTimeout(() => el.removeAttribute("data-changed"), 2500); - } - async function showBulletReorder2(expId, expTitle) { - let achievements = []; - let proposedOrder = null; - let hasJobAnalysis = false; - let proposedReasoning = ""; - let proposedAtsImpact = ""; - let proposedPageImpact = ""; + async function handleCustomizationResponse2(response) { try { - const detailsRes = await fetch("/api/experience-details", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ experience_id: expId }) - }); - if (!detailsRes.ok) { - let detailsErr = `HTTP ${detailsRes.status}`; - try { - const payload = await detailsRes.json(); - detailsErr = payload.error || payload.message || detailsErr; - } catch (_2) { - } - throw new Error(detailsErr); - } - const detailsData = await detailsRes.json(); - achievements = detailsData.experience && detailsData.experience.achievements || []; - const recRecord = _findExperienceRecommendationRecord(expId); - const llmBulletOrder = recRecord && typeof recRecord.bullet_order === "object" ? recRecord.bullet_order : null; - if (Array.isArray(llmBulletOrder?.order) && llmBulletOrder.order.length > 1) { - proposedOrder = llmBulletOrder.order; - hasJobAnalysis = true; - proposedReasoning = String(llmBulletOrder.reasoning || "").trim(); - proposedAtsImpact = String(llmBulletOrder.ats_impact || "").trim(); - proposedPageImpact = String(llmBulletOrder.page_length_impact || "").trim(); + const data = response !== null && typeof response === "object" ? response : extractFirstJsonObject(response); + if (!data) { + appendMessage("assistant", typeof response === "string" ? response : ""); + return; } - if (!proposedOrder) { - try { - const proposedRes = await fetch(`/api/proposed-bullet-order?experience_id=${encodeURIComponent(expId)}`); - if (proposedRes.ok) { - const proposedData = await proposedRes.json(); - proposedOrder = proposedData.proposed_order || null; - hasJobAnalysis = proposedData.has_job_analysis || false; - } else { - log6.warn("Could not load suggested bullet order:", proposedRes.status); - } - } catch (e) { - log6.warn("Could not load suggested bullet order:", e); + if (data && (data.recommended_experiences || data.recommended_skills)) { + stateManager2.setTabData("customizations", data); + window.pendingRecommendations = data; + saveTabData(); + if (!isReconnectInProgress()) { + appendMessage("assistant", "\u2705 Customizations generated! Please review each section in the **Customizations** tab \u2014 Experiences, Achievements, Skills, Summary, and Publications. Select your preferences using the action buttons, then submit your decisions."); + switchTab2("exp-review"); } + } else if (!isReconnectInProgress()) { + appendMessage("assistant", response); } } catch (e) { - const errorText = e.message === "Failed to fetch" ? "Failed to fetch (server unavailable)." : e.message; - appendRetryMessage("\u26A0 Could not load bullets: " + errorText, () => showBulletReorder2(expId, expTitle)); - return; + log6.error("Customization response error:", e); + if (!isReconnectInProgress()) { + appendMessage("assistant", response); + } } - if (!achievements.length) { - appendMessage("system", "No bullet points found for this experience."); + } + async function populateReviewTab(pane) { + const content = document.getElementById("document-content"); + const customizations = stateManager2.getTabData("customizations"); + if (!window.pendingRecommendations || !customizations) { + content.innerHTML = '
    \u2699\uFE0F

    Review Customizations

    Click "Recommend Customizations" to generate recommendations.

    '; return; } - if (typeof _focusedElementBeforeModal !== "undefined") { - _focusedElementBeforeModal = document.activeElement; - } - const modal = document.createElement("div"); - modal.id = "bullet-reorder-modal"; - modal.setAttribute("role", "dialog"); - modal.setAttribute("aria-modal", "true"); - modal.setAttribute("aria-labelledby", "bullet-reorder-title"); - modal.style.cssText = ` - position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5); - z-index:9999;display:flex;align-items:center;justify-content:center;`; - const suggestedBtn = hasJobAnalysis ? `` : ""; - const suggestionMeta = proposedReasoning || proposedAtsImpact || proposedPageImpact ? `
    - ${proposedReasoning ? `
    AI rationale: ${escapeHtml(proposedReasoning)}
    ` : ""} - ${proposedAtsImpact ? `
    ATS impact: ${escapeHtml(proposedAtsImpact)}
    ` : ""} - ${proposedPageImpact ? `
    Page length impact: ${escapeHtml(proposedPageImpact)}
    ` : ""} -
    ` : ""; - modal.innerHTML = ` -
    -
    -
    -

    \u2195 Reorder Bullets

    -
    ${expTitle}
    -
    - -
    -
    - Use \u2191 \u2193 to reorder. Bullets higher in the list appear first on your CV. - The most relevant bullet will be auto-ranked highest if you reset. -
    - ${suggestionMeta} -
      -
    -
    - ${suggestedBtn} - - -
    -
    `; - document.body.appendChild(modal); - if (typeof trapFocus === "function") trapFocus("bullet-reorder-modal"); - if (typeof setInitialFocus === "function") setInitialFocus("bullet-reorder-modal"); - const escHandler = (e) => { - if (e.key === "Escape") { - e.preventDefault(); - restoreFocus(); - modal.remove(); - document.removeEventListener("keydown", escHandler); - } + const paneConfig = { + experiences: { title: "", desc: "Sorted by date (most recent first). Click action buttons to override recommendations.", container: "experience-table-container" }, + skills: { title: "\u{1F6E0}\uFE0F Skills", desc: "Sorted by relevance. Select how to feature each skill.", container: "skills-table-container" }, + achievements: { title: "\u{1F3C6} Achievements", desc: "Select how to feature each key achievement. AI recommendations are pre-selected.", container: "achievements-table-container" }, + summary: { title: "\u{1F4DD} Professional Summary", desc: "Select which professional summary to use. The AI's recommendation is pre-selected.", container: "summary-focus-container" }, + publications: { title: "\u{1F4C4} Publications", desc: "All publications ranked by relevance. Accept or reject each for your CV.", container: "publications-table-container" } }; - document.addEventListener("keydown", escHandler); - const list = document.getElementById("bullet-reorder-list"); - achievements.forEach((ach, idx) => { - const text2 = typeof ach === "object" ? ach.text || "" : String(ach); - const li = document.createElement("li"); - li.dataset.origIndex = idx; - li.style.cssText = `display:flex;align-items:flex-start;gap:8px;padding:8px;margin-bottom:6px; - background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;`; - li.innerHTML = ` -
    - - -
    - ${text2}`; - list.appendChild(li); - }); - _updateBulletArrows(); - if (hasJobAnalysis && proposedOrder) { - document.getElementById("use-llm-order-btn")?.addEventListener("click", () => { - _applyBulletOrder(proposedOrder); - }); - } - } - function _applyBulletOrder(order) { - const list = document.getElementById("bullet-reorder-list"); - if (!list) return; - const items = Array.from(list.querySelectorAll("li")); - const byOrig = {}; - items.forEach((li) => { - byOrig[parseInt(li.dataset.origIndex, 10)] = li; - }); - const listed = new Set(order.map(Number)); - order.forEach((idx) => { - if (byOrig[idx]) list.appendChild(byOrig[idx]); - }); - items.forEach((li) => { - if (!listed.has(parseInt(li.dataset.origIndex, 10))) list.appendChild(li); - }); - _updateBulletArrows(); - } - function moveBullet(btn, direction) { - const li = btn.closest("li"); - const list = li.parentNode; - if (direction === -1 && li.previousElementSibling) { - list.insertBefore(li, li.previousElementSibling); - } else if (direction === 1 && li.nextElementSibling) { - list.insertBefore(li.nextElementSibling, li); + const cfg = paneConfig[pane] || {}; + const headerHtml = pane === "experiences" ? ` +

    \u2699\uFE0F Review Customization Recommendations

    +

    Review the AI's recommendations. Use the action buttons to adjust each item, then save your decisions before generating the CV.

    +
    + \u{1F4C4} + Estimated length: calculating\u2026 +
    +
    + ` : cfg.title ? `

    ${cfg.title}

    ` : ""; + const navBack = { + skills: ``, + achievements: ``, + publications: `` + }; + const navContinue = { + experiences: ``, + skills: ``, + achievements: ``, + publications: `` + }; + const navHtml = pane === "summary" ? "" : ` + `; + content.innerHTML = ` + ${headerHtml} + ${cfg.desc ? `

    ${escapeHtml(cfg.desc)}

    ` : ""} +
    + ${navHtml} + `; + window._activeReviewPane = pane; + switch (pane) { + case "experiences": + await buildExperienceReviewTable(); + _updatePageEstimate2(); + break; + case "skills": + await buildSkillsReviewTable(); + break; + case "achievements": + await buildAchievementsReviewTable(); + break; + case "summary": + await buildSummaryFocusSection(); + break; + case "publications": + await buildPublicationsReviewTable(); + break; } - _updateBulletArrows(); } - function _updateBulletArrows() { - const list = document.getElementById("bullet-reorder-list"); - if (!list) return; - const items = list.querySelectorAll("li"); - items.forEach((li, idx) => { - const [upBtn, downBtn] = li.querySelectorAll("button"); - upBtn.disabled = idx === 0; - downBtn.disabled = idx === items.length - 1; - upBtn.style.opacity = upBtn.disabled ? "0.3" : "1"; - downBtn.style.opacity = downBtn.disabled ? "0.3" : "1"; + window._activeReviewPane = "experiences"; + async function switchReviewSubtab(pane) { + document.querySelectorAll(".review-subtab").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.pane === pane); }); - } - async function saveBulletOrder(expId) { - const list = document.getElementById("bullet-reorder-list"); - const items = list ? list.querySelectorAll("li") : []; - const order = Array.from(items).map((li) => parseInt(li.dataset.origIndex, 10)); - try { - const res = await fetch("/api/reorder-bullets", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ experience_id: expId, order }) - }); - const data = await res.json(); - if (!res.ok || !data.ok) { - appendRetryMessage("\u26A0 Could not save bullet order: " + (data.error || "Unknown"), () => saveBulletOrder(expId)); - return; - } - appendMessage("assistant", "\u2195 Bullet order saved. It will apply when you generate the CV."); - document.getElementById("bullet-reorder-modal")?.remove(); - } catch (e) { - appendRetryMessage("\u26A0 Network error saving bullet order: " + e.message, () => saveBulletOrder(expId)); + document.querySelectorAll(".review-pane").forEach((p) => p.style.display = "none"); + const target = document.getElementById(`review-pane-${pane}`); + if (target) target.style.display = "block"; + window._activeReviewPane = pane; + if (!window._reviewPaneLoaded || !window._reviewPaneLoaded[pane]) { + await _loadReviewPane(pane); } } - async function resetBulletOrder(expId) { + async function _loadReviewPane(pane) { + if (!window._reviewPaneLoaded) window._reviewPaneLoaded = {}; try { - const res = await fetch("/api/reorder-bullets", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ experience_id: expId, order: [] }) - }); - const data = await res.json(); - if (!res.ok || !data.ok) { - appendRetryMessage("\u26A0 Could not reset bullet order: " + (data.error || "Unknown"), () => resetBulletOrder(expId)); - return; + switch (pane) { + case "experiences": + await buildExperienceReviewTable(); + break; + case "skills": + await buildSkillsReviewTable(); + break; + case "achievements": + await buildAchievementsReviewTable(); + break; + case "summary": + await buildSummaryFocusSection(); + break; + case "publications": + await buildPublicationsReviewTable(); + break; + default: + return; } - appendMessage("assistant", "\u21BA Bullet order reset. Relevance-based ordering will apply."); - restoreFocus(); - document.getElementById("bullet-reorder-modal")?.remove(); - } catch (e) { - appendRetryMessage("\u26A0 Network error resetting bullet order: " + e.message, () => resetBulletOrder(expId)); + window._reviewPaneLoaded[pane] = true; + } catch (error) { + delete window._reviewPaneLoaded[pane]; + throw error; } } - var _PHASE_PROGRESSION = [ - "init", - "job_analysis", - "customization", - "rewrite_review", - "spell_check", - "generation", - "layout_review", - "final_generation", - "refinement" - ]; - var _STEP_FWD_PHASE_MIN = { - analysis: 1, - // job_analysis - customizations: 2, - // customization - rewrite: 3, - // rewrite_review - spell: 4, - // spell_check - layout: 5, - // generation - download: 7, - // final_generation - cover_letter: 7, - screening: 7, - interview_prep: 7, - thank_you: 7, - harvest: 7 - }; - function updateWorkflowSteps(status) { - const UPCOMING = /* @__PURE__ */ new Set(); - const RE_RUN_STEPS = /* @__PURE__ */ new Set(["analysis", "customizations", "rewrite", "spell", "layout"]); - const STEP_LABELS = { - job: "\u{1F4E5} Job Input", - analysis: "\u{1F50D} Analysis", - customizations: "\u2699\uFE0F Customise", - rewrite: "\u270F\uFE0F Rewrites", - spell: "\u{1F524} Spell Check", - layout: "\u{1F3A8} Layout Review", - download: "\u2B07\uFE0F Download Files", - cover_letter: "\u{1F4E9} Cover Letter", - screening: "\u{1F4CB} Screening", - interview_prep: "\u{1F3A4} Interview Prep", - thank_you: "\u{1F64F} Thank You", - harvest: "\u{1F33E} Harvest" - }; - const phase = status.phase || ""; - const postLayout = phase === PHASES.FINAL_GENERATION || phase === PHASES.REFINEMENT; - const done = { - job: !!status.job_description, - analysis: !!status.job_analysis, - customizations: !!status.customizations, - rewrite: phase !== PHASES.REWRITE_REVIEW && !!status.customizations, - spell: phase === PHASES.GENERATION || phase === PHASES.LAYOUT_REVIEW || phase === PHASES.FINAL_GENERATION || phase === PHASES.REFINEMENT, - layout: postLayout, - download: postLayout, - cover_letter: postLayout, - screening: postLayout, - interview_prep: postLayout, - thank_you: postLayout, - harvest: postLayout - }; - const highestPhaseIdx = _PHASE_PROGRESSION.indexOf(status.highest_phase || ""); - const currentPhaseIdx = _PHASE_PROGRESSION.indexOf(phase); - const phaseToStep = { - "init": "job", - "job_analysis": "analysis", - "customization": "customizations", - "rewrite_review": "rewrite", - "spell_check": "spell", - "generation": "layout", - "layout_review": "layout", - "final_generation": "download", - "refinement": "download" + function _updatePageEstimate2() { + const widget = document.getElementById("page-estimate-widget"); + if (!widget) return; + const label = document.getElementById("pe-label"); + const fill = document.getElementById("pe-fill"); + const icon = document.getElementById("pe-icon"); + if (label) label.textContent = "Updating server layout estimate\u2026"; + if (fill) fill.style.width = "0%"; + if (icon) icon.textContent = "\u23F3"; + const payload = { + experience_decisions: { + ...window._savedDecisions && window._savedDecisions.experience_decisions || {}, + ...userSelections2.experiences || {} + }, + skill_decisions: { + ...window._savedDecisions && window._savedDecisions.skill_decisions || {}, + ...userSelections2.skills || {} + }, + achievement_decisions: window._savedDecisions && window._savedDecisions.achievement_decisions || window.achievementDecisions || {}, + publication_decisions: window._savedDecisions && window._savedDecisions.publication_decisions || window.publicationDecisions || {}, + summary_focus_override: window.selectedSummaryKey || null }; - const activeStep = phaseToStep[phase] || "job"; - const _phaseToStep2 = Object.assign({ "init": "job" }, phaseToStep); - const reentryStep = status.iterating ? _phaseToStep2[status.reentry_phase] || status.reentry_phase || null : null; - const stepIds = [ - "job", - "analysis", - "customizations", - "rewrite", - "spell", - "layout", - "download", - "cover_letter", - "screening", - "interview_prep", - "thank_you", - "harvest" - ]; - const staleSteps = new Set(status.stale_steps || []); - stepIds.forEach((step) => { - const el = document.getElementById(`step-${step}`); - if (!el) return; - if (UPCOMING.has(step)) return; - el.classList.remove("active", "completed", "clickable", "upcoming", "stale", "forward-skip"); - let label = STEP_LABELS[step] || step; - if (step === activeStep) { - el.classList.add("active"); - if (status.iterating && reentryStep === step) { - label += ' \u21BB Refining'; + const requestId = ++pageEstimateRequestId; + clearTimeout(pageEstimateTimer); + pageEstimateTimer = setTimeout(async () => { + try { + const response = await apiCall("POST", "/api/cv/layout-estimate", payload); + if (!response.ok) throw new Error(response.error || "estimate failed"); + if (requestId !== pageEstimateRequestId) return; + const pages = Number(response.page_count_estimate || 0); + const pct = Math.min(100, pages / 3 * 100); + const confidence = Number(response.page_count_confidence || 0); + let cls = "ok"; + let colour = "#22c55e"; + let iconText = response.page_count_exact != null ? "\u2705" : "\u{1F4C4}"; + if (response.page_length_warning) { + cls = pages > 3 ? "over" : "warn"; + colour = pages > 3 ? "#ef4444" : "#f59e0b"; + iconText = pages > 3 ? "\u{1F6A8}" : "\u26A0\uFE0F"; } - } else if (done[step]) { - el.classList.add("completed"); - el.classList.add("clickable"); - if (RE_RUN_STEPS.has(step)) { - const rerunLabel = _STEP_DISPLAY[step] || step; - label += ` `; + const sourceLabel = response.page_count_exact != null ? `Exact: ${response.page_count_exact} page${response.page_count_exact === 1 ? "" : "s"}` : `Estimated: ${pages.toFixed(1)} pages`; + const confidenceLabel = response.page_count_exact != null ? "" : ` (${Math.round(confidence * 100)}% confidence)`; + const contributorText = Array.isArray(response.contributors) && response.contributors.length > 0 ? ` ${response.contributors[0]}.` : ""; + const recheckText = response.page_count_needs_exact_recheck && response.page_count_exact == null ? " Exact recheck recommended." : ""; + widget.className = `page-estimate ${cls}`; + if (label) { + label.textContent = `${sourceLabel}${confidenceLabel}.${contributorText}${recheckText}`.trim(); + } + if (fill) { + fill.style.width = `${pct}%`; + fill.style.background = colour; + } + if (icon) icon.textContent = iconText; + stateManager2.setGenerationState({ + pageCountEstimate: response.page_count_estimate ?? null, + pageCountExact: response.page_count_exact ?? null, + pageCountConfidence: response.page_count_confidence ?? null, + pageCountSource: response.page_count_source || null, + pageNeedsExactRecheck: Boolean(response.page_count_needs_exact_recheck), + pageWarning: Boolean(response.page_length_warning) + }); + } catch (error) { + if (requestId !== pageEstimateRequestId) return; + widget.className = "page-estimate warn"; + if (label) label.textContent = `Layout estimate unavailable: ${error.message}`; + if (fill) { + fill.style.width = "0%"; + fill.style.background = "#f59e0b"; } - } else if (highestPhaseIdx >= 0 && (_STEP_FWD_PHASE_MIN[step] ?? Infinity) <= highestPhaseIdx && highestPhaseIdx > currentPhaseIdx) { - el.classList.add("forward-skip", "clickable"); - label += ' \u23E9'; + if (icon) icon.textContent = "\u26A0\uFE0F"; } - if (staleSteps.has(step)) el.classList.add("stale"); - const isStale = staleSteps.has(step); - const isStaleCritical = el.classList.contains("stale-critical"); - const isActive = el.classList.contains("active"); - const isCompleted = el.classList.contains("completed"); - const isFwdSkip = el.classList.contains("forward-skip"); - const srState = isStaleCritical ? " (critical \u2014 review required)" : isStale ? " (stale \u2014 results may be outdated)" : isActive ? " (current step)" : isCompleted ? " (completed)" : isFwdSkip ? " (previously completed \u2014 click to jump ahead)" : ""; - if (srState) label += `${srState}`; - el.innerHTML = label; + }, 250); + } + function handleActionClick2(itemId, action, type) { + const row = type === "experience" ? document.querySelector(`tr[data-exp-id="${itemId}"]`) : document.querySelector(`tr[data-skill="${itemId}"]`); + const buttons = row.querySelectorAll(".icon-btn"); + buttons.forEach((btn) => { + btn.classList.remove("active"); + if (btn.hasAttribute("aria-pressed")) btn.setAttribute("aria-pressed", "false"); }); - applyLayoutFreshnessNavigationState(); - if (!document.getElementById("step-rerun-style")) { - const s = document.createElement("style"); - s.id = "step-rerun-style"; - s.textContent = ".step.completed:hover .step-rerun, .step.completed:focus-within .step-rerun { opacity: 1 !important; } .step-rerun:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; opacity: 1 !important; }"; - document.head.appendChild(s); - } - if (typeof updateTabBarForStage === "function") { - updateTabBarForStage(activeStep); + const clickedBtn = row.querySelector(`[data-action="${action}"]`); + clickedBtn.classList.add("active"); + if (clickedBtn.hasAttribute("aria-pressed")) clickedBtn.setAttribute("aria-pressed", "true"); + if (type === "experience") { + userSelections2.experiences[itemId] = action; + window._explicitlyReviewed = window._explicitlyReviewed || { experiences: /* @__PURE__ */ new Set(), skills: /* @__PURE__ */ new Set() }; + window._explicitlyReviewed.experiences.add(itemId); + } else { + userSelections2.skills[itemId] = action; + window._explicitlyReviewed = window._explicitlyReviewed || { experiences: /* @__PURE__ */ new Set(), skills: /* @__PURE__ */ new Set() }; + window._explicitlyReviewed.skills.add(itemId); } - updateActionButtons(activeStep); - _updateViewingIndicator2(stateManager2.getCurrentTab()); - } - if (typeof window !== "undefined") { - window.addEventListener(GENERATION_STATE_EVENT, applyLayoutFreshnessNavigationState); - window.addEventListener(GENERATION_STATE_EVENT, applyDirtyPhaseNavigationState); + _updatePageEstimate2(); } - function applyDirtyPhaseNavigationState() { - const dirtyPhases = stateManager2.getDirtyPhases(); - const stepsToClear = []; - stepsToClear.forEach((step) => { - const el = document.getElementById(`step-${step}`); - if (!el) return; - const isDirty = dirtyPhases.includes(step); - el.classList.toggle("stale", isDirty); - const rerunHtml = el.querySelector(".step-rerun")?.outerHTML || ""; - const label = _STEP_DISPLAY[step] || step; - const badge = isDirty ? ' Content changed' : ""; - el.innerHTML = `${label}${badge}${rerunHtml ? ` ${rerunHtml}` : ""}`; + function bulkAction(action, type) { + const tableId = type === "experience" ? "#experience-review-table" : "#skills-review-table"; + const data = window.pendingRecommendations || {}; + const dt = $.fn.DataTable.isDataTable(tableId) ? $(tableId).DataTable() : null; + const rows = dt ? dt.rows({ search: "applied" }).nodes().toArray() : Array.from(document.querySelectorAll(`${tableId} tbody tr`)); + rows.forEach((row) => { + const expId = row.dataset.expId; + const skillId = row.dataset.skill; + const itemId = expId || skillId; + if (!itemId) return; + let resolvedAction = action; + if (action === "recommended") { + resolvedAction = type === "experience" ? _resolvedExpAction(itemId, data) : _resolvedSkillAction(itemId, data); + } + row.querySelectorAll(".icon-btn").forEach((btn) => { + btn.classList.remove("active"); + if (btn.hasAttribute("aria-pressed")) btn.setAttribute("aria-pressed", "false"); + }); + const target = row.querySelector(`[data-action="${resolvedAction}"]`); + if (target) { + target.classList.add("active"); + if (target.hasAttribute("aria-pressed")) target.setAttribute("aria-pressed", "true"); + } + if (type === "experience") { + userSelections2.experiences[itemId] = resolvedAction; + } else { + userSelections2.skills[itemId] = resolvedAction; + } }); + _updatePageEstimate2(); } - function _doStepNavigate(step) { - const stepToTab = { - analysis: "analysis", - customizations: "goals", - rewrite: "rewrite", - spell: "spell", - layout: "layout", - download: "final_generate", - cover_letter: "cover-letter", - screening: "screening", - interview_prep: "interview-prep", - thank_you: "thank-you", - harvest: "harvest" - }; - const tabName = stepToTab[step]; - if (!tabName) return; - if (typeof updateTabBarForStage === "function") updateTabBarForStage(step); - switchTab(tabName); + function _resolvedExpAction(expId, data) { + const rec = getExperienceRecommendation(expId, data); + if (rec === "Emphasize") return "emphasize"; + if (rec === "Include") return "include"; + if (rec === "De-emphasize") return "de-emphasize"; + return "exclude"; } - function handleStepClick(step) { - const el = document.getElementById(`step-${step}`); - if (!el) return; - if (step === "job") { - if (el.classList.contains("completed") || el.classList.contains("active")) { - switchTab("job"); - } else { - showLoadJobPanel(); - } - return; + function _resolvedSkillAction(skillName, data) { + const rec = getSkillRecommendation(skillName, data); + if (rec === "Emphasize") return "emphasize"; + if (rec === "Include") return "include"; + if (rec === "De-emphasize") return "de-emphasize"; + return "exclude"; + } + async function populateTaglineReviewTab() { + const content = document.getElementById("document-content"); + if (!content) return; + content.innerHTML = ` +

    \u{1F3F7}\uFE0F Applicant Tagline

    +
    + `; + if (typeof buildTaglineReviewSection === "function") { + await buildTaglineReviewSection(); } - if (el.classList.contains("forward-skip")) { - const label = _STEP_DISPLAY[step] || step; - if (typeof confirmDialog === "function") { - confirmDialog( - `Jump ahead to ${label}? + } -Intermediate stages may need re-running if you make changes here.`, - { confirmLabel: "Jump ahead", cancelLabel: "Stay here" } - ).then((confirmed) => { - if (confirmed) _doStepNavigate(step); - }); - } else { - _doStepNavigate(step); - } - return; - } - if (!el.classList.contains("completed") && !el.classList.contains("active")) return; - const hasUnansweredPostAnalysisQuestions = () => { - const qs = Array.isArray(window.postAnalysisQuestions) ? window.postAnalysisQuestions : []; - if (qs.length === 0) return false; - const answers = window.questionAnswers && typeof window.questionAnswers === "object" ? window.questionAnswers : {}; - return qs.some((q2) => { - const value = answers[q2.type]; - return !value || !String(value).trim(); - }); - }; - const stepToTab = { - analysis: hasUnansweredPostAnalysisQuestions() ? "questions" : "analysis", - customizations: "goals", - rewrite: "rewrite", - spell: "spell", - layout: "layout", - download: "final_generate", - cover_letter: "cover-letter", - screening: "screening", - interview_prep: "interview-prep", - thank_you: "thank-you", - harvest: "harvest" - }; - const tabName = stepToTab[step]; - if (!tabName) return; - const doNavigate = () => { - if (typeof updateTabBarForStage === "function") updateTabBarForStage(step); - switchTab(tabName); - }; - if (el.classList.contains("completed") && !el.classList.contains("active")) { - const stepIdx = _STEP_ORDER.indexOf(step); - const hasDownstreamCompleted = _STEP_ORDER.slice(stepIdx + 1).some((s) => { - const downstream = document.getElementById(`step-${s}`); - return downstream && downstream.classList.contains("completed"); - }); - if (hasDownstreamCompleted) { - _showReRunConfirmModal(step, "back-nav", doNavigate); - return; - } - } - doNavigate(); + // web/preview-render.js + function renderHtmlIntoIframe(iframeEl, html2) { + if (!iframeEl) return; + iframeEl.setAttribute("sandbox", "allow-same-origin"); + iframeEl.setAttribute("referrerpolicy", "no-referrer"); + iframeEl.srcdoc = html2; } // web/layout-instruction.js @@ -5839,9 +5860,7 @@ Intermediate stages may need re-running if you make changes here.`, if (!preview) return; showPreviewLoading(false); preview.onload = () => fitLayoutPreviewToPane(preview); - preview.setAttribute("sandbox", "allow-same-origin"); - preview.setAttribute("referrerpolicy", "no-referrer"); - preview.srcdoc = html2; + renderHtmlIntoIframe(preview, html2); const doc = preview.contentDocument || preview.contentWindow?.document; if (doc?.readyState === "complete") { fitLayoutPreviewToPane(preview); @@ -11746,11 +11765,13 @@ ${helpMessage}`); // web/questions-panel.js var questions_panel_exports = {}; __export(questions_panel_exports, { + advanceQGroup: () => advanceQGroup, askPostAnalysisQuestions: () => askPostAnalysisQuestions2, draftQuestionResponse: () => draftQuestionResponse, fetchPostAnalysisQuestionsFromApi: () => fetchPostAnalysisQuestionsFromApi, finishPostAnalysisQuestions: () => finishPostAnalysisQuestions, handleQuestionResponse: () => handleQuestionResponse2, + jumpToQGroup: () => jumpToQGroup, onQInputChange: () => onQInputChange, persistPostAnalysisState: () => persistPostAnalysisState, populateQuestionsTab: () => populateQuestionsTab2, @@ -11866,9 +11887,11 @@ ${helpMessage}`); const isLastGroup = _currentGroup === numGroups - 1; const existingAnswers = window.questionAnswers && typeof window.questionAnswers === "object" ? window.questionAnswers : {}; const groupLabel = numGroups > 1 ? `Group ${_currentGroup + 1} of ${numGroups}` : `${total} question${total > 1 ? "s" : ""}`; + const groupNavHtml = numGroups > 1 ? renderQGroupNav(numGroups, qs, existingAnswers) : ""; let panelHtml = `

    \u{1F4AC} A few quick questions

    -

    ${groupLabel} \u2014 answer each to continue.

    `; +

    ${groupLabel} \u2014 answer each to continue.

    + ${groupNavHtml}`; groupQs.forEach((q2, localIdx) => { const idx = groupStart + localIdx; const savedAnswer = (existingAnswers[q2.type] || "").toString(); @@ -11909,6 +11932,57 @@ ${helpMessage}`); }); updateQProgress(); } + function _isGroupFullyAnswered(groupIdx, qs, existingAnswers) { + const groupStart = groupIdx * GROUP_SIZE; + const groupEnd = Math.min(groupStart + GROUP_SIZE, qs.length); + for (let idx = groupStart; idx < groupEnd; idx++) { + const q2 = qs[idx]; + if (!q2) continue; + if (!(existingAnswers[q2.type] || "").toString().trim()) return false; + } + return true; + } + function renderQGroupNav(numGroups, qs, existingAnswers) { + const dots = []; + for (let i = 0; i < numGroups; i++) { + const isActive = i === _currentGroup; + const isDone = !isActive && _isGroupFullyAnswered(i, qs, existingAnswers); + const cls = ["q-group-dot"]; + if (isActive) cls.push("active"); + if (isDone) cls.push("done"); + const stateLabel = isActive ? "current" : isDone ? "completed" : "not yet answered"; + const ariaLabel = `Review answers for Group ${i + 1}, ${stateLabel} \u2014 no resubmission, your draft is saved automatically`; + dots.push( + `` + ); + } + return `
    ${dots.join("")}
    `; + } + function _flushCurrentGroupAnswers() { + const qs = window.postAnalysisQuestions || []; + const groupStart = _currentGroup * GROUP_SIZE; + const groupEnd = Math.min(groupStart + GROUP_SIZE, qs.length); + for (let idx = groupStart; idx < groupEnd; idx++) { + const q2 = qs[idx]; + const ta = document.getElementById(`q-input-${idx}`); + if (q2 && ta && ta.value.trim()) { + if (!window.questionAnswers) window.questionAnswers = {}; + window.questionAnswers[q2.type] = ta.value.trim(); + } + } + } + function advanceQGroup() { + _flushCurrentGroupAnswers(); + persistPostAnalysisState(); + _currentGroup++; + renderQuestionsPanel(); + } + function jumpToQGroup(targetGroup) { + _flushCurrentGroupAnswers(); + persistPostAnalysisState(); + _currentGroup = targetGroup; + renderQuestionsPanel(); + } async function draftQuestionResponse(idx) { const qs = window.postAnalysisQuestions || []; const q2 = qs[idx]; @@ -16221,8 +16295,8 @@ Proceed anyway?`, Go back to refine an earlier step \u2014 all prior decisions and approvals are preserved.
    -
    - @@ -204,6 +204,7 @@

    Conversation

    +
    @@ -235,7 +236,20 @@

    Conversation

    - + + + +
    @@ -253,9 +267,9 @@

    Select a tab to view content