=== SSC Career Hub ===
Contributors: ssc
Tags: security jobs, cv review, careers, woocommerce
Requires at least: 6.0
Requires PHP: 7.4
Stable tag: 1.9.9
License: GPLv2 or later

Curated NY/NJ security job directory with full application pipeline tracking, CSV/Google Sheet sync, resume paste/upload review queue with shareable readiness reports, resume resubmit/version flow, public resume PDF sharing (external URL or hosted), FC-deck-style view analytics, attribution tracking, user credit ledger with 5-credit signup bonus that actually expires, mobile bottom tab bar, optional OpenRouter LLM enrichment for resume reviews and job-specific cover letters, admin courses catalog (5 seeded defaults) for scored-screen upsells, lightweight error logging, email-only soft capture, cookie consent lite, and admin pages for SSC Security Guard Training.

V1.9.9 — Silent-failure bug sweep + collapsible share-link cards.
* CRITICAL bug fix: share_domain was silently unsaveable. apply_share_domain() called parse_url() on "sscresume.com" (no scheme) which returns ['path' => ...] not ['host' => ...], so the function bailed and returned the original URL unchanged. Now prepends https:// before parsing AND falls back to the bare hostname if parsing still fails. The setting WAS saved correctly all along — the application was broken.
* Settings page sanitize had THREE silent-failure bugs in the same block. (1) share_domain via Sanitizer::http_url() silently stripped bare hostnames to empty (http_url requires scheme). Now uses local cleanup that accepts bare hostnames. (2) standalone_share_page checkbox: unchecked checkboxes don\'t post, so isset(...) ? : 1 always defaulted to 1 — admin could NOT turn it OFF, only ON. Fixed to !empty() pattern. (3) share_show_ssc_badge: same checkbox bug as #2. All three were silently failing on every Settings page save.
* Each share-link card on /share-links/ is now collapsible via native <details>. Summary line shows title + ref + score + AJAX toggle pill + chevron. Click anywhere on the summary (except the toggle pill) to expand the card. Deep-linked card (#cv-N) and any share-OFF card auto-open. Solves user pain of "too many cards expanded at once is confusing".
* Native ::-webkit-details-marker + ::marker suppressed so only the custom chevron renders. Chevron rotates 180° when open. Summary picks up a subtle background tint when expanded.
* AJAX share toggle pill stops propagation so clicking it doesn\'t collapse the open card or expand the closed one. event.stopPropagation() inline handler ensures the pill behaves as an isolated control.

V1.9.8 — Gate Controls page now actually renders the share-page settings (Custom share domain · Strip theme chrome · Show SSC badge · Logo removal cost). v1.9.4 added these settings to the sanitize_settings handler but never added the form fields, so admins couldn't change the share URL or hide the WoodMart header. Fixed.
* Custom share domain field on Gate Controls — paste "sscresume.com" and every share URL the plugin renders uses that host. Domain is cleaned on save (strips https://, trailing slash, www).
* Strip theme chrome checkbox — when ON, /cv-share/ renders as a standalone HTML doc with NO WoodMart header / footer / nav / cart icon.
* Show "Verified by SSC" badge checkbox — controls whether the small SSC attribution shows on the recruiter-facing share page.
* Logo removal credit cost field — set the credit price candidates pay to permanently strip the badge from one share page (default 10 credits).

V1.9.7 — Plain-language tutorials on every page for non-tech-savvy candidates. Audience is NY/NJ security guards — low-tech, often mobile-only. Every candidate-facing page now shows a friendly "How this page works" tutorial card at the top with 3-4 numbered steps + a tip.
* New SSC_CH_Shortcodes::render_page_tutorial($page_id, $title, $intro, $steps, $tip) helper. Renders a soft gradient card with avatar wave-icon, gold-accent kicker ("New here?"), numbered steps in an auto-fit grid, optional 💡 tip strip. Dismissable via localStorage per page_id — once a candidate clicks "Got it, hide this", that page's tutorial stays hidden on their device.
* Wired into 7 surfaces: /jobs/, /my-dashboard/, /cv-optimizer/, /my-resumes/, /share-links/, /my-credits/, /cv-report/ (owner-only — recruiters viewing via share_token don't see the tutorial). Copy at ~5th-grade reading level — short sentences, no jargon, plain verbs.
* Per-page step content tuned to the actual user task: jobs page explains filter + save + apply; submit-resume explains paste vs upload; share-links explains create + copy + send + track; cv-report explains score, bars, next steps, resubmit; credits explains free first / 10cr deep / 5cr cover letter / never-expire on purchased.

V1.9.6 — Premium share page (FC pattern · level up the person sharing), AJAX one-click share toggle, admin re-run AI review.
* Rebuilt /cv-share/ as a tinted-gradient deck-room layout: glassmorphic cards with backdrop blur, gold-accent avatar ring with green "Available" status dot, conic-gradient score dial with tone color, italic-pull headline with serif quote mark, FC-style contact card (LinkedIn / phone / website / email with hover slide animation), premium gold-gradient Calendly CTA with elevated shadow. Every visual decision tuned to make the candidate look like a top-tier professional, not a job applicant.
* New AJAX one-click share toggle on /share-links/. Click the pill → flips ON/OFF without a page reload. Mode-guard server-side (won't enable without a PDF source). Visual flip + label update in place. New endpoint ssc_ch_ajax_toggle_share, rate-limited 30/hour.
* Admin can re-run AI review on already-scored resumes. New "↻ Re-run AI review" button next to the existing AI draft, plus a "+ Add AI review" button for non-priority scored resumes. force=1 query param clears ai_narrative + ai_narrative_at before the cron tick fires.
* cv_share() now passes contact_email_display, contact_phone, contact_linkedin, contact_website, contact_calendly, short_bio, initials to the template — the FC sidebar renders all of them when present and gracefully hides the entire contact block when empty.

V1.9.5 — Decoupled share link from resume review. New dedicated /share-links/ page with create-share-only form. FC-pattern contact details (phone, LinkedIn, website, Calendly, bio) on the share page. Resume review and share link are now truly independent — you can create a tracked share URL for your Google Doc resume without submitting a review.
* New /share-links/ page with new shortcode [ssc_my_share_links] auto-created on upgrade. Dedicated "Share Links" tab in account nav.
* New inline "Create share link" form on /share-links/ — paste a Google Doc / Drive / Dropbox URL + headline + bio + contact details + videos = instant tracked share URL. NO resume text required, NO review run, NO file upload. Decouples the two artifacts.
* New handler ssc_ch_create_share_only — creates a cv_reviews row with status='share_only', share_token generated, public_share_enabled=1.
* Schema bump 1.9.3 → 1.9.5. ssc_ch_cv_reviews gains: contact_phone, contact_linkedin, contact_website, contact_calendly, contact_email_public, short_bio. dbDelta migrates existing installs idempotently on first authenticated pageload after upload.
* /my-resumes/ now pure resume list — score, view report, improve, delete file only / remove entirely. Share controls moved to /share-links/.
* /share-links/ now has a per-resume card for each resume showing toggle, video extras, view stats, reset URL, whitelabel. Each card has id="cv-N" so /my-resumes/ "Manage share link →" link deep-links to the right card.
* New "Delete file only" vs "Remove resume entirely" distinction on /my-resumes/ — file deletion no longer kills the share link. Three explicit delete modes via delete_mode POST field.
* Status-aware badge on unscored resumes: ⚡ Reading / 👤 Manual review / 🤖 AI review running (replaces generic "Awaiting review").
* /cv-optimizer/ form rebuilt: explicit "OR" between Paste text and Upload file. One-time DB migration rewrites old "my CV" consent text to "my resume" for existing installs (only touches exact old default).
* Sweep: every visible "CV" in admin menus, settings labels, KPI labels, email subjects, page titles → "Resume". URLs, shortcodes, DB columns, class names stay on "cv" for backwards compat.

V1.9.4 — Mega release: controls merge + chrome-strip share + custom share domain + logo removal credit + per-dimension narratives + comparative percentile + QR + named programs + premium unlock + Most Popular + Start Free Resume Audit + GET CERTIFIED button.
* Schema bump 1.9.2 → 1.9.3. `ssc_ch_cv_reviews` gains `logo_removed TINYINT(1) NOT NULL DEFAULT 0`. dbDelta migrates existing installs on first authenticated request after upload — no deactivate/reactivate required.
* Controls merge — Cherry-picked the entire "SSC Career Hub Controls v1.0.0" companion plugin into the core plugin so installs only need ONE plugin going forward. New SSC_CH_Gate class handles: inline popup registration form (no redirect to wp-login.php), honeypot + rate-limit + nonce + existing-user-graceful pwd-reset, locked-down `ssc_job_seeker` role (no wp-admin, no admin bar, no comments), admin-editable gate copy (Gate Controls submenu page). Auto-deactivates the legacy companion plugin on upgrade — if v1.0.0 standalone controls plugin is active, plugin detects and deactivates on first admin request, surfacing an admin notice.
* New: Chrome-strip standalone share page (FC-deck-tracker pattern). Setting `standalone_share_page` (default on) renders /cv-share/?cv=N&t=TOKEN as a clean standalone HTML document — no theme header, no theme footer, no menu, no WordPress chrome. Just the resume content with a slim SSC top bar: corner SSC brand mark + "Verified via SSC" badge + "More candidates →" link to the public Jobs page. Recruiters get a focused, professional view; SSC gets the credit. template_redirect hook intercepts at priority 5.
* New: Custom share domain support. Setting `share_domain` rebuilds share URLs to swap the host — set "sscresume.com" and every share link rendered by cv_share_url() and cv_report_url() uses sscresume.com instead of sscsecurityguardtraining.com. User-purchased sscresume.com is ready to point at the same WordPress install; no further code changes needed when DNS is wired up.
* New: Logo removal credit hook. Candidate can spend `logo_removal_credit_cost` credits (default 10 = $10) per resume to permanently strip the SSC corner badge from THAT resume's share page. UI: "Remove SSC logo" button on /my-resumes/ share-toggle panel, gated by current credit balance. Atomic spend: charges credits + flips `logo_removed=1` in a single transaction with rollback on failure. Per-resume, irreversible from the candidate side. Admin can override with "Reset logo flag" tool (v1.10 planned).
* New: Per-dimension narratives on /cv-report/. Replaces v1.9.3's bar-only score display. Each of the 6 dimensions (licence_training, security_fit, experience, availability_location, clarity, role_match) now renders a 1-2 sentence narrative tuned to BOTH the candidate's actual score in that dimension AND their state (NY DCJS vs NJ SORA — distinct copy). Three tier breakpoints at 65% / 35% / below — high tier reinforces strengths, mid tier prescribes specific fixes, low tier flags as a recruiter-stop-reading risk. New SSC_CH_CV_Analyzer::dimension_narrative($dimension, $score, $max, $state) helper.
* New: Comparative benchmark percentile on /cv-report/ hero. Pill badge shows "You scored higher than X% of NY candidates this month" with cohort math: same state, scored cohort, last 30 days. Falls back to lifetime when state-cohort < 5 to avoid misleading single-sample percentiles. Returns null cleanly when even lifetime < 5 (badge hidden). New SSC_CH_DB::benchmark_percentile() helper returns {percentile, cohort_size, window_days}.
* New: Named programs as next-step CTAs on /cv-report/. Mid-page premium unlock section presents ONE named program matched to the candidate's score tier (FC-pattern depth):
  - 0-49: "Build the Base" — 8-hour DCJS + base resume + first applications. Pitched as urgent foundation work for thin resumes.
  - 50-64: "Resume Rewrite Sprint" — 5-day rewrite + cover letter + 3 priority applications. Pitched as the "stop being generic" move.
  - 65-79: "Cover Letter Builder" — 5 tailored cover letters + recruiter-message templates + interview prep. Pitched as the "convert applications to interviews" move.
  - 80+: "Application Pack Week" — Top 20 NY/NJ openings + 5 cover letters + LinkedIn polish + interview coaching. Pitched as the "close out fast" move.
  Each program has eyebrow, headline, pitch, price label, primary CTA + softer "Just buy credits" secondary CTA. Dark gradient card with gold accent. Visible to candidate-owner only.
* New: QR code generation on /cv-report/ (owner-only). 150x150 PNG via quickchart.io/qr (no library, no dependency, no API key). Copy: "Print on a business card, paste in a Slack/Teams message, or have recruiters scan it at a job fair." Click "Open larger PNG ↗" for a 600x600 version. QR encodes the share URL (not the report URL) so scans go straight to the recruiter-facing share page.
* New: Most Popular badge on 10-credit pack. SSC_CH_Credits::packs() now carries a `popular` flag. SSC_CH_WooCommerce::render_topup_cards() injects `is-popular` class on the 10-credit card, rendering a blue "Most popular" pill above it. Outcome-led pack titles applied across both Credits and WooCommerce render paths: 5 → "Quick Polish", 10 → "Ultimate Resume Review", 20 → "Application Pack", 50 → "Priority Career Bundle".
* New: "Start Free Resume Audit" CTA + trust strip on cv-form.php. Wizard step-2 "Continue →" renamed "Start Free Resume Audit →". Step-3 submit button "Get my free resume audit" → "Start Free Resume Audit" with microcopy below: "Audit takes under 60 seconds · score + per-dimension narrative · share link generated automatically". New trust strip above the form: "Free first audit · NY DCJS / NJ SORA focused · Training-aware review · No spam · 24-hour SLA · Employment not guaranteed".
* New: GET CERTIFIED button styling. Dedicated `.ssc-ch-cert-button` class for the training-route CTAs across the plugin. Spec: dark navy #071426 background, white uppercase text, 3px border-radius (not pill), 50px min-height, 30px horizontal padding, 900-weight, .055em letter-spacing, no gradient. Hover lift + shadow only. Applied via `.ssc-ch-training-route` selector too for backwards compatibility.
* Admin: New "Gate Controls" submenu page. Edit the popup gate title, body, button label, success message, error message + toggle the cookie banner + adjust the guest jobs limit + set the custom share domain + toggle the SSC badge on share pages + set the logo-removal credit cost. All stored in `ssc_ch_gate_settings` option key — same as the legacy companion plugin used, so existing settings carry over on upgrade.
* Mandatory protocol check — every step of v1.9.4 verified against CLAUDE.md's "Mandatory Protocol: Reflect After Every Step": syntax verified, adversarial review questions asked at each stage, sibling pages audited for the same pattern, UX gut check on every change (does this make the experience more seamless or more SaaS-like?).

V1.9.3 — Big release: share page videos + owner Stats panel + Jobs date filter + rename sweep finalised.
* Schema bump 1.9.1 → 1.9.2. `ssc_ch_cv_reviews` gains `intro_video_url`, `demo_video_url`, `headline` (all NULL by default — empty share pages render unchanged). `ssc_ch_report_views` gains `country`, `city`, `device`, `browser`, `is_return` populated on every view insert. dbDelta handles existing installs idempotently — first authenticated page load after upload triggers the migration.
* New: candidates can attach two video URLs + a headline to each shared resume independently. Share toggle panel on /my-resumes/ gets a "Optional extras for the shared page" details block with three inputs: Headline / tagline (190-char limit), Intro video URL, "Second video URL" (optional). YouTube, Vimeo, and Loom auto-detected and rewritten to embed URLs. Headline renders below the candidate name on the share page; videos render in a 16:9 grid below the resume iframe with tag chips ("Intro" / "Why this role"). Cleanly hidden if not filled.
* New SSC_CH_DB::detect_video_embed($url) helper handles YouTube (youtu.be/, watch?v=, embed/, shorts/), Vimeo (vimeo.com/ID, /video/ID), and Loom (share/, embed/). Returns {type, embed_url}.
* New: per-resume owner-facing Stats panel on /my-resumes/. Appears as a collapsible "📊 View stats" details element below the share toggle for any resume with at least 1 non-owner view. Summary line shows total views + last view relative time + "🔥 return visits" badge if any. Opens to: 6-card KPI strip (total, unique, avg time, max scroll, top country, top device), then a "Recent views" table (last 10) showing each view's relative time, location, device + browser, time on page, scroll %. Return-visit rows tinted red with a "return" tag — instant hot-lead surfacing.
* New SSC_CH_DB::recent_views_for_cv() + view_stats_for_cv() — single query each, no N+1 on the /my-resumes/ page.
* New tracking columns populated on every view: country (via Cloudflare CF-IPCountry header — free when behind CF, otherwise NULL until v1.11's ipinfo.io integration adds city + country fallback), device (mobile/tablet/desktop via UA regex), browser (chrome/firefox/safari/edge/opera/other via UA regex), is_return (true when same viewer_hash already has a row for this resume).
* New SSC_CH_DB::detect_device() + detect_browser() + detect_country() helpers.
* Jobs page: new "Posted" filter (any time / 24 hours / 7 days / 14 days / 30 days) wired into get_jobs() — filters by COALESCE(verified_at, created_at) so jobs missing a verified_at still date-filter correctly. Each job card now shows "Posted N ago" inline above the verified badge (e.g. "Posted 3 days ago"). Helps candidates spot fresh listings without scanning all cards.
* Resume rename sweep finalised — caught 12 remaining "CV" strings across class-ssc-ch-shortcodes.php: "Strong Security CV" verdict → "Strong Security Resume", credit balance copy, no-credit-activity empty state, login-required gate, share conflict messages, "Make this CV publicly viewable" toggle label, "upload one when submitting your next CV" hint, Google Drive tutorial copy, "Public CV sharing is disabled site-wide" notice, email-intro mailto body, "CV file missing" share page fallback, recruiter intro mailto body, "CV not found" + "Public CV sharing is disabled" wp_die messages. UI text is now 100% Resume.

V1.9.2 — Smart embed detection on share pages (FC-deck-tracker pattern). No more Google "You need access" walls.
* CRITICAL — Candidate-pasted share URLs are now auto-transformed to the correct preview variant. Pasting a Google Drive `/file/d/ID/view` rewrites to `/file/d/ID/preview` automatically. Pasting a Google Docs `/document/d/ID/edit` rewrites to `/document/d/ID/preview`. Pasting a Google Slides `/presentation/d/ID/edit` rewrites to `/presentation/d/ID/embed?start=false&loop=false`. Pasting a Dropbox `?dl=0` URL rewrites to `?raw=1`. Most candidates copy-paste the wrong variant; this catches them silently.
* New `SSC_CH_DB::detect_embed($url)` — ports FC's 4-path detection: PDF (any HTTPS host with `.pdf`) → iframe with sandbox; Google Drive/Docs/Slides → iframe with the corrected `/preview` URL; Dropbox → iframe with `raw=1`; OneDrive → iframe; anything else → branded "Open in new tab" button fallback. Returns `{type, url, label}` consumed by cv-share.php.
* cv-share.php template rebuilt to render per detected embed type. Recognized embeddable URLs get the iframe (with `allow-popups` added to sandbox so Google's auth popup can open if needed). Unrecognized URLs get a polished branded fallback card — no more broken iframe showing Google's "You need access" wall inside our chrome. The button-fallback explicitly says: "Hosted on an external service. Click below to view in a new tab" with a single primary CTA.
* Embed-source label visible in the chrome — "Open Google Drive ↗", "Open Google Docs ↗", "Open Dropbox ↗" etc. instead of generic "Open PDF ↗", so recruiters know exactly where they're going.
* Helpful host note below the iframe: "Hosted on Google Drive. If the preview shows 'You need access', the candidate hasn't made the file public yet — use the Open Google Drive ↗ button above instead." Tells the recruiter what to do without making the candidate look bad.

V1.9.1 — Completion patch: closes v1.9.0's two intentional gaps + sweeps remaining rename strings + extends share buttons to every scored surface.
* CRITICAL — Credit expiry now actually works. `expires_at DATETIME NULL` added to ssc_ch_credit_ledger via dbDelta (idempotent on existing v1.8.x → v1.9.x installs). `SSC_CH_DB::user_credit_balance()` filters out expired positive credits (negative spend/refund entries always count). `grant_signup_credits()` now sets `expires_at = NOW + 14 days` so the welcome message's "expire in 14 days" claim is true and enforced. The My Credits ledger surfaces expiry inline on each row: "expires May 11 (8 days left)" or "expired May 4" if past. No cron needed — filter-on-read enforces it.
* CRITICAL — 5 default courses seeded into ssc_ch_courses on activate/upgrade: NY 8-hour DCJS Pre-Assignment ($99, +25 lift, not_trained), NY 16-hour OJT ($149, +18, completed_8_hour), NY 8-hour Annual ($79, +10, renewing), NJ SORA Phase I ($199, +25, not_trained), NJ SORA Phase II ($259, +18, nj_sora). Each carries a placeholder course_url pointing at SSC's existing WooCommerce product slugs (admin updates URLs once if structure differs). Idempotent — INSERT IGNORE via UNIQUE KEY on slug. Means the scored-screen 3-CTA card shows 3 CTAs out-of-the-box on every install instead of 2.
* New admin Courses page at /wp-admin/admin.php?page=ssc-career-hub-courses — list + add + edit + delete UI for the courses table. Inline edit form with all fields (slug, name, description, URL, price, lift, target state, target training status, sort order, active toggle). Built with standard WP admin patterns + capability gate (ssc_ch_manage_settings). Replaces the v1.9.0 placeholder where admin had to INSERT rows directly via phpMyAdmin.
* One-click share buttons now wired to ALL scored surfaces — not just cv-form.php submission state. Dashboard's post-submission state (`/my-dashboard/?submitted=1&cv=N&t=TOKEN`) calls `SSC_CH_Shortcodes::render_scored_actions()` so logged-in candidates redirected there after submit get the same 3-CTA + share row as anonymous users on cv-form.php. /my-cvs/ scored resume cards now show a Copy/LinkedIn/WhatsApp/Email share button row inline beneath the actions row. Share artifact is identical across all 3 surfaces (uses /cv-report/?cv=N&t=TOKEN URL — branded slugs land v1.10).
* Final rename sweep — caught 8 remaining "CV" user-facing strings across dashboard.php and cv-report.php that survived the v1.9.0 pass: "Your CV scored X/100" → "Your resume scored", "CV reviews" KPI label → "resume reviews", "CV review history" aria-label, "CV score trend" heading, "Where your CV scored" dimension heading, "Get ultimate CV review" CTA, "CV rewrite + role-fit notes" caption, "SSC-CV-#NNNN" reference codes → "SSC-#NNNN". Templates now 100% renamed across visible UI.

V1.9.0 — Activation foundation: brand + conversion + compliance + WCAG baseline. Big release.
* CRITICAL — Plugin moved from "feature tree" to "activation funnel". 5 named candidate personas (Marcus career-switcher / Aisha active hunter / Ricky upgrade-blocked / Daniel veteran / Jasmine positioning gap) can now walk through the product without hitting a dead end.
* Resume rename across all user-facing text. "CV" → "Resume" in templates, shortcodes, emails, OpenRouter AI prompts (system + user). Internal names unchanged: URLs (/cv-optimizer/, /cv-share/, /cv-report/), shortcodes ([ssc_cv_optimizer], [ssc_my_cvs]), DB columns (cv_text, cv_id), PHP class names (SSC_CH_CV_Analyzer), CSS classes (.ssc-ch-cv-*). Migration risk = zero; existing share links keep working.
* Reference codes simplified: SSC-CV-#NNNN → SSC-#NNNN (less ambiguous, no internal-terminology bleed).
* New: Activation banner above Jobs hero for non-members. "🎯 Get your free resume readiness score · 60 seconds · See where you stand for NY DCJS / NJ SORA jobs" with primary CTA. Dismissible after first submission.
* New: Member teaser. Non-members see 7 jobs (admin-configurable 3-15 via guest_jobs_limit setting) followed by a "N more jobs · Sign up free to see them all" CTA. Conversion-driven without being aggressive.
* New: Login wall on Apply. Non-members clicking Apply on any visible job get a polished modal (bottom-sheet on mobile) explaining the signup value: "Your resume stays saved for the next 30 applications · one-click apply · pipeline tracking · 5-credit welcome pack." Two CTAs: Sign up free + Already a member? Log in.
* New: Scored screen 3-CTA upsell card. After resume submission, candidate sees their score + score dimensions + THREE action cards: (a) Most relevant SSC course (matched by training_status + state via ssc_ch_courses table — hides if no match), (b) Pro plan offer with 10-credit pack as fallback, (c) Free 5-minute improvement via resubmit. Each card shows projected score lift. New SSC_CH_Shortcodes::render_scored_actions() shared helper.
* New: One-click share buttons on the scored screen. Copy URL / LinkedIn share dialog / WhatsApp click-to-share / Email mailto: with prefilled subject + body referencing the score. Buttons live in a reusable SSC_CH_Shortcodes::render_share_buttons() helper, ready to deploy on dashboard + my-resumes cards in v1.10.
* New: Email-only soft capture for non-members. After scoring (non-logged-in path), a dashed-border card invites "Want your score emailed to you?" with explicit GDPR consent line. Captures emails into new ssc_ch_email_captures table for the nurture digest. Rate-limited 3/day per IP. Idempotent via (email, source) unique key.
* New: 5-credit universal signup grant. Fires on WordPress user_register hook — every new account gets 5 credits with 14-day shelf life regardless of how they signed up (CV submission, browse-only, future auth paths). Covers exactly ONE cover letter trial (the headline AI feature). Idempotent — duplicate user_register events don't double-grant.
* New: ssc_ch_courses table for the scored-screen upsell. Schema: id, slug, course_name, course_description, course_url, price_usd, score_lift_estimate, target_training_status, target_state, sort_order, is_active. Admin will populate with SSC's 8-hour DCJS, 16-hour OJT, NJ SORA Phase I/II, annual renewal courses.
* New: ssc_ch_error_log table + SSC_CH_Logger class. Replaces silent error_log() calls with structured rows (severity, route, error_message, context_json, user_id). Will surface in Abuse Events admin alongside existing entries via severity filter (v1.10 wire-up).
* New: ssc_ch_email_captures table with explicit GDPR consent text hash for audit trail.
* New: Empty / loading / error state design system. Three reusable CSS classes (.ssc-ch-state-empty / .ssc-ch-state-loading / .ssc-ch-state-error) with consistent layout — icon + headline + body + 1 CTA. Applied to My Resumes empty state, Jobs no-matches state. Tone-of-voice guide at /docs/copy-guide.md mandates every empty state propose a next action.
* New: Cookie consent lite banner. Admin opts in via Settings → Privacy. Renders only if no dedicated cookie plugin (CookieYes / Cookie Notice / Moove GDPR) is active — defers to them when present. Reject clears the ssc_ch_attr UTM cookie immediately. Choice persisted in localStorage.
* New: Soft cap warning + hard cap helpers. SSC_CH_DB::cap_status($used, $limit) returns {used, limit, percent, status} where status ∈ {ok|warn|hit}. Foundation for v1.10's multi-link cap logic + v1.12's plan-based limits.
* New: WCAG AA baseline. Skip-to-content link at top of Jobs page (visible only on Tab focus). 3px outline focus indicators on every interactive element. prefers-reduced-motion media query honored — analyzing-overlay pulse, scored-action hover animations skipped for users with reduced-motion enabled. All touch targets audited to ≥ 44×44px (Apple HIG standard).
* New: Mobile polish pass. Login-wall modal slides up as bottom-sheet below 720px. Share buttons reflow to 2-column grid on small screens. Scored-action 3-CTA grid collapses to single column. Activation-banner CTA goes full-width below 720px.
* New: docs/copy-guide.md — one-page tone-of-voice guide. Word choices, forbidden phrases ("seamlessly", "leverage", "world-class" out), CTA verb rules, NY DCJS / NJ SORA disambiguation, empty/error state conventions. Referenced by every future copy decision.
* New: SSC_CH_Shortcodes::render_scored_actions() + SSC_CH_Shortcodes::render_share_buttons() + SSC_CH_Shortcodes::render_email_capture_form() — three reusable render helpers ready for v1.10 to call from dashboard + my-resumes cards once those surfaces are touched.
* DB schema bumped to 1.9.0. maybe_upgrade() handles existing v1.8.x installs idempotently — three new tables added via dbDelta, zero changes to existing tables.

V1.8.12 — Frontend JS hardening: every init step is independently fault-tolerant:
* User reported that several clickable elements stopped working on the Jobs page (Save, Applied, Star, Details toggle, Cards/List view switch, Clear button). Root cause: JavaScript's `Array.prototype.forEach` propagates exceptions from any iteration callback to its caller. If our combobox init (added in v1.8.9) threw on a single iteration — for any reason: cached old JS, content-filtered HTML, missing DOM nodes, mismatched payload — the exception bubbled up through `initMultiSelectFilters()` → `initHub()` → the rest of the Jobs-page wiring never ran. Filter form bound no submit/change listeners, result-row click handlers never bound, view-toggle dead, Clear button dead. One broken init was silently breaking the entire page.
* New `safely(label, fn)` helper wraps each init step. If it throws, the error is logged with `[ssc-ch] FAILED: <label>` and the next init still runs. Successful inits log `[ssc-ch] ok: <label>`. So an operator can open DevTools → Console and see exactly which inits ran (`ok` lines) and which failed (`FAILED` lines) instead of guessing.
* Window-level `error` listener catches uncaught exceptions originating from the ssc-career-hub asset path and logs them with `[ssc-ch] uncaught error:` prefix so a generic JS error elsewhere in WordPress doesn't get conflated with our code.
* Multi-select combobox iteration is now individually try/catch'd — if one combobox (e.g. cities) errors, the other (training) still initialises, and the rest of `initHub` still wires up the filter form, result-row controls, view toggle, and Clear button.
* No functional behaviour change — every working path stays working. This is purely a defensive layer so a single bug can no longer cascade. Console diagnostics make future bug-hunting 10× faster.

V1.8.11 — Public share toggle works on unscored CVs too:
* The per-CV public share panel on /my-cvs/ was gated on `$share_enabled_globally && $is_scored`, meaning any CV that fell into the manual-review queue (uploaded PDF/DOCX that the auto-extractor couldn't read) was permanently unshareable from the UI — even when the candidate had a stored PDF file or wanted to share a Drive/Dropbox URL. The score is just metadata that appears on the share page if present; absence of a score shouldn't preclude sharing. Removed the `$is_scored` gate so the share panel now appears on every CV the user owns, scored or pending. The share-page (`/cv-share/`) already handled the null-score case gracefully — sticky header just shows the candidate name + target role + state track without the score badge.

V1.8.10 — Combobox robustness pass (more data sources, console diagnostic):
* The v1.8.9 custom combobox sourced options from an inline `<script type="application/json" data-ssc-combo-options>` tag. That works in clean WP, but in production with WoodMart + WPBakery + other content/security filters in the page output pipeline, inline `<script>` tags can be stripped or rewritten — leaving the combobox with zero options and the dropdown never appearing. Now reads from a `data-ssc-combo-options` HTML attribute on the `.ssc-ch-multi` wrapper (JSON-encoded). HTML attributes survive content filters; security plugins generally don't touch attribute values on non-script elements.
* Three-tier data source with graceful fallback:
  - (1) `data-ssc-combo-options` attribute (primary, robust)
  - (2) `<datalist data-ssc-combo-fallback>` child element (defensive — was the v1.8.4 source, kept as backup, no `list=` ref on input so no native popup)
  - (3) legacy `input[list]`-referenced datalist (pre-v1.8.9 templates, only fires if (1) and (2) both missing)
  Whichever source has options wins. The widget should now hydrate correctly under any reasonable template + content-filter combination.
* Console diagnostic: every combobox init logs `[ssc-ch] combobox init: city options=34` (or similar) to the browser console on page load. Operators can open DevTools → Console and verify (a) the new JS is actually loaded — if no log line, the cached old JS is still running — and (b) options are being read — if `options=0` the data source failed and we know exactly which layer to debug. No more guessing whether the issue is cache, content filter, or actual JS bug.

V1.8.9 — Custom combobox + filter alignment (fixes broken multi-select):
* CRITICAL — Cities and Training multi-select had been quietly broken across browsers since v1.8.4. Native <datalist> autocomplete inserts either the option `value` ("Brooklyn") or the option `label` ("Brooklyn (NY)") into the input depending on the browser/version, and our knownValues() check only accepted exact `value` matches — so on Safari, Firefox, and some Chrome configurations, clicking a city in the dropdown silently failed to add a pill. Also, the native datalist popup rendered as an uncontrollable browser-positioned widget that appeared on the wrong side of the viewport ("squashed on the side" complaint). Both problems fixed by replacing the native datalist with a custom combobox: dropdown panel rendered as a sibling of the input, absolutely positioned below it, full-width of the field, NY/NJ section headers grouping the cities/training options, click-to-add, keyboard navigation (↑ ↓ Enter Esc), filtered by typed text in real time. Same UX in every browser. Backend form submission shape unchanged — hidden `<input name="city[]">` and `<input name="training[]">` still carry the selections, so the server-side filter logic and Clear button work identically.
* Inline JSON payload (`<script type="application/json" data-ssc-combo-options>`) replaces the native `<datalist>` as the data source. Cleaner separation of data + widget, easier to extend. Falls back to legacy datalist parsing if someone deploys the new JS without updating the template (defensive — shouldn't happen but won't crash).
* Filter field alignment fixed. CITIES and TRAINING previously had `<small>` hint rows under their labels ("type to add · pick multiple", "NY DCJS · NJ SORA") that STATE TRACK and ROLE TYPE didn't have, so the right-column inputs sat ~14px lower than the left-column inputs in the 2-col grid. Hints moved into placeholders ("Type a city · pick multiple", "Type training · NY DCJS or NJ SORA"). All four wrappers now have identical structure → all four inputs sit on the same baseline.
* New multi-select tag visual: pills are now compact blue rounded chips with × remove buttons, scoped via `.ssc-ch-jobs` so they only affect the filter widget (not other multi-tag UIs that may exist elsewhere). The input shell has a unified focus-within outline matching the plain inputs.

V1.8.8 — Jobs page modern-utility pass + bot view filter + cost-tracking tripwire:
* Jobs hero collapsed from 2-col title-aside (~150px tall) to single inline row "Security jobs · NY DCJS + NJ SORA · 12 active · 4 NY/DCJS · 8 NJ/SORA" (~50px). Same information, ~100px reclaimed for results.
* Filter strip "Filter the board · Type to filter…" header copy hidden — form labels (STATE TRACK / CITIES / ROLE TYPE / TRAINING) are self-explanatory; this was marketing-style decorative copy on a utility module that competed with results for visual attention. The Indeed/LinkedIn/Wellfound pattern: filters are quiet, results dominate. Saves another ~70px of vertical space.
* Filter inputs aggressively compacted: input padding 10/14 → 7/11, font 14 → 13, grid gap 14/18 → 8/12, section padding 20-34 → 12-22, label font 12 → 10.5. Per-input height drops from ~44px to ~32px; 2x2 grid + actions row now ~140px instead of ~210px. Total filter+hero now ~190px (~22% of an 800px viewport) instead of the previous ~470px (~55%) — closer to the modern job-board norm.
* Email intro button on job cards no longer wraps onto two lines in narrow actions columns. Was inheriting the default browser word-wrap behaviour when the actions column got narrow (multiple CTAs per row: Star, Details, Apply, Email intro, Save, Applied). Added `white-space: nowrap` + smaller font (11.5px) + tighter padding so the icon + label stay on one line.
* Bot user-agent exclusion on CV view tracking. When a candidate posted their share link in WhatsApp, Slack, Discord, LinkedIn, Twitter, Telegram, Facebook etc., each platform's link-preview crawler hit the page to generate an unfurl preview — and every unfurl was counted as a view. So a candidate sharing once in a WhatsApp group could see "5 views" before any human eyeballs hit the page. New `SSC_CH_DB::is_bot_user_agent()` helper detects 20+ common preview/crawler UAs; bots get neither a `report_views` row nor a `share_views`/`share_pdf_views` counter bump. Real human visitors still tracked exactly as before. Conservative: empty UA also treated as bot (real browsers always send one).
* OpenRouter cost-tracking tripwire. The v1.8.7 fix (`usage: { include: true }`) added cost reporting — but if OpenRouter ever silently changes the contract again, we'd regress to $0 without noticing (exactly what happened from v1.4.2 to v1.8.6). New check in `SSC_CH_OpenRouter::call()`: if a successful response carries `total_tokens > 0` but `cost = 0`, log `openrouter_cost_missing` to Abuse Events. Next regression surfaces in admin within minutes instead of 13 releases.
* `SSC_CH_Credits::review_economics()` now returns `cost_status='unknown'` (was always `'healthy'`) when ai_cost is exactly $0. The healthy badge previously fired trivially at $0 because the heuristic was `cost <= 0.08`, which is true at zero — so "tracking broken" and "tracking working but tiny" looked identical. Only a positive recorded cost gets the healthy tone now.

V1.8.7 — real spending tracking + revenue/profit visibility + UX cleanups:
* CRITICAL — OpenRouter API requests now include `usage: { include: true }` in the body, so the response's `usage.cost` field actually populates. Without this flag the API only returns token counts and `cost` is absent — meaning every value in `ssc_ch_ai_usage.cost_usd` since v1.4.2 has been $0.00 regardless of actual spend, breaking every cost cap, every profit calculation, and the "AI cost" card on Credit Analytics. Existing rows stay at $0 (no backfill — OpenRouter's per-generation lookup costs another API call per row); new calls from 1.8.7 onward record the real model-priced USD figure.
* Credit Analytics page now has a full "Revenue & profit" section above the legacy KPI strip. Four headline cards: Revenue $ (credits sold × $1 — pack pricing is 1:1, defined in SSC_CH_Credits::packs()), OpenRouter spend $, Gross profit $ (tone-coded green when positive, amber when negative), Margin % (tone-coded: healthy ≥70%, watch 40-70%, thin <40%). Honours the date-range pills/picker, so admin can scope to last 7/30/90 days or a custom window. When OpenRouter isn't configured, an inline note explains why AI cost is $0 (no half-broken state).
* Daily activity right-hand chart upgraded from a single "credits purchased" bar series to a dual-axis chart: revenue bars on the left axis ($), AI cost line on the right axis ($). Gap between the two visualises gross profit day by day. New `SSC_CH_DB::time_series_ai_cost()` feeds the second series.
* CV Reviews admin page adds an "AI cost" column with per-CV OpenRouter spend, call count, and total tokens. Batched in one query (no N+1) using new `SSC_CH_DB::ai_cost_by_cv()`. Admin can now see exactly which CVs cost what — e.g. spot a runaway cover-letter generation, or confirm a 10-credit ($10) priority review only cost $0.0034 in OpenRouter spend (healthy ~3000× markup).
* UX — anonymous CV submitters now redirect back to /cv-optimizer/ on success (the form already supports the success card per v1.4.7), not to /my-dashboard/. The dashboard's personalized greeting, KPI cards, score trend, recent-CVs grid and credit balance are all logged-in-only, so sending anon users there was showing them a half-empty page with the status panel floating at the top. Logged-in users still land on the dashboard hub.
* UX — delete_cv endpoint replaced all four wp_die() failure paths (not logged in, bad nonce, rate-limited, CV not found) with redirects back to /my-cvs/ carrying a `delete_error` param. The My CVs page renders each error code as a friendly inline banner ("That CV could not be found — it may have already been deleted", "You hit the delete limit (20/hour)…") instead of slamming the user into a bare WP error page mid-flow.

V1.8.6 — logic-pass hotfix (privacy leak + view dedup + delete rate-limit):
* CRITICAL — CV report OG/Twitter meta tags were being injected on ANY page whose URL carried `?cv=X&t=Y` query params, not just the cv-report page itself. Because v1.5.0 redirects successful submits to `/my-dashboard/?submitted=1&cv=N&t=TOKEN&score=N`, the dashboard page picked up OG tags advertising the candidate's score and name ("Edward scored 75/100 on SSC Career Hub"). Sharing the dashboard URL on social media then leaked the CV score in the link preview. Fixed by gating `inject_report_meta()` on `is_singular()` + `has_shortcode($post->post_content, 'ssc_cv_report')` so OG injection only fires on the actual report page. Also drops a needless DB query on every other page that happened to carry those params.
* Public CV PDF share view counter no longer inflates on reloads. `cv_share()` was inserting a report_views row AND bumping `share_pdf_views` on every page load, so a recruiter hitting refresh 5 times turned 1 real view into a 5-count the candidate saw on /my-cvs/. Now matches `record_report_view()` behaviour — counter bumps only on the first visit per viewer_hash, repeat visits still log a row for analytics but don't double-count.
* `delete_cv` endpoint is now rate-limited (20/hour per user-IP) — every other state-changing endpoint (job_action, bulk_job_action, toggle_cv_share, pipeline_update) already had this. Each call does a filesystem unlink + DB write, so an authed user could previously hammer it with prepared nonces.

V1.8.5 — Horizontal filter layout + universal hero slim:
* Jobs filter form rewritten from a single-column vertical stack (state → cities → role → training, each on its own row) to a 2-column grid on desktop (state + cities on row 1, role + training on row 2). Search/Clear buttons span both columns. Single-column collapse below 720px. Cuts filter section height roughly in half on desktop.
* CV report hero slimmed. Was inheriting the global .ssc-ch h2 rule at clamp(32px, 5vw, 58px) — a 58px screamer on a transactional results page. Now clamp(22px, 2.8vw, 30px) so the big score dial on the right stays the magnetic centrepiece, with the headline as supporting copy.
* CV optimizer hero brought down from clamp(26px, 3.4vw, 38px) to clamp(24px, 3vw, 32px) — matches the universal 22-32px workspace size used by Dashboard, Jobs, My CVs, and My Credits. Every member page now reads with the same calm-on-the-eyes workspace typography instead of three different sizes across three different pages.

V1.8.4 — Tag-pill multi-select replaces verbose chip-grid:
* City and Training filters on the Jobs page rewritten from a vertical chip-grid (~360px tall when open with 34 cities + 12 training options) to a single tag-pill multi-select input (~52px when empty, +28px per row of selected pills). User types or picks from a native <datalist> autocomplete; each pick becomes a removable blue pill above the search box. Backspace from empty input removes the last pill (Linear/Wise pattern). Cleaner space usage and faster scan-to-search flow.
* State filter narrows the datalist. Pick "NY" and the autocomplete only suggests NY cities/training; pick "NJ" and only NJ; leave blank for both.
* Defensive validation: only values present in the datalist are accepted as tags. Prevents typos from polluting the filter query and stops users from injecting arbitrary city strings via a forged form. Duplicate-add is silently ignored.
* Form submission unchanged — hidden <input name="city[]"> and <input name="training[]"> still carry selections to the server, so all the existing job-filter logic and `Clear` button work as-is. Clear now also tears down the pill list.
* Kept all 34 cities and all 12 training options — wider catalog means smaller markets (Niagara Falls, Princeton, etc.) aren't locked out. Real-estate cost stays low because users only see what they've picked.

V1.8.3 — admin polish + Jobs hero slim + visual hardening:
* Suppressed 3rd-party admin_notices output on our plugin pages. TGM Plugin Activation prompts ("Activate eForm / Contact Form 7 / MailChimp / Safe SVG"), WPBakery anniversary banners, and similar plugin-install nags were rendering inside the SSC Career Hub admin pages and visually crashing into the dark gradient hero. Hooked into current_screen → if the screen ID contains 'ssc-career-hub', remove_all_actions on admin_notices/all_admin_notices/user_admin_notices/network_admin_notices. Our own inline <div class="notice"> messages survive because they are emitted directly inside each page method (not via the hook).
* Real Dashboard page. Previously the SSC Career Hub top-level menu page was an almost-empty single hero box, which left enough whitespace below it for injected 3rd-party banners to fill in and clash. Now the Dashboard page renders a full overview: hero with 4 live KPIs (active jobs, CV reviews, credits outstanding, awaiting review), 6 status cards (review queue with warn/good tinting, applied, apply clicks, paid credits, credits spent, OpenRouter on/off), and a 6-card "Jump to" quick-link grid linking to CV Reviews, Jobs, Import Jobs, Credit Analytics, Settings, and Abuse Events.
* Jobs page hero slimmed. Old verbose 3-line copy + 4-chip pathway aside was overwhelming the search use case. Replaced with one-line "Security jobs · NY DCJS + NJ SORA" headline + a single subhead, keeping the 3-stat aside (active matches, NY/DCJS, NJ/SORA). Faster scan-to-filter time for returning candidates.
* Admin hero text-color hardening. All white-text rules inside .ssc-ch-admin-hero (h1, p, a, KPI labels, KPI numbers) marked !important so WordPress admin theme overrides or 3rd-party plugins injecting color rules cannot make text invisible inside the gradient. Hex background (#0f4cbd) stamped first so a CSS-var failure or gradient-unsupported renderer still gets a solid blue background instead of a transparent box. Same defensive pattern as v1.7.1's frontend account-nav fix.

V1.8.2 — 20-scenario self-audit hotfix:
* CV share template's inline view-tracking beacon was reading window.SSC_CH.ajaxUrl, which is set by the localized ssc-ch-frontend script loaded in the footer. The inline script could execute first → ajaxUrl undefined → beacon silently broken → owner sees no view counts on shared CVs. Fixed by passing admin_url('admin-ajax.php') directly via PHP echo. Now view tracking on cv-share is independent of script load order.
* cv_share() now verifies the uploaded PDF file actually exists on disk BEFORE generating the iframe URL. Previously, if the file had been removed (filesystem cleanup, manual deletion, etc.), the iframe would load /admin-post.php that returns 404 — recruiter saw a broken page. Now we show a clear "CV file missing — ask the candidate to re-upload" message at the template level.
* toggle_cv_share for share_mode='both' now requires at least ONE source (external URL OR uploaded PDF). Previously, enabling share with mode=both and neither source would succeed in the DB but the share page would just say "not available" — misleading. New 'need_source' error code with a clear message on /my-cvs/: "You need either an HTTPS link to a hosted PDF OR an uploaded PDF on file before you can enable share."
* Documented 20 NEW scenario checks in the handoff doc (cumulative with the previous 20 = 40 audited what-if cases). Real bugs found and fixed inline; soft gaps documented as known limitations.

V1.8.1 — honest manual review + PDF deep security + self-audit fixes:
* Manual-review queue is now an explicit admin opt-in (Settings → Enable manual review queue). DEFAULT OFF. The plugin no longer silently promises "we'll email you when ready" when there's no human reviewer doing the work. When OFF and an uploaded PDF/DOCX can't be auto-read, the candidate gets a clear inline error: "We couldn't auto-read your file. Please paste your CV text below for an instant score." The orphan file is cleaned up; the failure is logged to abuse_events for admin diagnostics.
* When manual review IS enabled, admin gets an email per queued submission (with the candidate's name, email, CV reference number, and a direct link to the CV Reviews admin page). Notification email configurable in Settings — falls back to wp_get_option('admin_email') if blank.
* Deep PDF active-content security scan. New SSC_CH_File_Validator::is_safe_pdf() scans the entire uploaded PDF (capped at 8MB) for /JavaScript, /JS, /Launch, /SubmitForm, /EmbeddedFile, /RichMedia, /XFA markers — the PDF malware vectors. Conservative by design: any hit rejects with new 'pdf_active_content_rejected' error code. User is told to re-export as a plain PDF.
* Dashboard "Recent CVs" pending-state visual fixed. Was "—queued" stacked awkwardly; now a clean amber-tinted tile with ⏳ glyph and "Awaiting" label.
* Share-error banner on /my-cvs/. Toggle-handler errors (rate limit, need URL, need upload) now display as a clear red banner above the CV list instead of vanishing silently.
* New cv-form error messages for the new validator rejections (pdf_signature_failed, docx_signature_failed, active_content_rejected, pdf_active_content_rejected, file_too_large) — all give the user a concrete next step instead of a generic "upload failed".

V1.8.0 — public CV PDF share:
* New [ssc_cv_share] shortcode — public, token-shareable page where recruiters view a candidate's CV PDF. Sticky top bar carries candidate name, target role, NY-DCJS / NJ-SORA track, location, score badge (if scored), one-click Contact button (mailto with prefilled subject), copy-link, and breakdown-link. PDF rendered in a generous viewport iframe below.
* Per-CV opt-in toggle on /my-cvs/ — candidate chooses which CV(s) to make public. Off by default. View counter visible to candidate.
* Admin share_mode global setting (Settings page): url_only (recommended — candidate hosts on Drive/Dropbox/OneDrive, less SSC liability) / upload_only (SSC hosts) / both / off.
* Practical hosting tutorial collapsible in the candidate's share-toggle UI: exact Drive /view → /preview swap, Dropbox ?dl=0 → ?raw=1 swap, and OneDrive embed-URL extraction.
* Security: External URL sanitiser rejects non-HTTPS, javascript:/data: schemes, URLs with embedded credentials (parse_url rebuild strips them), and URLs > 500 chars. Local PDF serve endpoint validates token via hash_equals, basename()s the stored filename (no path traversal), magic-byte check on every serve (rejects anything not starting with %PDF-), responds with Content-Type: application/pdf + X-Content-Type-Options: nosniff + inline Content-Disposition. External-URL iframe gets sandbox="allow-scripts allow-same-origin" (no popups, no top-nav escape). Toggle handler rate-limited at 15/hour per user.
* Settings-conflict warning surfaced to candidate when admin switches share_mode after they've enabled sharing in the opposite mode — tells them exactly what to fix (e.g. "Site is set to URL-only sharing — paste an external URL").
* Engagement tracking reused from [ssc_cv_report] beacon (visibilitychange + pagehide → sendBeacon with cv_id + token + seconds + scroll%). New view_type='cv_pdf' column on ssc_ch_report_views so CV-share views are separable from score-report views.
* New DB columns on ssc_ch_cv_reviews: public_share_enabled (default 0), external_pdf_url (max 500 chars), share_pdf_views. New column on ssc_ch_report_views: view_type (default 'report'). dbDelta auto-applies via maybe_upgrade on init priority 1.
* Auto-create /cv-share/ page on activation/upgrade (joins the existing 6 auto-pages).

V1.7.0 — closing deferred gaps:
* Per-CV view-count column on admin CV Reviews. Total views + unique viewers + last-view timestamp per CV, rendered in a new column. Single batch query (COUNT, COUNT DISTINCT, MAX created_at GROUP BY cv_id) avoids N+1 on the admin row render.
* CV resubmit / version flow. "Improve this CV →" button on Dashboard Recent CVs cards and on [ssc_my_cvs] cards. Clicking it goes to /cv-optimizer/?from=N. Form prefills cv_text, state, training, target_role, city from the user's previous CV (verified via wp_user_id match — no cross-user leakage). Hidden parent_cv_id field passed through upload handler → stored in new cv_reviews.parent_cv_id column. Yellow "Improving CV-#NNNN · previous score X/100" banner at top of form.
* Mobile bottom tab bar. Below 720px the sticky-top account nav switches to a fixed-bottom 5-tab bar with icon glyphs (⌂ Home, ▤ Jobs, + Submit, ☰ CVs, ★ Credits) and short labels. Active tab gets a pill-style background highlight. Spotify / Wise / Robinhood pattern. Body padding scoped via body:has(.ssc-ch-account-nav) so non-plugin pages aren't affected.
* Cover-letter LLM generator (admin-only V1). New SSC_CH_OpenRouter::generate_cover_letter() method follows the same prompt-injection-safe pattern as the CV narrative (fixed system role, ===CV BEGINS=== / ===JOB DESCRIPTION BEGINS=== markers, 200-260 word cap, no AI self-disclosure). Admin opens a <details> panel on the CV Reviews row, pastes the job description (min 80 chars), clicks Generate. Result stored in new cv_reviews.cover_letter_text / cover_letter_at columns. Admin reviews + manually shares with candidate. Cost logged to ai_usage with :cover_letter model suffix for separate tracking.
* New DB columns: cover_letter_text MEDIUMTEXT, cover_letter_at DATETIME, parent_cv_id BIGINT UNSIGNED. dbDelta auto-applies via maybe_upgrade on init priority 1 (no deactivate/reactivate needed).

V1.6.1 — final sanity-pass hotfix on top of 1.6.0:
* maybe_upgrade now runs on the `init` hook (priority 1) instead of `admin_init`. The previous wiring only triggered schema auto-upgrade when an admin visited wp-admin — so a public user submitting a CV after a plugin upload but before any admin pageload would hit the stale-schema cv=0 bug again. Now schema check fires on every request (one cached get_option + version_compare; cost is negligible). Closes a real race-window hole in 1.4.9's self-healing install.
* SSC_CH_DB::user_application_pipeline() SQL fixed for MySQL 5.7+ strict mode. The previous query had MAX(a.created_at) as an aggregate alongside non-aggregated j.title, j.company, etc., with GROUP BY j.id, a.activity_type — which ONLY_FULL_GROUP_BY (default in modern MySQL/MariaDB) rejects, returning an empty pipeline silently. The inner subquery already constrains the result to exactly one activity row per job, so the outer MAX() and GROUP BY were both redundant. Now uses a.created_at directly with no outer GROUP BY. Pipeline kanban will populate correctly on production MySQL.

V1.6.0 — page-by-page parity pass:
* FC-deck-tracker parity on the CV report. Owner-only "Who's viewed your report" panel: total views, unique viewers, last viewed (relative time), average time-on-page, deepest scroll, plus a collapsible "Recent views" list (date, viewer hash short, referrer host, seconds, scroll percent). A JavaScript beacon on the report page tracks time-on-page + max scroll depth and fires once on visibilitychange='hidden' / pagehide via navigator.sendBeacon (fallback fetch keepalive). Engagement is appended to the existing ssc_ch_report_views row matched by cv_id + viewer_hash. Token verified server-side with hash_equals before any write.
* New ajax endpoint: ssc_ch_track_report_engagement (logged-in + anonymous variants). Token check, length validation, hash_equals, sanitised payload (max 7200s, max 100% scroll).
* Print-friendly CV report. New @media print stylesheet hides nav, share box, engagement panel, WP admin bar; preserves score dial + dimension bars via print-color-adjust; appends URL after each link except in-app navigation. "Print / Save as PDF" button on the owner-facing share row uses window.print(). Recipients (employers, mentors) can save a clean PDF copy of the report without any extra plugin.
* Application pipeline on the Dashboard. New Trello/Linear-style kanban board with 6 columns: Saved, Applied, Heard back, Interview, Offer, Hired. Each saved job appears as a card in its current stage; user changes stage from a per-card dropdown which auto-submits via admin_post (nonce + rate-limit protected, 30 transitions/hour per user). New activity_type values added to record_activity allowlist: heard_back, interview, offer, rejected, hired, withdrew. New SSC_CH_DB::user_application_pipeline() returns the latest stage per (user, job) pair joined to the jobs table.
* User-facing credit ledger on /my-credits/. Shows last 25 credit transactions in a clean table (date, activity type, +/- credits with tone colour, note). Friendly labels for purchase / spend / admin_adjustment / refund. Empty state guides first-time users to top up.
* Copy share-link button on /my-cvs/ cards (parity with the Dashboard Recent CVs section).
* "Email intro" button on every job card. Generates a polished pre-written intro (user's first name, target role, company, location, NY/NJ track, available start date) and opens the user's mail client via mailto:. Detects whether apply_url is a mailto: (uses that recipient) or a regular URL (recipient blank so user can paste into the employer's form).
* New SSC_CH_DB helpers: report_engagement(), update_report_engagement(), user_credit_ledger(), user_application_pipeline().

V1.5.1 friction reducers + magnetic UX:
* CV form now prefills name, email, phone, and city from the logged-in WP user profile (first/last name → name, user_email → email, billing_phone + billing_city meta if WooCommerce). A small green note tells the user "Prefilled from your account — edit if you want different details". Removes 4 fields of friction for returning members.
* Personalized greeting on Dashboard. "Good morning, Edward" / "Good afternoon, Edward" / "Good evening, Edward" based on time of day. H2 also flips from generic "My Security Career Dashboard" to "Edward's career dashboard" when first name is known.
* Smart "Next move" card on Dashboard. Single CTA that adapts to where the user is in their journey:
  - No CV yet → "Submit your first CV" → /cv-optimizer/
  - Has a scored CV but score < 65 → "Push your score to 80+" → opens their latest report
  - Has CV but no saved jobs → "Save 3 realistic jobs" → /jobs/
  - Has saves but no applies → "Apply to 1 today" → /jobs/
  - Active user → "Refresh your CV monthly" → /cv-optimizer/
  Bold gradient banner with a radial glow accent, single white pill CTA. Never a dead end — always one specific thing to do next.
* Smooth hover micro-polish applied to every card across pages (recent CVs, side cards, submission tiles, top-up cards, quick actions). Unified .15s ease transition so the whole UI feels cohesive when the user moves through it.

V1.5.0 unified account hub + flow consolidation:
* CV submission now redirects to the Dashboard (not back to the CV form). After submit, the user lands on /my-dashboard/?submitted=1&cv=N&t=TOKEN[&score=N] with the same status panel (icon, reference number, timeline, primary actions). This answers "what happens after submit" — the user is on their account hub with the score front-and-centre, not stranded on the form page.
* Dashboard catches the submission params at the top and renders the status panel above the rest of the dashboard content. Stats, score trend, recent CVs, and saved jobs remain below — so the user sees their result AND their broader account state in one place.
* Account nav is now sticky at the top of the viewport across every member page. Sits below the WP admin bar (32px or 46px on small screens). User always knows where they are and can jump to any section without scrolling back up.
* Dashboard hero dialed down from the full marketing treatment to a workspace-sized header (clamp(24px,3vw,34px) instead of clamp(32px,5vw,58px), tighter padding, smaller reminder note). The dashboard is a transactional hub, not a landing page — content should dominate, not the hero.

V1.4.9 self-healing install + cv=0 hotfix:
* CRITICAL FIX — Share-link URLs were being generated with cv=0 when CV inserts silently failed. Root cause: users uploading a new zip via WP Plugins → Add New → Replace, without deactivating+reactivating the plugin, leave dbDelta stale; new columns added in 1.4.x (candidate_feedback, ai_narrative, ai_narrative_at) never get created, so $wpdb->insert returned 0 and the redirect URL contained ?cv=0. The user then clicked the email link and saw "page not found" (or a theme redirect to home).
* SSC_CH_Activator::maybe_upgrade() now runs on every admin_init. Compares the plugin's compiled SSC_CH_DB_VERSION constant against the stored ssc_ch_db_version option; on mismatch, re-runs the full activator (dbDelta picks up new columns idempotently). The activator now uses update_option for the version stamp instead of add_option so the value actually changes on subsequent runs.
* SSC_CH_Activator::ensure_pages() auto-creates the 6 required WordPress pages on activation if they don't already exist: /jobs/, /cv-optimizer/, /cv-report/, /my-dashboard/, /my-cvs/, /my-credits/. Each gets its matching shortcode as content. Resulting page URLs are stamped into ssc_ch_settings so the account nav + cv_report_url always resolve. Safe to re-run (checks by slug first).
* Upload handler now refuses to send a broken URL. If SSC_CH_DB::insert_cv returns 0, it logs the wpdb->last_error to abuse_events and redirects with a new save_failed error code instead of building cv=0. The cv-form template renders a clear "we could not save your CV; admin has been notified — please try again" message.

V1.4.8 post-submission flow + analyzing UX:
* Redesigned the post-submission state from a small text card into a proper transactional confirmation panel (Stripe / Linear / Wise pattern). Shows: tone-coded status icon (green check for scored, amber clock for queued), big headline, SSC-CV-#NNNN reference number, "saved for [email]" account context, 3-step visual timeline (Saved → Reviewed → Report ready) with pulsing active dot, primary actions (Open report / Copy share link), and 4 next-step tiles (My CVs / Dashboard / Browse jobs / Submit another).
* Form is now hidden when the page renders in submission state — the user knows the action completed and is given clear next moves instead of an empty form. "Submit another" tile reloads the bare URL to bring the form back.
* New "Scored by" badge on every result: blue "Auto-scored by SSC readiness engine" for free local submissions, red "Local engine score · AI review running" when credits-spent priority review fires the OpenRouter cron, amber "Queued for human reviewer" for manual-review fallback. Users always know what evaluated their CV.
* When AI review isn't active (no key configured OR no credits spent), the success panel now shows an explicit upgrade prompt with a direct top-up link, so users know AI review is an option they can unlock.
* New full-page "Analyzing your CV…" overlay during form submission — animated three-dot pulse, cycling status messages ("Reading your submission / Extracting key signals / Scoring readiness / Saving to your account"), backdrop blur. Pure UX feedback (no AJAX); disappears naturally when the browser navigates to the success page.
* CV Optimizer hero h2 dialed down from clamp(32px,5vw,58px) to clamp(26px,3.4vw,38px) — the page is transactional, not marketing, so a 58px header was overwhelming.

V1.4.7 logic + navigation pass:
* Fixed success-card after CV submission. The v1.4.3 guard required score>0 to show the "scored" card; a legitimate 0/100 was falling through to the "manual review" message and confusing users into thinking their CV wasn't saved. New guard tests for the presence of ?cv= and ?t= in the URL (which only the upload handler sets when scoring actually happened). All branches now explicitly say "saved to your account" so the user knows the data persisted regardless of which message displays.
* Fixed CV analyzer keyword matching. The v1.4.0 tight word-boundary regex required BOTH sides of a keyword to be non-alphanumeric, so "guards" (plural), "patrolling", "licensed", "supervisors" all failed to match — artificially deflating scores. New regex requires only the start-of-word boundary; matches morphological variants but still blocks substring false positives like "rearguard" matching "guard".
* Cities + Training filters now grouped by state inside their collapsible panels — "New York" sub-section above "New Jersey" sub-section, with small uppercase headers. State filter hides the entire opposite-state sub-section (previously it only hid individual chips, leaving an orphaned section title).
* New account-nav tab bar (Dashboard / Jobs / Submit CV / My CVs / Credits) rendered at the top of every member shortcode. One consistent place to navigate; current page gets a solid SSC-blue pill. Horizontally scrollable on mobile.
* Dashboard upgraded to a real account hub:
  - Quick Actions row: "Submit a new CV" (primary gradient), "Browse jobs", "Top up credits" — clickable tiles with icon + title + subtitle.
  - New "Recent CVs" cards grid below the score trend. Each card shows score with tone-coloured number (green/amber/red), status pill, role, date, plus [View report] and [Copy link] buttons. Pending/manual-review CVs show an "Awaiting manual review" note.
* New generic data-ssc-copy-link clipboard handler. Used by Recent-CVs cards and the post-submission success card. Native Clipboard API with hidden-textarea fallback; brief "✓ Copied" confirmation flash.

V1.4.6 CV form vertical stretch fix:
* The form card was rendering ~1200px tall with only ~370px of content, producing huge dead space between the wizard chips, the step heading, the fields, and the Continue button. Root cause: the parent .ssc-ch-cv-shell is a 2-column grid (form + aside) with default align-items:stretch, so the form was being stretched to match the taller aside (credit balance + 4 top-up cards). The form is itself display:grid, so it then distributed the extra height across its internal rows — pushing chips, legend, fields, and nav far apart.
* Fix: align-items:start on the parent shell (form sizes to its own content), plus align-self:start + align-content:start on the form (defensive, pack rows at top regardless of container height). Both gaps collapse to the intended 14px row gap.

V1.4.5 dashboard hero + KPI layout fix:
* The dashboard hero was rendering "My Security Career Dashboard" as four stacked words ("My / Security / Career / Dashboard") at giant size — root cause: the hero is a 2-column grid (minmax(0,1fr) 320px) but the template loose-leafed three direct children (kicker, h2, reminder p), so the h2 landed in the 320px-wide column and word-wrapped.
* Restructured dashboard.php to match the Jobs hero pattern — content wrapped in a <div> + new <aside class="ssc-ch-hero-card"> that holds the stats (tracked / applied / CV reviews / credits) and the pathway chips. Now they live INSIDE the dark hero where the existing white-on-dark styling actually works.
* Added defensive CSS: any hero without an aside/hero-card child collapses to a single column (fixes [ssc_my_credits], [ssc_my_cvs], and the logged-out dashboard, all of which have no aside).
* Forced kicker pill to sizeof-content with higher specificity in case a parent theme applies "p { width: 100% }".
* Saved/tracked jobs table got proper styling — uppercase muted column headers, hover state, status pill in SSC blue, "View" link in SSC blue instead of inheriting the theme's anchor red.
* Stats grid switched to 2x2 inside the hero aside so the 4th stat doesn't orphan.

V1.4.4 jobs-page filter strip visual fix:
* Filter labels (State track / Role type / Training) were rendering as white-on-white because the v1.4.0 chip styles assumed the filter strip was on the dark hero gradient (legacy 1.3.x layout). Rewrote the entire chip/label/collapse block for the actual light background — labels are now uppercase muted ink, chips are blue-ink-on-white pills with solid SSC blue when active, collapsible City/Training panels have a clean +/− toggle.
* Broke the legacy 6-column grid (.ssc-ch-filters at line 425) for chip-mode forms — the chip form is now a clean vertical flex column so State / City / Role / Training / Search-Clear stack naturally instead of sprawling across the row.
* Search/Clear actions row has a top border + proper button styling so it reads as the form's commit zone.
* Improved focus state on inputs/selects (3px blue outline + ink border).

V1.4.3 spacing + UX patch on the CV Optimizer form:
* Fixed ~150px of dead vertical space inside the wizard fieldset by resetting Chrome's default fieldset min-inline-size and the legend's implicit top anchoring. Wizard steps now sit flush below the progress chips.
* Tightened wizard progress chips bottom spacing (was 22+24+border, now 14+16+border).
* Empty submit-row container no longer takes vertical space on non-last wizard steps (hidden via data-wizard-last="0").
* Success card no longer renders "Your CV scored 0/100" when the URL has ?submitted=1 without a real score. Falls through to a generic "received" message unless cv, t, and a positive score are all present.

V1.4.2 adds secure OpenRouter integration:
* New SSC_CH_OpenRouter helper. The API key is read ONLY from a wp-config.php constant (SSC_CH_OPENROUTER_KEY) — never from wp_options, so it does not appear in DB backups (JetBackup → Google Drive in this hosting setup). Optional SSC_CH_OPENROUTER_MODEL constant overrides the default model (anthropic/claude-haiku-4.5). Optional SSC_CH_OPENROUTER_HOST for self-hosted gateways.
* When a candidate spends credits for an ultimate CV review (status → priority_review), a WP-cron event fires SSC_CH_OpenRouter::run_for_cv() which calls OpenRouter, stores the draft narrative in cv_reviews.ai_narrative, and logs token usage + cost in ssc_ch_ai_usage.
* Admin gate: the AI draft is NEVER auto-published. It appears as an admin-only details panel on the CV Reviews row with a "Use this as candidate feedback" button that copies the draft into the candidate_feedback textarea. The admin reviews, edits, and saves before anything reaches the candidate.
* Per-call cost cap (openrouter_cost_cap_usd setting) is enforced as a 100× daily ceiling. If today's cumulative AI cost exceeds the ceiling, requests are skipped and logged to abuse_events.
* Settings page shows "Configured via wp-config.php (…last4)" or "Not configured" — the key value itself is never echoed to the page.
* Prompt-injection defence: CV text is wrapped in explicit ===CV BEGINS=== / ===CV ENDS=== markers, system prompt fixes the role, output is capped at 600 tokens, and the model is instructed not to mention being AI.
* New columns on ssc_ch_cv_reviews: ai_narrative, ai_narrative_at. Existing CVs are unaffected.
* If the constant is absent, the plugin degrades to the prior behaviour (local heuristic + human-fulfilled priority reviews). No errors, no half-broken state.

V1.4.1 sanity-check pass on top of 1.4.0:
* CV form wizard is now progressive enhancement — without JS the form renders as a single page with a working submit button; with JS the multi-step nav + per-step validation kicks in.
* [ssc_my_cvs] cards now expose a "View report" button per scored CV (was missing the link in v1.4.0).
* Candidate confirmation email goes out automatically after CV submission, with the shareable report link and clear NY/NJ disclaimer.
* New candidate_feedback field on CV reviews — admins can write public-facing reviewer notes (rendered on the CV report) separately from internal admin_notes, and can tick "Email the candidate" to push the update.
* Candidate dashboard now includes a "CV score trend" bar chart of the last 5 reviews, with score-delta vs previous and a one-click link to the latest report.
* Chart.js admin enqueue keeps the CDN dependency but the placeholder SRI was removed (a wrong digest would brick the charts); doc-comment explains how to pin the real hash post-install.
* New DB columns on ssc_ch_cv_reviews: candidate_feedback, candidate_notified_at.

V1.4.0 was a major design + capability pass:
* Shareable token-based CV report at [ssc_cv_report] — full score breakdown, six dimension bars, strengths, next-step checklist, recommendation CTA, score-over-time delta, and OG/Twitter share-card metadata.
* Pure-PHP PDF and DOCX text extraction (no Composer deps, no shelling out) — text-based uploads now get scored automatically; only scanned/encrypted PDFs fall through to manual review.
* UTM/source attribution capture (cookie-based, sendBeacon ingest) joins to CV submissions and credit purchases, surfacing a "where candidates come from" widget on the analytics page.
* Admin analytics rebuilt: date-range filter with 7/30/90/all presets, Chart.js daily activity + credits-purchased charts, conversion funnel (saves → applied → CV → credits), CSV export of the credit ledger, and tone-coded ledger rows.
* Mobile CV form is now a 3-step wizard with progress chips and per-step validation.
* Mobile jobs filter swaps native multi-select for tap-friendly chip groups in collapsible details panels.
* Bug fixes: word-boundary keyword matching in the CV analyzer (no more "patrol" inside "patrol-style"), removed all three unused legacy CSS files, dropped legacy 0000-00-00 deleted_at fallbacks, hardened error reflection on the CV form.
* New DB tables: ssc_ch_attribution (UTM/referrer/device per session) and ssc_ch_report_views (CV report engagement). New columns on ssc_ch_cv_reviews: share_token, share_views, share_first_viewed_at, extraction_source, attribution_id. Existing CVs are backfilled with share tokens on upgrade.

V1.3.4 added admin manual credit adjustments, a fuller analytics control room, recent credit ledger visibility, CV status breakdowns, and stronger candidate dashboard stats. V1.3.3 tightened the CV/credit flow review: empty submissions are blocked, file-only CVs are queued for manual review, priority credit spend uses the logged-in account identity. V1.3.2 preserved manually uploaded WooCommerce product images. V1.3.1 fixed WooCommerce variation checkout links. V1.3.0 added credit top-ups, My Credits/My CVs shortcodes, CV scoring, safer PDF/DOCX/TXT uploads, credit analytics, and API-cost tracking.

== Description ==
SSC Career Hub provides shortcodes:
* [ssc_jobs] — searchable job directory from local DB synced from CSV/published Google Sheet.
* [ssc_job_dashboard] — simple seeker dashboard for saved/applied activity.
* [ssc_cv_optimizer] — three-step CV intake wizard with readiness score and admin review.
* [ssc_cv_report] — public token-shareable CV readiness report (place on its own page, e.g. /cv-report/).
* [ssc_my_credits] — logged-in candidate credit balance and smooth top-up cards.
* [ssc_my_cvs] — logged-in candidate CV review tracker.

Important: SSC provides training and career preparation support. Job listings are informational and employment is not guaranteed. New York DCJS training and New Jersey SORA are distinct requirements.

== Installation ==
Upload/activate the plugin, place the shortcodes on pages, configure CSV URL under SSC Career Hub > Sync Jobs, then run Manual Sync.

== CSV Columns ==
Supports common headers: id, title/job_title, company/employer, city/location, state, job_type/type, pay/pay_text, schedule, training_requirement/training, tags, summary/description, requirements, apply_url/url, source_name, source_url, verified_at, expires_at.

== Security ==
Uses nonces/capabilities, fixed file allowlist (txt/pdf/docx only; legacy .doc is blocked), private upload folder protection, hashed IP/email for rate/abuse events, formula-safe CSV helper, and WooCommerce buyer detection only when WooCommerce functions exist.
