SEOMedic — full changelog
=========================
The plugin's readme.txt keeps only the most recent releases (WordPress.org
limits the readme Changelog section to 5000 characters). The complete
history is preserved here.

== Changelog ==

= 2.94 =
* Freemius SDK 2.13.4 → 2.13.5, prompted by Freemius's dashboard notice. Wholesale replace of `vendor/freemius/` from the official `Freemius/wordpress-sdk@2.13.5` tag; our 2.13.4 copy was first confirmed byte-identical to the official 2.13.4, so no local changes were lost. Upstream: esc_html/esc_url on error messages, emails and URLs throughout `class-freemius.php`; opt-in restricted to `is_user_admin()`; post-purchase sticky notice removed; pricing template min-price fix for lifetime plans and React pricing app context; wpcs dev dep bump. Nothing in the contact-form path the bug report panel uses changed.
* Bug report panel adds `topic=bug` (the SDK itself calls `contact_url( 'bug' )`, so it is a valid topic).

= 2.93 =
* Focus Keyword vs Ranking: header cells are `position: sticky; top: 0`. The table's wrapper now scrolls both ways with `maxHeight: 75vh` — sticky only works against the scrolling ancestor, and the old overflow-x wrapper swallowed vertical stickiness. Cell backgrounds and a box-shadow rule replace the `<tr>` background and border (rows showed through; collapsed borders don't move with a stuck cell). The Actions header is sticky top and right (z-index 3).
* New "What the badges mean" legend. Reported as unclear, including "exact" read as "matches the top query" — it means the keyword is any of the page's top 25 queries. Badge labels and colours moved into one `BADGE` map shared by the table and the legend; hover text rewritten to match.

= 2.92 =
* New core file `class-bug-report.php` (`SEOMedic_Bug_Report`): "Report a bug" panel at the foot of Settings (manage_options). Collects SEOMedic version/edition, WP, PHP, theme, Yoast/Rank Math, memory limit, WP_DEBUG, locale, GSC connection (Pro) and other active plugins (capped at 40); the browser half (user agent, screen, last 15 entries of the dashboard's `seomedic_diag_log`) is added client-side. Send opens the Freemius contact form with `summary` and `message` prefilled — Pro via `contact_url()` (the in-admin page passes `$_GET` through to the hosted form), free via `FS_Contact_Form_Manager::get_standalone_link()`. Nothing is sent from the plugin itself. Past ~4 KB encoded the technical details go to the clipboard instead of the URL. No Freemius → Copy only.
* The Plugins-row Support link used a hardcoded `admin.php?page=seomedic-contact`, which free installs don't have (Freemius makes Contact an external link there); it now uses the same destination.
* New harness `tests/bug-report-harness.php` (16, including the panel JS under Node with a fake DOM).

= 2.91 =
* `generate_tasklist()` multiplied `ctr` by 100, but the client sends `GscPageData.ctr`, which is already a percentage (the server scales it on read). Every page's CTR reached the prompt 100× too high — reported as the Oink for Influencers page (andrewmurrayhq.com, position 6.1, real CTR ≈ 0.98%) being described as "already strong 98% CTR". Now passed through unchanged. The other CTR prompts were checked: `optimize_snippet`'s per-query CTR is clicks ÷ impressions (a fraction), so its × 100 is correct. Saved task lists keep the bad text until regenerated.

= 2.90 =
* bare-embed covers core oEmbed providers beyond YouTube/Vimeo: Dailymotion, TED, TikTok (video), Spotify, SoundCloud, Mixcloud, twitter.com, Reddit (rich). Patterns follow WP_oEmbed's provider list; x.com is not in it, so it is left alone. Amazon/Pinterest/Tumblr/Flickr excluded (their patterns match ordinary product/affiliate links). Rich embeds get no aspect-ratio class, matching what Gutenberg saves.
* Content after `</figure>` inside an image/embed/video/audio block moves out after the block (the 2.89 heading rule, generalised).
* Quotes: loose content between inner blocks is wrapped in blocks inside the quote; a pre-6.2 quote holding anything but `<p>`s is converted to inner blocks, keeping `<cite>`. A `<p>`-only old quote is valid and left alone; nested quotes are skipped.
* Harness → 133, with a verbatim fixture from soccerstories.net draft 5268 (made to test these shapes; the repaired markup passes the site's own block parser).

= 2.89 =
* Block-markup repair, from andrewmurrayhq.com post 14768. `rebuildParagraphBlock()` joined a removep()'d run line-by-line with `<br>`, so `URL\n<h2>…</h2>` became one `<p>` (no auto-embed, invalid block). It now splits out h1–h6/ul/ol/figure/table/blockquote/div/pre/hr as their own blocks (heading → wp:heading with `wp-block-heading`, parseable list → canonical list, else wp:html) and gives a line holding only a video URL its own paragraph so the bare-embed pass rebuilds it. iframe is excluded (valid inside `<p>`).
* Heading blocks with content after `</hN>` (an image) have it moved into the blocks after the heading. `parseListHtml()` returns text after the last list closes as `tail`, emitted as a paragraph after the rebuilt list; text BETWEEN lists in one region still refuses.
* New finding `block-in-block`: a wrapped `<p>` holding a block element (2.88's own output) or a heading block with trailing content; Repair unwraps it.
* A video URL sharing a wrapped `<p>` with other lines (`<p>URL<br>text</p>`) is reported as bare-embed and split so it embeds; a URL inside a sentence is left alone.
* Harness → 120. Fixtures, verbatim: 14768 before repair, 14768 as 2.88 left it, and 14785 (untouched damage).

= 2.88 =
* Analytics: Change results moved from a collapsed bar inside the pages section (easy to miss between Brand terms and the SERP bar) to a card at the top of the page. Always-visible tiles: finished, improved, declined, no clear change, in progress; "First result ~date" while nothing has finished; full lists behind "Show all changes". Loads with the page (the read can finalise up to 5 matured results, one Search Console call each) instead of on first expand; a load error shows in the card header with Retry.

= 2.87 =
* New Pro file `class-serp-enrichment.php` (DataForSEO Live Advanced): settings (API login/password, never echoed back; estimate price; async AI Overviews +$0.002, refunded when absent), `POST /serp/estimate` (free: cache hits, cost, balance from `appendix/user_data`), `POST /serp/cached`, `POST /serp/check` (manage_options, one page × desktop + mobile, `force` bypasses the 30-day cache, `fatal` on auth / 402xx so a bulk run stops). Country codes from DataForSEO's free per-country locations endpoint (Country row only, cached), 2000+ISO-numeric table as fallback. Results cached 30 days in `seomedic_serp_checks` (autoload=false, 500, parser version `v`). Last raw response kept 1h, downloadable from Settings.
* Parser: features above our result by `rank_absolute`, our `rank_group`, AI Overview citation (cited / not_cited / unknown when async wasn't loaded), featured-snippet owner, competitor titles. Sitelinks need ≥ 2 links to OTHER pages — live responses fill `links` with "Read more" (#:~:text=) and Reddit comment permalinks. Fixtures: DataForSEO's documented example + a live response (whoop 5 release date, 2026-10-19).
* `utils/serpVerdict.ts`: cited / answered / hybrid (cited on one device) / slipped (live ≥ 3 worse than GSC) / confirmed. Sets the status and next action for weak-CTR and likely-zero-click pages; rewrite recommended only when confirmed; cited/answered pages leave the "recoverable from title fixes" total. Labels: AI-cited → AI-referred, Hybrid → Google + AI-referred.
* Snippet optimiser reads the checked query's competing titles from the server-side cache (never the request body), quoted as data, with a stand-out requirement.
* New Pro file `class-change-tracker.php`: auto-tracking on save (title, Yoast/Rank Math/SEOMedic SEO title, meta, focus keyword; content at ≥ 15% or ≥ 150 changed words, word multisets) via one-off WP-Cron at shutdown; repeat edits within 14 days merge; later ones archive the running event as interrupted. Fixed 28-day before/after windows; results frozen into `seomedic_change_results` (autoload=false, 500) by the daily GSC cron and lazily on read. Refresh events gain `w` (changes) and `s` (source). `GET /change-results`, `DELETE /change-results/{id}`; `POST /gsc/refresh-events/{id}` takes `{merge, changes}`.
* Analytics: Change results panel (in progress + finished, Improved / Declined / No clear change at ±10% clicks/day, CSV), per-row impact tracker, Check live Google results / Check again, Details collapsed, "Checking saved results…" instead of a status flip, "about 20–40 seconds" hint, Copy button removed. Settings: Connections overview at the bottom.
* CTR fixes from live checks: "when was / when did / come out / came out" are quick-answer; a page ratio under 0.5 is weak CTR even with no single 100-impression query; target queries need 20+ impressions and position ≤ 20.
* Harnesses: serp-parse 61, serp-routes 32, serp-verdict 15, change-tracker 27, ctr-action 31, ctr-diagnosis 34, query-intent 42.

= 2.86 =
* Changed: "Top 10 — weak CTR" was decided from page-average position and CTR against a hardcoded curve (31/24/18/13/9/6/4/3/2/2), while the "vs typical" column used the site's own page-level median — so a page could be labelled weak while showing 2.5× typical (/poppy-ai). Brand queries (12,004 of 12,949 impressions on /whisprflow-vs-typeless-vs-voicedash) and quick-answer queries were averaged into the same number, and AI-cited/Hybrid only ever filled from the AI-referer tracker, so every low-CTR page defaulted to weak.
* New Pro route `GET /gsc/ctr-diagnosis`: every (page, query) row plus each page's dominant country for the Analytics window, paginated 25k × up to 4 requests, cached 6h as JSON; queries under 5 impressions fold into a per-page tail. Separate from the capped 90-day `/gsc/page-queries-detailed`.
* New `utils/queryIntent.ts`: navigational detection (user brand list + terms found where product names sit in the slug/title: leading word, beside "vs", before review/pricing/legit; modifier words never navigational; spacing-insensitive match) and quick-answer detection (what is / release date / how much / definition / settings / [product][number][release]), quick-answer checked first. Decisions stored in `seomedic_ctr_brand_terms` (autoload=false; GET `/ctr-brand-terms` edit_posts, POST manage_options), reviewed in the new Brand terms panel. Option removed by both uninstall paths.
* New `utils/ctrDiagnosis.ts`: site CTR curve = median per position bucket (1, 2, 3, 4–5, 6–7, 8–10) over capturable queries with 20+ impressions, generic fallback for buckets under 20 queries. A query is weak at ratio < 0.5, 100+ impressions, position ≤ 10. Page status: brand + quick-answer ≥ 50% → Mostly brand searches / Likely zero-click (whichever larger); weak ≥ 40% of capturable impressions → weak CTR; else ratio ≥ 0.8 healthy, below that "Below typical". Page ratio = actual ÷ expected clicks over capturable top-10 queries, shared by the status, "vs typical", the CTR dot and sorting.
* New trend check (`utils/ctrTrend.ts`, Pro routes `GET /gsc/ctr-trend` + `/gsc/ctr-trend-weekly`): last 91 days vs previous 91 for weak / zero-click pages; position moved < 1.5 and CTR fell > 40% (with ≥ 10 prior clicks, so 0.1% → 0% isn't a drop) → "CTR drop, position stable", with the drop week located from a weekly series fetched only for those pages (max 25).
* Changed: lost clicks = Σ max(0, expected − actual) × impressions over weak queries only, on the site curve. Banner: "~X clicks/month recoverable from title/description fixes (excludes Y brand and Z zero-click impressions)".
* New `utils/ctrAction.ts`: per-page diagnosis + next action; "Optimize for CTR" recommended only for a stable weak-CTR page, hidden for healthy ones. Falls back to the page-level classifier when query data is unavailable.
* Harnesses: query-intent 38, ctr-diagnosis 32, ctr-trend 16, ctr-action 14. Acceptance pages checked on the live site.

= 2.85 =
* Fixed: Rank Math's focus keyword lives in `rank_math_focus_keyword` (no leading underscore; comma-separated, primary first — the same convention as its `rank_math_title` / `rank_math_description` keys, which we already used correctly). `class-seo-integration.php` read, wrote, queried and cleared `_rank_math_focus_keyword`, a key Rank Math never touches. Consequences: `update_focus_keyword()` "synced" into a dead key; `get_posts_missing_focus_keywords()` asked whether the dead key existed, so any post the Assistant had ever saved was counted as done; result "✓ No issues found" on soccerstories.net while Rank Math showed no keyword on the same posts. Confirmed from live post meta: 3278 carries `rank_math_focus_keyword` from Rank Math's own UI next to our stray `_rank_math_focus_keyword`; 5030 carries only the stray one. New `RANK_MATH_FOCUS_KEY` / `RANK_MATH_LEGACY_KEY` constants, `get_rank_math_primary_keyword()` (first comma entry), `set_rank_math_primary_keyword()` (replaces the primary, preserves secondaries, empty clears), and `migrate_legacy_rank_math_keyword()` run once from `seomedic_maybe_migrate()` (flag `seomedic_rank_math_key_migrated`): copies the stray value into the real key where that is empty and Rank Math is active, then deletes the stray key; bounded to 1000 posts. The API clear path now clears via the same helper.
* Harness: new `tests/focus-keyword-harness.php`, 18 assertions, fixtures from the two live meta shapes.

= 2.84 =
* New: `rebuildLists()` in `utils/blockMarkup.ts`. TinyMCE's list normalisation turns every list-item block into `<li style="list-style-type: none"><ul><li>item</li></ul></li>`, and after two round-trips each bullet is its own list block whose <li> holds nothing but a nested list. The one property both wrappers share and a real sub-list never has: the wrapping <li> carries no text. Rule: an <li> with no text and exactly one nested list is a wrapper — hoist its items; an <li> with text AND a nested list (post 3466: "Improved Reaction Time:" + bullets) is genuine and kept. Parsed with a tiny ul/ol/li tokenizer (block comments stripped first) so the harness stays DOM-free; any shape the parser can't vouch for (text outside an <li>, a list directly inside a list) is left untouched and stays reported. Top-level sibling lists inside one block are merged; adjacent damaged single-bullet blocks are rejoined; empty bullets dropped; in-item newlines (removep()'d <br>s) become <br> again; `start` attrs and the block's own attrs preserved; output is Gutenberg's canonical serialisation. Clean lists are byte-identical after repair.
* New: `bare-embed` finding + repair — a paragraph that is exactly one YouTube/youtu.be/Vimeo URL (removep() reduces an embed block to that) becomes a canonical `wp:embed` block (attrs escaped like serializeAttributes: `--`, `<`, `>`, `&`), so the theme's responsive wrapper applies again. Empty self-closing `<!-- wp:embed /-->` dropped.
* Changed: `scanBlockMarkup()` no longer hard-codes which findings are manual. `repairable` = repair changes the content; `manual` = what `analyseBlockMarkup()` still reports on the repaired content. Honest by construction: a list the rebuild can't read stays manual, one it can rebuild doesn't.
* Applied to all 16 repaired soccerstories.net posts (visible words identical before/after); 3466's four numbered blocks with real sub-bullets survive as four `start`-numbered blocks, which is how Gutenberg saves a numbered list broken by other blocks.
* Also: a list Gutenberg's "Attempt recovery" has already emptied (every bullet blank — seen on 3928 and 4006 after the user tried recovery) is removed rather than left invalid, and the change says why; the text is in the post's revisions.
* Harness → 98 assertions (+21): flat rebuild to exact canonical markup, real sub-list + start attr kept, split bullets rejoined, empty bullet + <br>, clean nested list untouched, unreadable shape untouched and scan-reported as manual, repairable/manual split, bare YouTube/youtu.be/Vimeo embeds, URL-in-prose and non-video URL untouched, entity-encoded query escaping.

= 2.83 =
* Fixed: `repairBlockMarkup()` left NESTED leaf openers alone. Each TinyMCE round-trip drops closers in pairs, so a post tail ends up as `<!-- wp:paragraph --> text <!-- wp:paragraph --> text <!-- /wp:paragraph --><!-- /wp:paragraph -->`; Gutenberg parses that as a paragraph holding a paragraph and renders the whole run as one invalid block. `restructure()` now closes an open LEAF block (paragraph/heading/html/image/embed/separator/table…) the moment any other block opens inside it, and closes whatever is still open at the end of the post — both exactly what the editor would save. New finding `unclosed-block` (counted by the same walk, dry-run) so the section can name it; containers (quote/list-item/group) are never closed implicitly, so a paragraph inside a quote is still clean. Found on post 3928 while running the 2.82 repair across all 17 damaged posts on soccerstories.net; the other 16 repaired as designed (visible words verified identical before/after against a public-API snapshot).
* Harness: 77 assertions (+12): nested openers → sibling paragraphs with one closer each, orphan lead before the nest, change-list wording, block open at end of post, and the quote-contains-paragraph negative.

= 2.82 =
* Fixed: `doc.body.innerHTML` drops a leading HTML comment — the HTML parser puts a comment that precedes the first element at the DOCUMENT level, as a sibling of <html>. All four DOMParser writers in `linkUtils.ts` (`updatePostContentWithLinkChanges`, `replaceUrlsInContent`, `replaceSingleUrlInContent`, `clearAllLinkStatuses`) therefore stripped a block post's opening `<!-- wp:paragraph -->` on every link write. Consequence chain, confirmed on soccerstories.net (17 of 101 posts): a closer with nothing open makes Gutenberg's parser give up and put the REST OF THE DOCUMENT into one Classic block; the Classic block is TinyMCE, whose save runs removep() — every <p> stripped, <br> → newline, lists re-wrapped in `<li style="list-style-type: none">`, embed blocks → bare URLs, browser-extension DOM (Shaper) saved as content. has_blocks() stays true so wpautop never runs. New `serialiseParsedContent()` in `utils/blockMarkup.ts` re-attaches document-level comments; the writers use it. Proven with DOMParser in-browser; harness tests the serialiser against the exact node shape the parser produces.
* New: `scanBlockMarkup()` / `analyseBlockMarkup()` / `repairBlockMarkup()` (pure, DOM-free) + `sections/BlockMarkup.tsx`. Findings: stray-closer (mirrors Gutenberg's parser: closer at depth 0), bare-paragraph, extension-debris, template-closer (never-opened post-content/query/group closers), broken-list (report-only). Repair: debris removed, orphan template closers removed, the LEADING lost opener re-synthesised (heading level / ordered inferred from the markup; a stray closer deeper in the document is left for the editor), bare paragraph text re-wrapped (blank lines split into separate paragraph blocks, single newlines → <br>, empty blocks dropped). Saves through the existing `updatePost` path (wp_kses_post + wp_slash), so a revision is kept. Idempotent; no-op on clean, Classic and group content.
* Harness: tests/block-markup-harness.ts, 49 assertions, fixtures verbatim from posts 496/675/4006.

= 2.81 =
* Fixed: Broken Video Embeds false positives + a Refresh that could break a working embed. `url_renders_iframe()` called `$wp_embed->shortcode()` from the REST request; WP_Embed keys the post-meta cache on md5( url . serialize( attrs ) ) and attrs carry `wp_embed_defaults()`'s width = the theme's `$content_width`, which Astra (and others) set per request (is_single()/is_home() branches) — unreproducible from REST. Every dashboard check therefore missed the real entry, did a fresh provider fetch, and on a rate-limited failure wrote an orphan `{{unknown}}` under a key the page never reads, keeping the post in the suspect set forever. `refresh_post_embeds()` then deleted ALL `_oembed_*` rows including the good one. Now: `cache_state()` reads the cached HTML by VIDEO ID (key-independent, provider-bounded regex so id "abc" can't match "/embed/abcdef"); the provider is asked only when the cache can't vouch, via `wp_oembed_get()` which writes no post meta; refresh deletes only `{{unknown}}` markers (+ `_oembed_time_` twins) and `prime()`s via a real front-end self-request with a cache-busting query arg so WordPress re-fills the cache under its own key. Deep scan: cache first, provider for unprimed. `recheck()` returns `state`.
* New: rows carry `embeds[]` (`videoId`, `provider`, `thumbnail`); YouTube's `i.ytimg.com/vi/<id>/mqdefault.jpg` 404s for a deleted video, rendered as "video unavailable".
* New: `replace_embed_url()` + `POST /embed-health/replace` — exact-string swap of the old URL in post_content including its JSON-escaped form in the block comment (so block attrs don't drift from the wrapper, the 2.60/2.62 false-positive source), `wp_slash()`ed `wp_update_post()`, in-handler `edit_post`, then clear markers + prime + report. UI: "Swap & save" inline editor per row; "works now — re-save the post" → "renders now".
* Fixed: `get_missing_focus_keywords` returned `seoPlugins.rank_math` while the React type reads `rankMath` — Rank Math always read as absent in the Focus Keyword Assistant and `syncRankMath` was never true, so saves never wrote `_rank_math_focus_keyword`. Mapped to camelCase in the handler.
* Harness rewritten to the cache-by-id model: 75 assertions, including the field case (working video + {{unknown}} marker + rate-limited provider → NOT flagged, zero provider calls) and backslash preservation through the replace save.

= 2.80 =
* Fixed: every "Upgrade to Pro" button in the plugin's own settings and notices led to a page that does not exist. The upgrade link was hardcoded to a /pro address on the SEOMedic site, which returns "page not found" — so anyone clicking it from the settings screen, the AI feature prompts or the upgrade notice landed on a 404 instead of the pricing page. Those five buttons now open Freemius's pricing screen inside your dashboard, the same place the dashboard widget already sent you.

= 2.79 =
* Added: `Plugin URI` header in seomedic.php and `Donate link` in readme.txt, both https://getseomedic.com/. The WP.org directory renders Plugin URI as the sidebar "Plugin Homepage" link and Donate link as "Donate to this plugin"; Author URI is never shown there (the author name links to the wp.org profile from Contributors). With neither set, the listing had zero links to getseomedic.com, which is why backlink tools reported none.

= 2.78 =
* Improved: LOADING_DETAIL_MS 2200 -> 4200. Eight lines cycled in under twenty seconds, so a minute-long load repeated the same messages three or four times and none stayed up long enough to finish reading. A full cycle is now ~35s. Added a 500ms fade keyed on the index so a change reads as a change rather than a flicker, with a prefers-reduced-motion override that flattens it.

= 2.77 =
* New: /post-index now carries impressions, index status and inbound link count per entry, and LinkToPost renders all three, sorted by impressions descending. Every value comes from data already on hand — no scan, no API call.
* Impressions: sync_clicks_post_meta() already had them in the same GSC response and was discarding them. New _seomedic_gsc_impressions meta, written alongside clicks and deleted in the same stale-cleanup pass so a number cannot outlive the window it came from.
* Index status: read from the URL Inspection store added in 2.69. Three states, not two — 'unchecked' is distinct from 'no', because Google not having been asked is not the same as Google saying no.
* Inbound counts: new seomedic_inbound_counts option (autoload=false) written once per dashboard load via POST /internal-links/counts. The count is only cheap in the dashboard, where the link graph is already parsed in the browser; the alternative was a post_content LIKE scan per candidate per keystroke, or a full server-side content parse on cron. The trade is staleness, and updatedAt is stored so the UI can state the age rather than implying currency. Empty maps are refused server-side: a dashboard that failed to load posts would otherwise wipe a good map and make every candidate read "0 links", which looks like data rather than absence.
* /post-index response gained a wrapper ({posts, inboundUpdatedAt}). Both client callers accept either shape, and the transient is shape-checked rather than trusted, so a cache written by the previous build is treated as a miss instead of handed back unreadable.
* Both new keys added to uninstall.php and the Freemius cleanup.

= 2.76 =
* Fixed: the trashed-redirect admin notice used `is-dismissible`, whose ✕ core injects and which only removes the element from the DOM — it persists nothing. Users reasonably read it as "stop suggesting this", so the notice reappeared on every edit-post and posts-list screen, while the one control that actually called dismiss_trashed_redirect() sat inside a <details> disclosure. New wp_ajax_seomedic_dismiss_trashed_redirect handler (nonce + edit_posts checked), post id and nonce carried as data attributes on the notice, and a delegated click listener on .notice-dismiss. Fire-and-forget: the notice has already left the screen, so a failed POST reappears once rather than throwing an error at someone who has moved on.

= 2.75 =
* Fixed: scanH1Issues counted only post_content and flagged at `h1s.length > 1`, missing the standard WordPress shape where the theme renders <h1 class="entry-title"> for the title — one content H1 already makes two on the page. Confirmed against a live page during investigation: two rendered H1s (entry-title + content), reported as fine.
* New core file class-h1-audit.php calibrates the theme's contribution ONCE — fetch newest post + newest page, count rendered H1s, subtract that post's own content H1s — then the number applies site-wide with no further requests. Post and page sampled separately: a theme that titles posts but not pages is common, and reading one from the other would put a false finding on every page. Served via /bootstrap, 6h transient, flushed on switch_theme.
* A failed self-request returns null for that type and the scan falls back to the content-only rule. Never assumes a title H1 exists — an invented "duplicate H1" on every post of an unmeasurable site would be far worse than the miss being fixed. Rows name the theme heading explicitly, or one heading would be listed under a count of two.
* themeH1For() and isDuplicateH1() extracted as pure functions: scanH1Issues needs a DOM and bails outside a browser, which would have left this change untested. h1-audit-harness.ts, 16 assertions.
* New: rotating detail line on the loading screen naming real work in progress. Deliberately a caption rather than tick-off steps — the pass is not separately observable, and five invented green ticks would be a nicer-looking lie than one honest line.

= 2.74 =
* Fixed: initialLoadDone now waits on `scansReady` (the useDeferredValue pass having caught up with allPosts). Reported as "appears, then unresponsive for a couple of seconds, counts a second late" — that is useDeferredValue behaving exactly as designed: React renders with the PREVIOUS value and schedules the expensive pass at low priority, so every DOMParser-heavy memo over 500 posts ran AFTER first paint, which is when the user is trying to click.
* It never caught up during loading on its own: setPostsProgress fires per fetch batch, and each high-priority render pre-empts the low-priority pass, so the deferred value could stay stale through the whole load and present its bill the instant the screen changed. Gating moves the work behind the loading screen the user is already watching — the memos in the component body run on every render, early return or not, so by the time the deferred value matches, every scan is computed.
* `isScanStale` already existed at Dashboard.tsx:532 and had no consumer; it now has one.
* Escape hatches: `allPosts.length === 0` short-circuits, an 8s cap (SCAN_WAIT_MS) covers a deferred value that never settles, and hardTimeoutFired still overrides everything. The cap is armed on `loading` flipping false rather than at mount — a timer from mount would expire during a slow fetch, spending the allowance on the wrong wait. Latched, so it only ever delays the FIRST reveal; rescans keep the dashboard up via hasLoadedOnce.
* New "Preparing the dashboard" step on the loading checklist, so the added wait reads as progress rather than a hang.

= 2.73 =
* New: internal link validation. New core file class-internal-link-audit.php + free routes POST /internal-links/check (database resolution, no HTTP) and POST /internal-links/verify (self-request confirmation, capped at 10/batch). New 'Broken' tab in InternalLinkHealth.
* The gap it closes: linkUtils.ts set `status = isExternal ? IDLE : OK` — internal links were marked OK on sight — and class-link-checker.php carried the comment "internal link — always OK, no redirect detection needed". Safe while nothing in the plugin could remove a page; unsafe from 2.71, when the Indexability section gained a Trash action.
* Resolution is two lookups: url_to_postid(), then a slug lookup across ANY status. The second exists because WordPress renames a trashed post's slug to `slug__trashed`, so url_to_postid() returns 0 for exactly the case the feature exists to catch — without it the audit would miss its own motivating bug. Direct query justified in-comment: get_page_by_path() excludes trashed posts.
* Anything neither lookup resolves is 'unresolved', NOT broken — category archives, tags, author pages, CPTs and paginated URLs all fail both lookups while being valid. A scan that reported every category link as broken would be worse than no scan. Confirmation is an opt-in second pass; a failed loopback request reads as 'unknown', never 'missing', since plenty of hosts block self-requests.
* 'private' is its own verdict rather than dead: the page exists and editors can read it, so a public link to it is a warning, not a broken link.
* Tests: internal-link-audit-harness.php (26) — the trashed-slug case, and five separate assertions that archives/tags/authors/CPTs/pagination are not called broken. 13 harnesses, 556 assertions.

= 2.72 =
* Fixed: class-admin.php localised gscProperty by rebuilding 'sc-domain:' . host — the fifth hardcoded property site and the only one missed in 2.69, while its own comment claimed to be "the exact string used by PHP API calls". Every Search Console deep link (Pruning's Check, and the new recheck link) was built from it, so on a www-hosted site they addressed sc-domain:www.example.com — a property nobody owns. Now reads SEOMedic_GSC_Integration::get_property().
* New: "Ask Google to recheck" deep link into URL Inspection on kind === 'quality' rows. Google's Indexing API is restricted to JobPosting/BroadcastEvent, so Request Indexing is necessarily a manual action; the link closes the loop between improving a page and telling Google.
* Improved: corrected the crawled-not-indexed copy. It asserted "More internal links will not change it", which is wrong — internal links carry importance signals as well as discovery, so they are a weak lever rather than a zero one, capable of tipping a borderline page though not of overturning a content judgement. Now reads "unlikely to be enough on their own", and states that the verdict is not permanent. The harness assertion was inverted to match and carries the reasoning.

= 2.71 =
* New: CoverageMeaning gains `kind` ('discoverability' | 'quality' | 'technical' | null) so the UI can branch on the distinction rather than only print prose. Copy for the confusable pair rewritten to lead with the contrast — "Google has NOT looked at this page" vs "Google HAS looked and decided against indexing it… more internal links will not change it" — and a per-kind count strip added above the filter pills, since a user scanning a long table will not reliably tell two similar sentences apart.
* New: POST /posts/{id}/trash — the plugin's first destructive action, and deliberately the gentlest: wp_trash_post(), never wp_delete_post(). Capability checked per post with `delete_post` rather than the route's blanket `edit_posts`, so an author cannot bin someone else's published page. Trashing runs through wp_trash_post so the existing capture_phantom_on_delete() hook records the still-public permalink for the redirect map.
* The Trash action is offered ONLY on rows where kind === 'quality' — Google fetched the page and declined it. On a "not crawled yet" row nothing has judged the content, so a delete button there would invite binning a page nobody has read. Two-step confirm, an undo link to the WordPress trash screen after, and copy stating the URL will 404 unless a redirect is set in Content Pruning first.
* Tests: coverage-state-harness +8, asserting the two states differ structurally (kind) and that the copy says which way round it is.

= 2.70 =
* Fixed: INSPECT_PRIORITY split from STATUS_ORDER in IndexabilityDashboard. The inspection queue reused the DISPLAY order (blocked first, worst-config-first), so a live 521-page run spent its first ~200 metered inspections on noindexed thank-you/backup/login pages whose verdict was already knowable, while 'not-indexed' rows waited at the back. Quota is metered and daily, so the order is the feature. Blocked pages are now skipped by default (BLOCKED_ARE_PREDICTABLE) with an opt-in checkbox — kept available because a live run found a noindexed page Google reported as "Crawled, rejected" rather than "Excluded by noindex tag", which suggests the tag may not be served.
* New: 'URL is unknown to Google' mapped in coverageState.ts — the most common state on a real site, previously falling through to 'Other'. Ordered BEFORE the 'discovered' rule so a compound phrasing cannot report a crawl-budget problem for a page Google has never encountered.
* Fixed: set_property() now calls clear_data_caches() when the value actually changes. Cache clearing previously lived only in the REST picker handler, so the SELF-HEAL path — the one most users hit, since they never open the picker — left the old property's rows in place under the new property's name.
* Fixed: dropped "reload the page" from the property-corrected message; get_property() re-reads the option on every call, so the correction applies to the next request.
* Tests: gsc-property-harness +3 (cache invalidation on change, no invalidation on no-op), coverage-state-harness +4. Removed a fabricated fixture from the punctuation-drift list ('URL is unknown to Google: Discovered, not indexed' — a string Google does not emit) that was asserting the wrong precedence once the real rule landed; same trap as 2.11.4-2.11.6.

= 2.69 =
* New (Pro): URL Inspection integration. inspect_url() / inspect_batch() in class-gsc-integration.php with a per-URL store (seomedic_gsc_index_status, keyed by URL with a checked-at stamp and a 14-day TTL — deliberately NOT the date-bucketed top-200 ring buffer used for snapshots, since the pages needing a verdict are exactly those with no impressions to rank by), daily quota accounting against Google's 2,000/property cap that REFUSES at the limit rather than collecting 429s, and client-driven batching with a Stop button. New Pro route POST /gsc/index-status, restricted server-side to URLs on this host.
* New: utils/coverageState.ts maps Google's coverageState strings to plain English plus the specific fix. Substring matching, not exact — Google has changed this punctuation before and an exact table would silently degrade a whole site to "unknown". Unmapped states quote Google verbatim rather than inventing a label.
* Fixed: the Search Console property was hardcoded as sc-domain:{host} at all five call sites, so URL-prefix-verified users got 403 on every call. New resolve_property() calls sites.list at connect time and picks the real property (domain preferred, then longest matching URL prefix); a 403 with no stored property self-heals by resolving once, guarded against recursing when the lookup itself 403s. Pro routes GET /gsc/properties + POST /gsc/properties/select, with an admin-only picker in the status bar. Switching property clears cached rows but keeps tokens and snapshot history.
* Refactor: new gsc_api_request() replaces five duplicated wp_remote_post blocks. Adds the first 429/5xx retry handling the Google path has ever had, and deliberately does NOT retry 403 or 400 — both spend quota to get the same answer back.
* Fixed: Content Pruning exclusions extended from noindex to all four deliberate-exclusion signals via utils/intentionallyExcluded.ts (noindex incl. Yoast/Rank Math, redirect, canonical elsewhere). Google's own "not indexed" verdict is deliberately NOT an exclusion — that is Google's opinion, not the user's intent.
* Tests: new gsc-property-harness.php (42) and coverage-state-harness.ts (32). 12 harnesses, 516 assertions, all from bash tests/run.sh.

= 2.68 =
* Fixed: is_effectively_noindex() / noindexAny (added 2.39, reads SEOMedic + Yoast + Rank Math) was plumbed to the client but read by exactly one section. IndexabilityDashboard, NotInGoogle, SitemapAudit and Pruning all read the narrower post.noindex — SEOMedic's own meta only — so a page noindexed in Yoast or Rank Math was labelled not-indexed / never-surfaced / should-remove-from-sitemap, and appeared as a delete candidate. New shared helper admin/src/utils/noindex.ts::isNoindexed(); all five sections now use it and no stray post.noindex audit reads remain.
* Fixed: Content Pruning's candidate list had NO noindex filter at all. Zero impressions is its entire entry criterion, and on a noindexed page that is the expected result of the user's own instruction — so the section recommended deleting pages because the user had successfully excluded them from Google. Noindexed pages are now excluded, with a visible count and explanation rather than a silent change.
* Tests: the click-depth (40 assertions) and template-link (28) harnesses moved from the scratchpad into tests/ and are run by tests/run.sh, which now bundles *-harness.ts with the esbuild already in admin/node_modules. Both guard shipped-then-fixed bugs; a guard that disappears with the session is not a guard.

= 2.67 =
* Maintenance: removed the dead SEOMedic_Hub_Planner::delete_all(), which had zero callers. Both uninstall paths (uninstall.php and the Freemius after_uninstall callback) hardcode 'seomedic_hubs' and '_seomedic_hub' as literals and must, since uninstall.php runs standalone under WP_UNINSTALL_PLUGIN with no plugin classes loaded. Replaced with a note on the constants warning that a rename means editing both uninstall paths by hand and that a miss is silent. No functional change.

= 2.66 =
* Fixed: removed the position-based rule from template-link detection (2.65). maxPositionVariance 0.02 permits a standard deviation of 14% of body length, so it could not distinguish a pasted CTA from a pillar linked consistently from each post's intro — it flagged legitimate hub-and-spoke structures, zeroed their contextual inbound counts and listed the pillar as an orphan while the Hub Planner showed it fully linked. Identical surrounding markup (contextHashShare) is now the only test. Regression guards added.
* Fixed: posts registered as hubs are exempt from template-link classification via a new protectedPostIds option — an explicit user declaration outranks a statistic.
* New: the template-only orphan row now names the repeated block and its anchor, so the finding can be checked rather than just asserted.
* Fixed: HubPlanner's "remove" control was gated on !row.linksToHub, so it vanished in the success state; the DELETE spokes route existed but was unreachable from the UI.
* Fixed: HubPlanner rendered without an isPro gate — createHub surfaced a raw "HTTP error! status: 404" toast on free. Now shows a ProHint.
* Fixed: acceptSuggestion awaited a handler that resolved on failure, so it fired N failing spoke assignments and then announced success. Now bails, and assigns the whole group in one request instead of N (each of which triggered a hubs refetch).
* Improved: "Hub links back" column explains that the up-link is the one that matters.

= 2.65 =
* Fixed: click-depth BFS seeded at a hardcoded "/" and built its graph from post content alone, so on a blog-index homepage (and on every subdirectory install) the queue drained immediately and every page reported as unreachable. Now seeds from nav menus + the reconstructed home listing, derives the home path from the site URL, unions rather than overwrites on normalised-path collision, and reports a third "couldn't measure" state instead of flagging the whole site.
* New: nav menus, block-theme wp_navigation, widget-placed menus and the home listing are read from the database (no HTTP) by a new core class, SEOMedic_Structural_Links. Menus assigned to no theme location are excluded from depth seeding.
* New: 'nav only' class in Click Depth — shallow for a crawler, zero contextual inbound links.
* New: in-body template-link detection (pasted CTAs, author bios, automated linking). Reported separately; raw inbound counts are unchanged.

= 2.64 =
* Fixed: the statistics cards at the top of the dashboard now take you to the section they count (previously the scroll target only existed while that section was already expanded, so clicking a card did nothing). "No Meta Desc" now opens the Missing Meta Descriptions section rather than the cut-down list inside the links table.
* Fixed: the dashboard no longer discards clicks in the first moment after it appears.

= 2.63 =
* New (Pro): **Your top 3 SEO tasks** — AI picks the three highest-impact things to do this week to improve rankings, focused on your highest-impression pages. Checkable; the list and progress are saved.

= 2.62 =
* Improved: Broken Video Embeds now only tests the video that actually renders (ignoring a stale URL left in an edited block) and confirms against the same cache visitors see — no more false positives on working videos.
* New: one-click **Refresh embed** button — clears WordPress's cached copy and re-fetches, fixing embeds that went blank from a stale cache.

= 2.61 =
* New: **Deep scan** button in Broken Video Embeds — optional, checks every video embed on every post and page, with progress and a Stop button. The fast scan stays the default.

= 2.60 =
* New: **Broken Video Embeds** check — finds posts where a YouTube/Vimeo embed shows a blank box instead of the video, with a per-video "re-check" button.
* Fixed: the video scanner no longer flags a working embed as "not embedded" when its block's saved URL had drifted from the one it displays.

= 2.59 =
* Fixed: the Focus Keyword vs Ranking section could show a "connect Google Search Console" message even when it was connected and already displaying your keyword-vs-query summary.

= 2.58 =
* New: **Internal Link Map** — a whole-site visual map of how your posts link to each other, coloured into clusters, with zoom/pan and click-to-edit.
* New (Pro): **Branded Search** — track how your brand name performs in Google Search Console versus the previous period, with a per-query breakdown.

= 2.57 =
* Maintenance: further internal cleanup of how post titles are decoded for display; no functional changes.

= 2.56 =
* Maintenance: internal code cleanup (consolidated a duplicated helper); no functional changes.

= 2.55 =
* Fixed: post titles containing an apostrophe or ampersand showed raw codes (e.g. "Why I&#8217;m…") in Internal Link Impact and a few other panels. Titles now display correctly, in the panels and in CSV exports.
* Improved: the Internal Link Impact "vs site" column is now "Page vs. whole site" and states plainly whether a page ran ahead of or behind the site-wide trend.

= 2.54 =
* Housekeeping: WordPress.org compatibility fixes (plugin-checker), no functional changes.

= 2.53 =
* New (Pro): **manage which links count as affiliate** — add your own networks or cloak paths (e.g. `mynetwork.com`, `/go/`) on top of the built-in list, from the Affiliate Link Clicks panel.
* New (Pro): **notes on any affiliate link** — jot "offer expired", "swap for X", etc. against a link; notes stay put even as click history rolls over.

= 2.52 =
* New (Pro): **Affiliate Link Clicks** — see how many times visitors click your outbound affiliate links, per link and per page, with a trend line. On a newer site, sales are a slow signal; clicks show a page is working long before it earns a commission, and a link with lots of clicks but nothing to show for it is worth a look (the offer may be wrong, or the link dead).
* Ties into Search Console: a "clicks per organic click" column shows how well each page turns Google traffic into affiliate clicks.
* Counts are collected in the visitor's browser and stay on your site — nothing is sent anywhere. They're approximate (ad-blocked and no-JavaScript visits aren't counted) and track clicks, not sales.

= 2.51 =
* Changed: the image-optimiser link in the Oversized images panel is now an affiliate link, and is labelled "(affiliate link)" like the hosting one, with `rel="nofollow sponsored"`. The FAQ disclosure has been updated to list both.
* Nothing about what the plugin reports changes. The Oversized images panel recommended a dedicated optimiser before there was any commercial relationship, for the same reason it does now: resizing and compressing are different jobs, and this plugin only does the first.

= 2.50 =
* Maintenance: rebuilt release with no functional changes from 2.49 (fresh package).

= 2.49 =
* Changed: affiliate links in the plugin are now labelled "(affiliate link)" where they appear, with a note explaining that SEOMedic earns a commission at no extra cost to you and that it never affects what the plugin reports. A matching disclosure has been added to the FAQ. There is one such link today — WordPress-optimized hosting, shown only when the dashboard is loading slowly.

= 2.48 =
* New: **which element is your LCP**, shown under each page in the Core Web Vitals table. An LCP of 3.4s tells you there's a problem but not what to fix — this names the actual element whose render time *is* that number, and flags when it's an image.
* New: **redirect chains**. If a page is served through one or more redirects, the row says so and how many milliseconds it costs. Linking the final URL directly removes it — and this is the kind of thing that's invisible until something measures it.
* Both come from the PageSpeed response the Core Web Vitals scan already makes. No extra requests, no extra quota.
* Note: the LCP element depends on which Lighthouse version PageSpeed happens to be running. Google removed the audit it came from and replaced it, so SEOMedic tries the old and new names and simply shows nothing if neither is available.

= 2.47 =
* New: **Save original** link on every oversized image, next to the Resize button. Resizing overwrites the full-size file and can't be undone, so this is the one chance to keep a copy. The confirmation now points at it rather than just warning you.
* Changed: the Oversized images panel now links to an image optimiser for the half of the job it deliberately doesn't do, and explains the difference — an optimiser shrinks the file it's given, this decides the file is the wrong size.

= 2.46 =
* New: **Oversized images** in the Image SEO Audit. Lists originals in your media library that are wider than 2000px or heavier than 300 KB, heaviest first, with dimensions and file size.
* New: **one-click resize** for images stored far larger than any page displays them — a 4000px original behind an 800px slot. The file keeps its name and URL, so nothing that links to it breaks. Administrators only, and it warns you first that the original is overwritten and can't be recovered.
* **This is not an image optimiser, on purpose.** Compression and WebP conversion are better handled by a dedicated plugin like ShortPixel, and this won't undo its work or compete with it. What those plugins don't do is decide an image is the wrong *size* — they compress whatever they're given. That gap is what this fills.
* Images that are heavy but not oversized are labelled as needing compression rather than offered a resize, because shrinking them wouldn't help.
* New: **image savings from PageSpeed Insights** in the Core Web Vitals table. Google already tells you which images on a page could be smaller and by how much — that data was arriving in every scan and being discarded. Costs no extra requests and no extra quota.

= 2.45 =
* Changed: **Biggest Movers now only shows keywords in or near the top 20.** Movement between position 27 and 24 isn't something you can act on, and it was crowding out the rows that matter. A keyword that *fell out* of the top 20 still appears — that's the most important thing this card can tell you, so it's filtered on the better of the two positions rather than on where the keyword sits today.
* New: **Edit and Find links** on every row of Biggest Movers — Top 15. The card told you a page was one push from page one without offering any way to act on it. Edit opens the post; Find links jumps to Internal Link Booster to look for pages that could link to it.
* New: **"Load automatically from now on"** on the search-data prompt. That prompt asks once per browser session by design, because fetching Search Console data is the slowest thing on the page. If your dashboard loads comfortably you can now switch it off permanently.
* Changed: the loading screen said "Auditing focus keywords" while doing considerably more than that. It now says "Auditing your site".
* Fixed: an image tag with no working `src` was reported **twice** — once as broken, and once as missing alt text. The second one asked you to describe an image that doesn't exist, and showed no preview because there was nothing to preview. Broken images are now reported once.
* Changed: the "Bad filename" label said something was wrong without saying what. It now reads **"Filename says nothing"**, and hovering explains that the name is an auto-generated one — a camera export, a screenshot, a numbered upload — which gives Google Image Search no signal about the picture.
* New: **Rename file** button on filename issues. Generating a suggested filename previously dead-ended at "copy this and rename it yourself in the Media Library". It now renames the file and updates every reference to it across your site in one click. Administrators only, and it warns you first that the old image URL will stop working.
* Fixed: an image preview that failed to load simply vanished, leaving an unexplained gap. It now shows a marker saying the file itself didn't load — which is a finding worth seeing in its own right.

= 2.44 =
* Fixed: the "Important Upgrade Notice" shown on the Plugins screen displayed raw HTML markup wrapped around the message, and escaped quote characters inside it, instead of a clean sentence. The update API returns that notice as rendered HTML; WordPress core strips the markup before displaying it, but the Freemius updater only escapes it — which is why this showed on Pro installs and not on the free version. SEOMedic now cleans its own notice before either one displays it. Cosmetic only.

= 2.43 =
* New: **Site Speed — autoloaded options**, a read-only diagnostic on the Settings page. WordPress loads every option marked "autoload" on every single request, and plugins you have deactivated or deleted routinely leave theirs behind — dead weight paid for on every page load. Nothing else in wp-admin shows you this; until now the only way to see it was to open the database by hand.
* Shows the total, the number of options, a plain-English verdict, and the 20 largest entries so you can spot names belonging to plugins you no longer run.
* It reports and never deletes. A row that looks abandoned can belong to a plugin you are about to reactivate, and the safe way to remove one is to delete that plugin properly from the Plugins screen — deleting runs its uninstall routine, deactivating does not, which is exactly why a deactivated plugin can still be costing you.
* It does not guess which plugin owns a row. Option names do not map reliably to plugin names, and a confident wrong accusation is worse than no label, because you would act on it.
* Free for everyone, no external requests.

= 2.42 =
* Fixed: the dashboard could lock the browser up on load, and on larger sites Chrome would show "Page unresponsive". The internal-link analysis was re-reading and re-parsing the HTML of every post two or three times per page load, because it re-ran each time a new batch of data arrived even though none of it changed what the parse produces. It now parses each post once and reuses the result.
* Changed: that parsing is also spread across small slices with a pause between them, instead of arriving as one long block. The browser stays responsive to clicks and scrolling while it happens.
* Changed: the analysis no longer blocks the first paint of the page — the dashboard draws, then the link data fills in.
* Net effect on a 500-post site: around 4,500 HTML parses per load become around 1,000, and those are spread out rather than run all at once.

= 2.41 =
* Fixed: in Focus Keyword vs Ranking you had to scroll the table sideways to reach Save. The Actions column is now pinned to the right edge, so it stays in view however wide the table gets — and it gets four columns wider once search volume is added.
* Changed: the small "SEOMedic" label under every focus keyword is gone. It was there to say which plugin the keyword lives in, but it never varied, so it was noise on every row. Keywords that come from Yoast or Rank Math still say so, because that tells you an edit here writes back into that plugin.
* New: **Hide exact matches** checkbox. A row badged "✓ exact" can still appear under "Needs attention" — the keyword matches, but another query on the same page earns far more impressions, so a better keyword is available. That is deliberate, and now you can filter those rows out when you only want the genuine mismatches.
* Changed: the search-volume columns now say **focus keyword** under the heading. That row shows a focus keyword, a top query, a matched query and a suggestion, and nothing said which one the volume and CPC figures described. They are for the focus keyword — or your unsaved draft, if you have staged one.

= 2.40 =
* Fixed: the **Impressions (90d)** column in URL Export was not 90-day data. It showed whichever window the dashboard's 30/90 comparison toggle was on — 30 days by default — under a 90-day heading, in both the table and the exported CSV. It now shows a real fixed 90-day figure, fetched when you open the section.
* Fixed: the URL Export CSV did not guard against spreadsheet formula injection. A post title beginning with `=`, `+`, `-` or `@` was executed as a formula when the file was opened in Excel, Sheets or Numbers. The 2.37 release fixed this for the other exports and missed this one. The file now also carries a byte-order mark, so Excel stops mangling non-Latin titles.

= 2.39 =
* New: **Bulk meta descriptions.** Tick the posts you want, generate drafts for all of them in one go, review, then save them together. Generating never saves — every result lands as an editable draft, and writing to your posts is always a separate, explicit click.
* New: **Hide noindex** in Missing Meta Descriptions. A page you have set to noindex is not in Google, so a missing meta description on it cannot cost you a click. The setting is remembered between visits — the whole point is that those pages stop coming back — and a full Rescan clears it when you want to audit everything.
* Fixed: that filter now sees noindex set in **Yoast and Rank Math**, not just noindex set inside SEOMedic. Previously a page you had excluded in your SEO plugin would keep reappearing, which made the filter look broken.
* Changed: the Impressions column in Missing Meta Descriptions now says **(90d)** and shows a fixed 90-day figure, so your priority order does not shift when you switch the dashboard's 30/90 day comparison toggle.
* New: **Top query** column in Focus Keyword vs Ranking — the search term Google actually sends the most people to each page for, on every row, with a one-click "use" to make it that page's focus keyword. It sits next to the keyword you have set, so deciding whether to change it is a glance rather than an investigation.
* New: Biggest Movers — Top 15 now shows **incoming and outgoing internal links** per page, optional **search volume** for each query, and a **30-day** position change alongside the existing 90-day one. A page ranking in the top 15 with no internal links pointing at it is flagged — that is usually the cheapest improvement available.
* The 30-day figure shows a dash until there is genuinely a month of history behind it, rather than labelling a four-day move as a monthly one.

= 2.38 =
* Fixed: **changing the Claude model in Settings did not stick.** The "Save and Test Key" button sits at the top of the settings form and looks like the save button, but it only ever saved the API key — then reported success. Any other change on that form, including the model, was discarded on the next page load. It now saves everything on the form, and is styled so the real Save button is the obvious one.
* Fixed: the same button was also discarding the AI prompts and the Keywords Everywhere, Open PageRank and PageSpeed Insights keys if you edited them and clicked it.

= 2.37 =
* New: **Focus Keyword vs Ranking** — a section that answers the question the plugin could never answer before: does each page actually rank for the keyword it targets? Shows the focus keyword beside the query Search Console says the page really earns its traffic from, with position, impressions, clicks, CTR against your own site's typical CTR at that rank, inbound internal links, and when the post was last updated.
* Honest about what Search Console can tell you. It returns at most 25 queries per URL, so "not in top 25" is shown as exactly that — not as "doesn't rank" and never as a made-up position 100. A merely *related* query is labelled and styled as related; its position is never presented as your keyword's position.
* Suggests a better focus keyword from queries the page already ranks for, preferring positions 4-20 where a change can actually move things. It stays silent when your keyword is already the right one — a page ranking #3 for its keyword gets no suggestion, because churning it would be a downgrade.
* Edit any focus keyword inline and save it from the row; syncs to Yoast and Rank Math as usual. Optional ✨ AI suggestion per row reads the page content alongside the query data, using whichever Claude model you selected in Settings.
* Export the whole table as CSV — including a machine-readable match state per row — to hand to Claude for direction on what to fix first.
* Optional Keywords Everywhere search volume for the keywords shown (1 credit each, dry-run and confirmation first, as everywhere else). Ranking position comes from Search Console and costs nothing.
* Fixed: saving a single focus keyword triggered a full-site database scan to recount how many posts were still missing one — a number that save never used. Inline edits on large sites were slow for no reason.
* Fixed: CSV exports did not neutralise spreadsheet formula injection. A keyword or search query beginning with `=`, `+`, `-` or `@` was executed as a formula on opening the file. All exports now guard against this, and add a byte-order mark so Excel stops mangling non-Latin queries.
* Changed: the Focus Keyword Assistant's "All Keywords" tab has been removed. It compared your keyword against the top query without knowing either one's position, so it could tell you they differed but not whether that mattered. The new section does that job properly. The Assistant keeps its real job: filling in posts that have no keyword at all.

= 2.36 =
* New: **Score domains** in the External Links table — one click looks up the Open PageRank of every domain you link out to and shows it beside each URL. Useful for spotting a page whose outbound links all point at nothing, and for finding links worth keeping when pruning.
* Uses Open PageRank (free key, 1,000 lookups a day), not Keywords Everywhere — this button spends no credits. Scores are cached for 30 days per domain, so paging through a large table costs at most one lookup per page.
* Badges read "OPR 4.2" rather than a bare number, because the scale is 0–10 and will be misread as Moz DA (0–100) otherwise. A domain Open PageRank has no score for shows "OPR —"; a domain that could not be reached shows nothing at all and is retried on the next click — a failed lookup is never recorded as a zero.
* Without an Open PageRank key the button explains what to add rather than failing silently. The key lives under SEOMedic → Settings.

= 2.35 =
* New: the Backlinks card now lists the domains actually linking to you, scored by Open PageRank, strongest first. This costs no additional Keywords Everywhere credits — the linking domains were already in the response you pay for on every refresh, and were previously counted and discarded. If you have refreshed backlinks before, you were buying this data and throwing it away; it is kept from now on.
* Scoring uses Open PageRank (free, separate key), so a full backlink profile costs nothing beyond the refresh you already ran. Domains that could not be scored show "—" rather than zero — not being reached is not the same as scoring nothing.
* The stored list is capped at 300 domains, and scoring is limited to 200 per refresh so a slow host is not left waiting on a long chain of extra requests. Anything unscored fills in on a later refresh.

= 2.34 =
* New: domain authority on the Backlinks card, via Open PageRank — Keywords Everywhere's authority product. Add its free API key under SEOMedic → Settings (1,000 requests a day, no card). Note the scale: Open PageRank runs 0–10, PageRank-style. It is NOT Moz Domain Authority, which runs 0–100, and the two numbers are not comparable. Costs no Keywords Everywhere credits. Authority is recorded in the history too, so it gains its own over-time comparison.
* Improved: backlink comparisons no longer pretend to be 30 and 90-day windows. They now compare against the checks you actually made — the most recent one and the oldest on record — each labelled with its real age ("43 days ago"). Previously a perfectly good 43-day-old check was discarded for not landing near day 30, and the card said "not enough history yet" while sitting on usable data.
* Improved: credit costs are stated as numbers, not warnings. The Backlinks card now shows an estimate based on your own referring-domain count (5 credits each), and the Settings breakdown says plainly that this is the most expensive button in the plugin at up to 5,000 credits per refresh, while Open PageRank costs none.
* Note: history recorded by 2.33 is preserved — the new format is read alongside the old one.

= 2.33 =
* New: the Backlinks card now shows how your referring-domain count has moved — "vs ~30 days" and "vs ~90 days", each stating the age of the snapshot it actually compared against. History starts from this release: Keywords Everywhere cannot supply past figures, and the count was previously overwritten on every refresh, so nothing can be backfilled. Expect "not enough history yet" until you have refreshed a few times over a couple of months.
* Fixed: the Backlinks refresh tooltip claimed each refresh "uses one Keywords Everywhere API credit". It does not. Keywords Everywhere bills 5 credits per referring domain returned, and the plugin asks for up to 1,000 — so a refresh on a well-linked site can cost thousands of credits. The tooltip now says so. Nothing about how often it refreshes has changed; only the warning was wrong.
* Fixed: the stored backlinks count and the new history were not removed on uninstall. Both are now cleaned up on both the free and Pro uninstall paths.
* Note on domain authority: Keywords Everywhere's backlinks endpoint returns no authority, domain rating or page rank field at all — only the linking domain, the target, the anchor text and the URLs. Showing DA would mean adding a second provider with its own API key, so it is not included here.

= 2.32.1 =
* Fixed: no way to tell which model wrote a suggestion. "Generate with AI" now says which model it will use, and the snippet optimiser reports the model that actually produced the variants — read back from Claude's own response, so a Settings change that silently didn't apply is now visible instead of looking identical to one that worked.
* Fixed: applying a rewritten title and meta gave almost no confirmation. The chosen option is now marked in place — "Saving…", then a green tick reading "Saved to post" in Analytics or "Added below — press Save" in the meta editors, since those genuinely do different things — and the options you didn't pick dim so the one you took is unmistakable. A save that fails now says so on the option you clicked instead of only in a browser alert.

= 2.32 =
* New (Pro): choose which Claude model the AI features use — Haiku 4.5 (default, fastest and cheapest), Sonnet 5, or Opus 5. Set it under SEOMedic → Settings, next to your API key. It applies to meta descriptions, titles, image suggestions, LSI keywords, AI summaries, deep scan and the AEO audit. The snippet optimiser added in 2.31 always uses Opus regardless, since that is the point of it.
* Fixed: every AI feature would have returned "generation failed" on any model that reasons before answering. The code read the first block of Claude's reply as the text, which is true of Haiku but not of Sonnet 5 or Opus 5 — those put their reasoning first, so the answer was read as empty with nothing in the logs to explain it. All ten call sites now find the text properly. This was a prerequisite for offering the model choice at all.
* Fixed: on a reasoning model, thinking is charged against the same token ceiling as the answer, so short generators (a meta description asks for 200 tokens) could spend the entire budget reasoning and return nothing. Those requests now get enough headroom, and reasoning is kept brief since these are short, well-specified writing tasks.
* Fixed: Haiku rejects the setting used to control reasoning depth, so it is now only sent to models that accept it — otherwise every generation on the default model would have failed outright.
* Improved: "Save and Test Key" now tests against the model you have selected. A key can be valid in general but lack access to a particular model, which the old test could not detect.

= 2.31 =
* New (Pro): a deeper snippet optimiser for pages that already rank but lose the click. Where "Generate with AI" writes one meta description for a page that has none, this writes three complete title + meta description pairs for a page whose copy is underperforming, and you pick the one to use. Runs on Claude Opus rather than the fast model — slower and more expensive per run, so it is only ever fired by an explicit click.
* New (Pro): two strategies to choose between, because they pull in opposite directions. "Optimize for SEO" protects and sharpens relevance to the query the page already ranks for; "Optimize for CTR" assumes the ranking is safe and spends the snippet competing for the click against everything else on the results page. One is marked "suggested" based on how the page's actual CTR compares with what your site typically earns at that position.
* New (Pro): where a page earns far below typical CTR for its position, the optimiser now says so instead of recommending a rewrite — at that point something on the results page is usually taking the click (an AI Overview, a featured snippet, ads) and new copy may not reach it.
* Improved: the optimiser reads Search Console itself rather than being handed a summary, so it sees every query the page ranks for with per-query position and impressions — including on the title, which previously received no search data at all.

= 2.30.2 =
* Fixed: the two date-range dropdowns on the "Ranking Over Time" card could render with the selected label clipped or squeezed, especially on the wider "12 months"/"16 months" options.
* New: "Ranking Over Time" can now drill into specific pages and queries instead of only the site-wide summary — a "Show page & query breakdown" toggle lists every page/query pair currently ranking in the top 15, ranked by impressions, with position and impressions from both selected windows side by side.
* New: that breakdown can be copied or exported as CSV — one row per page, with its top query, focus keyword, and every other query it ranks top 15 for bundled into one column. Useful for building a case study straight from the export. Required adding a focus-keyword field to the existing lightweight post lookup used elsewhere in the plugin (no new full-post fetch).

= 2.30 =
* Fixed: GSC/Bing position and keyword snapshot history was quietly capped at ~26 days instead of the ~6 months the plugin has always described. Snapshots moved to a guaranteed-daily background cron a few releases back, but the ring-buffer size was never widened to match — so a 26-slot buffer filled by daily writes gave 26 days, not 26 weeks. Widened to 90 daily entries across GSC position snapshots, GSC keyword snapshots, and Bing position snapshots. Existing installs will take a few days of the daily cron running to build back up to the fuller window; nothing to do on your end.
* New: "Biggest Movers — Top 15" card on the main dashboard (Pro), right below the top stats. Ranks pages/queries currently sitting in Google's top 15 positions by impressions — the ones realistically one push from page one — with each row's position change over up to the last 90 days. Reads from the local snapshot history, not a live API call, so it doesn't wait on "Load search data."
* New: a "6 months" option on the Search Analytics page's date-range selector, alongside 24h/7d/30d/60d/90d.
* New: "Ranking Over Time" card on the Search Analytics page (Pro). Pick two window sizes — 3/6/12/16 months — and see site-wide impressions, clicks, CTR and position side by side, both windows ending today. Since the longer window always contains the shorter one, this shows whether your recent stretch is pulling the longer-term picture up or down, not a strict before/after change (the card says so). 16 months is the ceiling because that's roughly what Google's Search Console API itself retains — an 18-month window would be asking for history Google has already discarded.

= 2.19.8 =
* New: Missing Meta Descriptions gets a "Hide pages" toggle to filter the list down to posts only. Display-only — doesn't affect the header count or CSV export.
* Improved: in Missing Meta Descriptions, the top GSC query now sits under the impressions count instead of under position — impressions and "what query is driving them" read together more naturally, and it fills space that used to sit empty.
* New: URL Export gains an Impressions (90d) column and a Focus Keyword column, shown in the results table and included in the CSV — reuses the same 90-day Search Console window already used elsewhere on the dashboard, no extra API call.
* New: URL Export can exclude posts carrying a specific tag — a dropdown populated from every tag actually in use. Requires a small backend addition: post payloads now include tag names alongside the tag IDs they already carried.
* Fixed: both Missing Meta Descriptions and URL Export now explain, inline, when Search Console columns are showing blank because search data hasn't been loaded this session yet — previously the columns just silently disappeared or showed dashes with no explanation.
* Improved: the "Load search data" bar is far more visible (bigger button, warning styling instead of a routine gray strip) and its copy now says plainly that it powers every Search Console/Bing section on the page, not just one, and that a "↻ Sync" button appears afterward for pulling fresh data anytime.
* New: a small note on the initial loading screen — if a site is struggling to load even with Low-resource mode on, that's usually the hosting, not the plugin. Links out to WordPress-optimized hosting.

= 2.19.2 =
* New: a dashboard alert now flags posts that are already earning Search Console impressions but have zero internal links pointing to them — pages nobody links to internally get crawled less often and rank worse than they could, and these ones are already proven to have demand. Click through to Internal Link Booster's Orphaned Posts tab, which was already there but easy to miss.
* New (Pro): fixing an orphaned post from that tab now connects to the same before/after ranking tracking as the editor sidebar. "Fix in editor" opens the suggested source post with the link pre-filled — target and, when a real matching phrase was found in the source's own text, the anchor too — so inserting it is one click, and that insert is automatically tracked the same way a sidebar-inserted link always has been.
* New: URL Export — search your posts and pages by title (or title + content) and export every matching URL as a CSV or copy them all to the clipboard at once. Useful for reviewing a batch with Claude or handing a URL list to an indexer or submission tool. WordPress's own admin search has no bulk-export of a result set.

= 2.19.1 =
* Fixed (Pro): inserting an internal link from the post editor sidebar left you hunting for it. The editor scrolled to the block containing the link rather than to the link itself — and on posts written in the Classic editor the entire post is one block, so it stopped somewhere in the middle and you had to search the page to find the change. It now scrolls to the new link and highlights it for a couple of seconds. Applies to both suggested links and "Link my selection".

= 2.19.0 =
* Fixed: the Volume column in Top Queries and Keyword Explorer did nothing when clicked. The heading wasn't wired up as a sort control, and the sort itself was looking for a field that search-volume data doesn't live in. Volume, Impr. share and Ad CPC all sort properly now. Keywords you haven't looked up yet stay at the bottom either way, rather than being treated as zero and pushing real results off the page.
* Fixed: sorting the Pages table by Volume could leave rows in a jumbled order once more than a couple of them had no search volume yet.
* Fixed: sorting one Top Queries table also re-sorted the other. Google and Bing now sort independently.
* Improved: the Google and Bing query tables are stacked instead of side by side, so neither runs off the edge of the screen once search-volume columns are showing. Both scroll sideways on a narrow window rather than squeezing the columns.
* Improved: the Keywords Everywhere panel in Settings now states plainly that nothing spends a credit on its own, and lists exactly which buttons do — including that the Backlinks refresh is billed separately and isn't part of the usage figure shown above it.

= 2.15.1 =
* Improved (Pro): the Ranked Queries panel in the post editor sidebar now explains itself. Every chip (position, clicks, impressions, search volume, and the "×N in post" phrase count) has a tooltip, and the "×N in post" count is now explicit that it's context, not a target — the panel already shows real cases of pages ranking well for a phrase that appears zero times in the post.

= 2.15.0 =
* Fixed: a 301 redirect set in Content Pruning stopped working the moment you removed the page — which is exactly when it was needed. Redirects only applied while the page was still published, so trashing, deleting or unpublishing it left the old address returning "not found" instead of passing visitors and Google to the replacement. Redirects are now stored independently of the page and keep working after it is gone, whichever order you do things in. Redirects you set before this update are repaired automatically.
* Improved: you no longer need to know the destination address to set a redirect or a canonical. Start typing and search your own posts and pages by name, then pick one — including a one-click Homepage option for pages with no good match. Pasting a full address still works for sending visitors elsewhere. The same picker is now used for the trashed-post prompt and the Broken URL Monitor.
* Improved: the Content Pruning explainer now spells out the order to follow — set the redirect first, then remove the page.

= 2.14.1 =
* Fixed: a "Pro adds a Claude semantic deep-scan…" upgrade hint could appear near Find & Replace even on fully licensed Pro installs. The check compared a value WordPress always passes to the browser as text ("1"/"") against the exact boolean `true`, so it could never match. Also fixed the same mistake in the Claude API key check, which could make configured installs show "Configure your API key" until you switched browser tabs and back.

= 2.14.0 =
* Improved: the statistics at the top of the dashboard (total links, external, internal, broken, redirects, nofollow) are now counted on the server and cached, instead of being worked out in your browser from the full content of every post. They appear from a single small request rather than waiting for the whole site's content to download, and your browser no longer parses every post just to count links. The figures refresh automatically whenever you edit content or rescan.


= 2.13.19 =
* Fixed: the dashboard now loads as a single file instead of thirty separate ones. Splitting it up saved a little download size but was the source of every "section failed to load" problem — files cached from an old version, thirty simultaneous requests competing with the dashboard's own data, and failures the browser refuses to retry. One file cannot hit any of those. Slightly larger first download, then cached until the next update.

= 2.13.18 =
* Fixed: the real cause of sections failing to load after an update. Section script filenames did not change between releases, but their contents did, so browsers kept serving a cached copy from the previous version alongside the freshly updated main script — a mismatch that made those sections fail permanently until the cache was cleared. Section filenames now include the plugin version, so every release gets fresh URLs and a stale copy can never be used.
* Improved: when a section does fail this way, the message now explains it is a cached-file problem and offers to reload, rather than describing it as a firewall issue.

= 2.13.17 =
* Fixed: section scripts no longer download while the dashboard is still loading its data. Every section requested its script the moment the dashboard appeared, competing with the data requests for the browser's limited connections — on busy servers a few sections consistently lost that race and failed to load. They now wait until the data is in.
* Fixed: when a section's script does fail, the recovery button now reloads the page instead of retrying in place. A failed script cannot be re-fetched by the browser without a fresh page load, so the old "Try again" could never succeed.
* Improved: diagnostics now record the actual error for a failed section script, not just that it failed.

= 2.13.16 =
* Fixed: the dashboard could reload itself in a loop, dropping back to the loading screen after it had already appeared. A 2.13.14 change removed the one-time retry marker from the URL but the loop guard was still reading it from there, so every failure looked like the first one and the reload cap never applied.
* Fixed: the dashboard no longer reloads at all once it is on screen. A section whose script fails now shows an error in just that section, instead of discarding a load that can take half a minute — and because reloading cancels in-flight requests, one failure was cascading into several.
* Improved: section scripts are retried for longer (5 attempts over ~30 seconds, up from 3 over ~1 second). On busy servers these requests queue behind the dashboard's data requests, and the old budget gave up long before their turn came.

= 2.13.15 =
* Improved: the dashboard makes far fewer requests to your server on load. Four separate requests are now one, and Search Console / Bing data is loaded by an explicit "Load search data" button instead of automatically. Every request has to start WordPress from scratch, which on shared or busy hosting is the slowest part of opening the dashboard — this removes most of them.
* Improved: Search Console position and keyword history now records once a day in the background, so trends and Biggest Movers keep building whether or not you open the dashboard. Previously history only accumulated on days you loaded the dashboard.
* Improved: AI-referrer counting no longer writes to the database on every visit to your site. Counts are batched (using your object cache when available) or sampled, which removes a database write from every front-end page view.

= 2.13.14 =
* Fixed: the one-time asset-retry flag was never removed from the dashboard URL, so it stayed in the address bar and appeared in server logs long after the issue it recovered from was resolved — and it left the recovery unable to run again. It is now cleared as soon as the dashboard loads successfully.

= 2.13.13 =
* Improved: the diagnostics log now resets automatically when the plugin updates, and stamps every line with the version that produced it. Previously the log carried over across updates, so after installing a fix it still displayed the old version's errors as though they were current.

= 2.13.12 =
* Fixed: dashboard section chunks (Sitemap Audit, Schema Audit, Broken URL Monitor, and others) could fail to load, sometimes leaving the dashboard stuck reloading. The build was generating chunk URLs relative to the site root instead of the plugin's own folder, so on some hosts/install paths the browser requested them from the wrong location entirely. Chunk URLs are now resolved relative to the plugin's own script, regardless of install path.

= 2.13.2 =
* Improved: Low-resource mode is now much more effective on limited or shared hosting. In addition to loading in smaller batches, it caches the post list — on the server (per page, cleared automatically whenever you edit or delete a post) and in your browser for the session — so reloading the dashboard no longer re-runs the heavy full scan against your server. A rescan or refresh still pulls fresh data. Normal mode is unchanged: nothing is cached unless low-resource mode is on.

= 2.13.1 =
* Fixed: opening the "Just Started Ranking" or "Keyword Explorer" report could crash the dashboard with a "removeChild" error once the data loaded. The two new sections updated their results on a different rendering priority than the loading indicator, which let React commit the two changes out of order. They now update together, matching every other section.

= 2.13.0 =
* New: Keyword Explorer — a sortable, filterable table of every search query your site appears for, right inside WordPress. Sort by impressions, clicks, CTR or position; filter by minimum impressions or position band (1–3, 4–10, 11–20, 21+); and type to filter by keyword. No need to switch over to Search Console.
* Each row shows the page that ranks best for the query, and flags when more than one of your pages competes for it (a keyword-cannibalization hint) — something Search Console makes you switch tabs to see.
* Bing Webmaster Tools impressions and position are shown as bonus columns when Bing is connected, matched by query text.
* Note: Google withholds queries from too few unique users, so the totals read slightly lower than your true traffic and won't exactly match Search Console.

= 2.12.2 =
* Fixed: long search queries in the dashboard widget's Top Queries lists ran past the edge of the widget and over the next dashboard column instead of being shortened.
* Improved: near-identical long-tail queries are now grouped into a single row with a "+N similar" note and their impressions combined. Bing in particular reports families of machine-generated queries that differ only in their last few words, and five variants of one phrase were filling the whole list. Short queries are never grouped, so genuinely different terms stay separate.

= 2.12.1 =
* Fixed: pages could appear twice in the dashboard widget's Top Pages table — once with Google numbers and once with Bing — because the two integrations were normalising URLs differently after the 2.11.3 change. They now share one implementation.
* Fixed: the Top Pages table could run past the edge of the dashboard widget when both Google and Bing were connected. It now scrolls within its own box.
* New: "Just Started Ranking" now also appears on your WordPress dashboard widget, showing the three most recent pages with their top query. Press Sync now to populate it.
* Improved: if the plugin hits an unexpected error, the report now names the component involved, which makes it far easier to diagnose.

= 2.12.0 =
* New: "Just Started Ranking" — a dashboard section showing pages that got their first-ever Google impressions in the last 7, 14, or 28 days, along with the queries they're appearing for. Publishing has a long feedback loop; this is the moment a new post stops being invisible in search.
* A page only appears here if it had no impressions at all in the 90 days before the window, so it's genuinely new visibility rather than a page that comes and goes.
* Expand any row to see the search terms it surfaced for, with impressions, clicks, and average position for each.

= 2.11.6 =
* Fixed: Find & Replace still could not find URLs containing "&". The 2.11.4 fix only handled one of the ways WordPress encodes an ampersand — content is commonly stored using the numeric form (&#038;) rather than &amp;, which the search still missed. All forms — literal, &amp;, &#038;, &#38; and hexadecimal — now match, in both post content and widgets, whichever form you paste into the search box.

= 2.11.5 =
* Fixed: the same "&" search problem in Find & Replace also affected widgets, which are searched by the server rather than the browser. Widget search and replace now match encoded and decoded forms of a term, and widget match previews show the correct surrounding text for matches that live inside markup.
* Improved: searching for a term with a space now also matches text where WordPress inserted a non-breaking space.
* Improved: "N of your published pages got zero impressions" on the WordPress dashboard is now a link. It opens the Pages Google Isn't Showing section directly, so you can decide what to redirect, expand, or prune.

= 2.11.4 =
* Fixed: searching Find & Replace for a URL containing "&" (such as an affiliate link with ?ref=...&utm_source=...) found nothing, even when the preview clearly showed that URL. WordPress stores "&" as "&amp;" in post content while the preview displays it decoded, so the text you copied out of the results could never match. Searching now finds both forms, whichever you paste.
* Fixed: the same mismatch affected quotes and angle brackets in search terms.
* Improved: replacements now keep the encoding your content already uses, so replacing a link doesn't quietly rewrite "&amp;" as a bare "&" elsewhere in the tag.

= 2.11.3 =
* Fixed: pages with accented or non-Latin characters in their URL never matched their Search Console data, so they showed no search performance at all and could appear in "Pages Google Isn't Showing" despite getting traffic.
* Fixed: impressions arriving on tagged URLs (?utm_source=..., ?fbclid=... and similar) were counted as a separate page rather than credited to the real one. They are now merged into the page's totals, with position and CTR averaged by impressions so a handful of tagged visits can't skew a page's average.
* Improved: the dashboard loading screen now says it will open automatically when data has finished loading.

= 2.11.2 =
* New: "Pages Google Isn't Showing" — a dashboard section listing your published pages that Google barely surfaced in the last 90 days, split into pages that got zero impressions and pages sitting under a threshold you choose. Noindexed and redirected pages are excluded, since their absence is intended.
* New: the Google Coverage line on your WordPress dashboard now also shows how many of your published pages got zero impressions — the actionable half of the count.
* Improved: Biggest Movers on the dashboard widget now stacks gaining and slipping full width instead of side by side, so query text is readable rather than cut off.

= 2.11.1 =
* Fixed: the top Google query shown for each page was picked arbitrarily rather than by impressions, so pages often showed a minor query instead of the one actually driving views.
* Fixed: automated rank-tracker and scraper queries (long strings full of "-site:" exclusions) are no longer treated as real search terms. They no longer appear as a page's top query, feed into focus keyword suggestions, or influence generated meta descriptions.
* Improved: query data is no longer limited to terms ranking in the top 20. Pages that rank below position 20 for their main term now show it, giving a fuller picture of what each page is actually found for.
* Improved: the Sync button now refreshes query data too. Previously it only refreshed page-level performance, leaving query data to update on its own six-hour cycle.
* Improved: removed a duplicate Search Console API request — the plugin was fetching the same query dataset twice on every refresh.

= 2.11.0 =
* New: the Mixed Content audit now scans your whole site, not just post content. It loads your rendered home page plus a sample post and page — catching insecure URLs in your theme header and footer, nav menus, widget areas, and other plugins' output — and reads your widget and menu settings directly.
* New: findings are split by severity. Sub-resources loaded over http:// (images, scripts, stylesheets, iframes) are flagged as blocking, because those are what actually break the padlock; plain http:// links are listed separately as lower priority.
* Improved: widget and menu findings link straight to the screen where you can fix them. Theme-sourced URLs are clearly marked as coming from your theme or another plugin.
* Improved: the SEOMedic dashboard widget now appears at the top of your WordPress dashboard by default. If you've rearranged your dashboard, your own order is respected.

= 2.10.0 =
* New: a "Search Analytics" widget on your WordPress dashboard home. See your search performance without leaving wp-admin.
* New (free): the dashboard widget shows a clearly-labelled sample preview of the Pro search-analytics charts — metric tiles, a clicks trend chart, and a Biggest Movers list — so you can see what upgrading unlocks.
* New (Pro): the widget adds a Biggest Movers section — the queries gaining and slipping the most in Google, each with an inline position sparkline, drawn from your existing snapshot history (no extra API calls).

= 2.9.0 =
* New (Pro): Refresh Impact tracking — after updating a post, click "✓ Done" in Refresh Opportunities to snapshot its last 28 days of Search Console data. SEOMedic then compares clicks/day, average position, and CTR before vs after the refresh, so you can see whether the update actually moved the needle.
* New (Pro): a "Recently refreshed — impact" panel at the top of Refresh Opportunities lists every tracked post with its before/after readout; the SEO Performance trend expander shows the same line for tracked posts.

= 2.8.21 =
* Fixed (Pro): the Gutenberg sidebar showed "No GSC queries yet for this URL" on www-hosted sites even when the post had Search Console data — the permalink was never matched against the normalised GSC URLs.
* Improved (Pro): LSI keywords missing from the post body now show as red pills (was grey) so gaps stand out at a glance.
* Improved (Pro): the "Load search volumes" buttons now note in a tooltip that each lookup spends Keywords Everywhere credits.

= 2.8.20 =
* Fixed: "Plugin could not be activated because it triggered a fatal error" when activating a new version while an older copy (or the free version) was still active. All functions in the main plugin file are now redeclaration-safe, so two copies can coexist during an upgrade and the free → Pro auto-deactivation hand-off works as intended.

= 2.8.19 =
* Smoother free → Pro upgrade: activating SEOMedic Pro now automatically deactivates the free version, so you're never left with both plugins active and duplicate menus.

= 2.8.18 =
* New (Pro): sortable "GSC clicks" column on the Posts and Pages list screens — see 90-day Google Search clicks per post at a glance and sort by it. Populated automatically whenever Search Console data syncs.
* Improved (Pro): the Gutenberg sidebar's Ranked Queries list now shows a clicks chip next to impressions for each query.

= 2.8.17 =
* Added a one-time, dismissible review request in wp-admin, shown two weeks after install. "Maybe later" snoozes it for 90 days; "No, thanks" hides it permanently. No tracking, no external calls — just a link to the WordPress.org review form.

= 2.8.14 =
* Fixed (Pro): "Analyze top 20" for AI Search Readiness could stop early with "Could not parse Claude response" — the AI response token limit was too low and truncated longer results. Raised the limit, and the bulk run now skips any post that fails instead of aborting the whole batch.

= 2.8.13 =
* Added an inline notice above the dashboard stat cards letting users know that on larger sites the counts can take a couple of minutes to populate, and each section scans on first expand — so a slower load isn't mistaken for a problem.

= 2.8.12 =
* Fixed: rescanning a single section no longer throws you back to the full-screen loading screen (and no longer jumps the page to the top). The full loader now appears only on the first load; rescans show an inline spinner and keep the dashboard in place.
* Added a "Couldn't load your content" retry screen with the Low-resource mode toggle, shown if the initial scan fails on a resource-limited host — so you can enable gentler loading and try again without getting stuck.

= 2.8.11 =
* Fixed a loading flicker where the dashboard briefly showed empty (0) stat cards before data finished loading. The staged loading screen now stays up — showing live progress and the Google Search Console / Bing steps — until your content has actually loaded.
* Clearer loading messaging that the first load can take a minute or more depending on how many posts and pages your site has.

= 2.8.10 =
* New "Low-resource mode" toggle on the load screen for shared/limited hosting — loads posts in smaller batches with a pause between them, so the scan is gentler on servers with CPU/memory/entry-process limits. Off by default; remembered per browser.

= 2.8.9 =
* Load on demand: the dashboard no longer auto-scans on every page load. It opens instantly with a "Load my content" button, and the heavy scan runs only when you click it (remembered for the session). Big improvement on large sites and resource-limited hosts.

= 2.8.8 =
* Resilience: if a dashboard module fails to load (e.g. a stale cached asset behind a CDN right after an update), the page now auto-reloads once to pull fresh files instead of showing an error.

= 2.8.7 =
* Performance: the dashboard now loads posts in pages instead of all at once. Large sites (hundreds of posts) no longer hit a server memory error (HTTP 507) on load, and the loading screen shows live progress (e.g. "150 / 504").

= 2.8.6 =
* Added a clearly-marked, staged loading screen so large sites don't look stuck while data loads.
* Hardened uninstall.php against direct file access.

= 2.8.5 =
* Fixed: Claude-powered features (AI Search Readiness, meta generation) could show "Configure Anthropic API key" on first load even when a key was saved — the key status now resolves immediately on page load instead of waiting for a window-focus event.
* Refreshed plugin screenshots and screenshot captions.

= 2.8.4 =
* Hardened the React dashboard against browser auto-translate extensions that could cause a "removeChild" crash on the main page.
* Compatibility and packaging polish.

= 2.4.7 =
* WP.org compliance pass: removed all Pro-only Claude/Anthropic handler code from the free distribution (moved to the separate SEOMedic Pro add-on plugin). The free zip no longer contains any calls to third-party AI APIs.
* Included the React UI source files (`admin/src/`) and build configuration in the zip so the compiled `admin/build/index.js` is reproducible from human-readable source.
* Tightened REST API permission checks: `GET/POST /seomedic/v1/settings` now requires `manage_options`; `POST /seomedic/v1/posts/{id}/dismiss` and `/undismiss` now verify `edit_post` on the specific target post.

= 2.4.0 =
* Plugin split for WordPress.org compliance: AI generation, Google Search Console, Bing Webmaster Tools, and Keywords Everywhere features moved to a separate **SEOMedic Pro** add-on plugin.
* Unlocked: Content Pruning redirect saving and Image File Rename are now available to all users (no Pro requirement).
* Tightened REST API permission checks on `/trashed-pending/{id}/dismiss` and `/check-link`.
* Renamed plugin text domain to `seomedic-seo-link-auditor` (matches plugin slug).
* Cleaned readme — removed entries for services this plugin no longer contacts.

= 2.3.x =
* Dashboard refactor — bulk meta operations, scroll-to-top, sitemap audit upgrades, plugin checker fixes.
* Gutenberg post-insights sidebar (moved to Pro in 2.4.0).
* Various fixes for keyword cannibalisation detection, readme stable-tag sync, and WP.org plugin-checker warnings.

= 1.0.x =
* Initial public releases — see git history for full per-version notes.

