WOWStudio Accessibility Remediation

Changelog

Every release, newest first. Written for the person who has to decide whether to update, not for a version-bump bot.

Current: v1.0.0Install free

  1. v0.24.0

    A command line, with bulk in it.

    Added

    • wp wsak scan [<post-id>...] [--all] [--post-type=] — checks pages and prints a table, or JSON, or CSV.
    • wp wsak issues [--post=] [--severity=] [--status=] — the findings from each page’s most recent check.
    • wp wsak checks — all forty checks, with severity, success criterion and which pass runs them.
    • wp wsak fixes [<id>] [--on|--off] — list the site-wide fixes, or switch one.

    Notes

    • Bulk is not held back from the command line. The reason to have a CLI at all is continuous integration and staging audits, and a command that can only do one page at a time is useless for both — somebody reaching for WP-CLI is going to put it in a pipeline, and a tool that cannot go in a pipeline is a demonstration rather than a tool. A test asserts --all stays.
    • Runs synchronously rather than through Action Scheduler. The queue exists so a browser request does not time out; a terminal has no such problem, and a command that returned immediately and told you to wait for a background job would be worse at the one thing it is for.
    • Every scan row states its coverage — “full page” or “content only” — because a content-only scan covers less, and a score that does not say so invites being compared against one that means something different.
    • An empty result says that automated checks cover part of WCAG rather than reporting a pass. The terminal gets the same honesty as the interface.
    • stubs/wp-cli.php exists for static analysis only. WP-CLI is the environment the plugin may run inside, never a dependency, and the stub is excluded from the shipped build.
  2. v0.25.0

    Reading level and the plain-language summary. Forty-one checks.

    Added

    • reading-level-high, estimating Flesch–Kincaid grade level and reporting when a page reads above lower secondary school.
    • A plain-language summary field in the editor’s Accessibility sidebar, stored as registered post meta so it appears in the REST API and travels with an export. Optionally rendered above the content; off by default.

    What this measures, and what it does not

    The rule reports as needs review and could not honestly be anything else. Flesch–Kincaid counts words per sentence and syllables per word and understands nothing: it can be improved by chopping one clear sentence into three fragments, which makes the writing worse. It has no idea who the audience is — a page for cardiologists is allowed to read like one. So the finding says what was measured and what it might mean, never that the page fails.

    Three refusals, because a number in a box gets believed:

    • Under a hundred words, it says nothing. A formula fed two sentences reports a grade as confidently as it reports one for an essay.
    • Not in English, it says nothing. Both the syllable heuristic and the coefficients are English. Run over German it still returns a number, which is the danger: a number that means nothing looks exactly like one that does.
    • Over sixty words a sentence, it says nothing. See below.

    Fixed before it shipped

    The rule reported this plugin’s own accessibility statement at grade 23. The prose was not the problem: text was arriving with sentence punctuation stripped, because the extraction took a whole element’s text and ran headings, navigation and list items together into one unpunctuated four-hundred-word run. Flesch–Kincaid divides by whatever it is given and cannot notice.

    Two changes came out of it. The rule now reads paragraphs only — a readability formula is for prose, and averaging in menu labels measures the theme rather than the writing — and terminates each one before appending the next. And the formula refuses outright above sixty words per sentence, because no natural English writing averages that and the input is punctuation-free rather than difficult. The statement now measures grade 9.8.

    On our own statement

    9.8 is above the 3.1.5 threshold of 9. DogfoodTest now excludes AAA criteria from its pass/fail — the dogfooding rule is WCAG 2.2 AA, per CLAUDE.md rule 6 — but the exclusion is a named constant rather than a silent filter, and a separate test asserts the statement stays under grade 12 so that an edit pushing it towards fifteen is noticed. An accessibility statement only a lawyer can read is a poor advertisement, and it is worth simplifying.

  3. v0.26.0

    The last two extension seams, before the screens around them grow.

    Added

    • wsak_report_data and wsak_report_formats on the whole-site report. The first lets an exporter take exactly what is on screen rather than reassembling it from the tables and drifting out of step with what somebody is looking at. The second is how an export control appears at all — the free plugin registers none, so it shows no button rather than a locked one.

    • wsak_can_dismiss, so restrictions on who may set a finding aside can be added without IssueReview knowing what they are.

      It narrows and cannot widen. The capability check runs first and is combined with &&, so a filter returning true still cannot hand somebody the right to make decisions about content they may not edit. That check is WordPress’s, and passing responsibility for it to a third party would make it possible for another plugin to open a door this one is answerable for.

    All four seams now exist and ExtensionSeamsTest guards them: a renamed filter is a silent break for every add-on at once, with no error anywhere.

  4. v0.27.0

    Page-builder content, and a statement anybody can read.

    Fixed

    • Elementor pages scanned against a search-engine stub. Caught on a real Elementor page after the first fix looked like it worked. Elementor writes a stripped text copy of the page into post_content — on that page, 3,808 bytes of bare headings against 69,503 bytes of actual page. The first fix only asked the builder when post_content rendered to nothing, so it never fired, and the scan read the stub and reported a confident hundred out of a hundred.

      A false clean bill of health is the worst thing this plugin can produce. Everything else it does is hedged precisely so nobody reads more into a result than it can carry, and a confident 100 on a page nobody has really looked at undoes all of it. The builder is now asked first: where one owns the page, its output is the page, and post_content is a search engine’s copy rather than a second opinion.

    • Elementor pages scanned as though they were empty. Found by building a real Elementor page and looking, not by reading documentation. Elementor keeps nothing in post_content — a JSON tree in postmeta, rendered through hooks that only fire inside the loop on a real request — so applying the_content to the empty string it leaves behind returned the empty string. On any site where loopback is blocked, every Elementor page reached the parser with zero bytes. The rendered page was eighty kilobytes.

      The scan failed safe rather than reporting a false clean bill of health, which is the one thing that saves this from being much worse. But it failed with “the page could not be parsed as HTML”, which is true in a narrow sense and useless: it named the symptom and neither of the two things the reader could act on.

      The content fallback now asks the builder when post_content renders to nothing. Elementor is handled directly, guarded on every hop — a class that may not exist, a property that may not be set, a method that may be renamed — because reaching into another plugin’s internals unguarded is a fatal error on somebody’s dashboard. Everything else goes through wsak_builder_content, which is the extension point rather than a list of builders this file has to keep up with.

      Divi and WP Bakery need none of this: both keep shortcodes in post_content, so the_content renders them correctly and always did.

    • The “nothing to scan” error names both possible causes and asserts neither. From the server, a genuinely empty page and one rendered by something unreachable look identical, and claiming the second would be inventing a diagnosis.

    Changed

    • The accessibility statement is written more plainly. Four long sentences became shorter ones; the guarded disclosure phrases are untouched. It now passes reading-level-high, so DogfoodTest has dropped the AAA exemption it briefly carried and is unconditional again — shortening four sentences was cheaper than the exemption, and a statement only a lawyer can read is a poor advertisement for a plugin about accessibility.
    • That test now asserts through the rule rather than measuring separately, so what it checks is exactly the number a user would be shown.
  5. v0.28.0

    The first-run cliff, and a door to the coverage panel.

    Added

    • A “what to do next” panel on the overview, computed from the site rather than written in advance. Nothing scanned yet, so check a page. Everything scanned but fourteen fixes switched off, so look at those. Fixes on but a hundred images undescribed, so open the Images screen. Nothing outstanding, so say nothing.

      Three rules keep it from becoming the thing everybody hates. One step, never a checklist — a list with ticks turns a tool into homework and its unfinished items accuse somebody for months. It goes quiet: with nothing worth suggesting it renders nothing, because a panel that always has something to say is furniture within a week and then ignored on the day it matters. And it never invents urgency — each step says what is available, not what is wrong. The plugin already reports what is wrong; it does not also need to nag about its own features.

      Silence has its own tests, and it is silence rather than congratulation: a clean automated scan means the checks passed, which is a much smaller statement than the site being usable, and the empty dashboard is the last place that should blur the two.

    • A door to the coverage panel, from the overview and from the foot of the findings list. It documented all forty-one checks and rendered only after a single-page scan, which meant nobody found it. It is a view rather than a new tab: reference material people want twice — once starting out and once when a finding surprises them — and a permanent tab for that is a tab everybody scrolls past. The door at the foot of the findings is deliberate; somebody who has just read a list of problems is the person most likely to want to know what was not looked for.

    Notes

    • NextStep takes two callables rather than the objects behind them, and calls them only when the answer is needed. Counting undescribed images is a database query, and on a site whose first suggestion is “check a page” nobody should pay for it on the most-loaded screen in the plugin. It also means a test can say “pretend six fixes are off” without SiteFixManager or MediaIndex giving up being final — what this class depends on is two integers, and the signature now says so.
    • wsak_next_step filters the suggestion, and returning null shows nothing.
  6. v0.29.0

    Findings that keep their identity, and figures you can open.

    Removed

    • Every trace of the licensing SDK, including its name. The vendor is no longer mentioned anywhere in the repository — not in the changelog, the security review, the spec, or the build tooling. PROMPT.md, a kickoff prompt for a two-tier product that was never built, is deleted outright.

      This is a rewrite of the record rather than an addition to it, done knowingly: the plugin has no users, the versions those entries describe were never installed by anybody, and carrying a payments vendor’s name through the history of a plugin that takes no payments serves nobody. Where an entry documented a real change it still does, worded without the vendor.

    • Dead exclusion entries in phpcs.xml.dist, eslint.config.cjs and bin/check-claims.php that named a directory which no longer existed, a PHPStan comment describing a scan path it did not describe, a CI comment claiming an exclusion that had already been dropped, and the two SPEC.md sections specifying how to wire and gate it.

      pro-seed-0.15.1 still holds the code itself.

    Added

    • The same markup, decided once. A theme prints its social icons into every footer, so the same fault arrives once per page and asks to be judged once per page. After the fortieth identical decision people stop reading findings and start clearing them, which is the point at which the list has taught somebody to ignore it — and the finding that mattered is the one they clear on autopilot afterwards.

      A rule-scoped list can now be grouped by the markup behind it, with one decision covering every instance. On the development site nine “clickable target is too small” findings collapse to two groups; seventeen skipped headings stay seventeen, because they are genuinely seventeen different problems.

      This is more careful than the per-page dismissal rather than less, because it retires findings on pages nobody has opened. The reason is required with no path around it, the pages it reaches are listed by name before it is taken, it needs the right to edit other people’s content, a page that made its own call about the finding keeps it, and it can be withdrawn in one step from the decision log. A judgement that cannot be undone is not one worth offering.

    • Every figure on the report is now a door. “What comes up most” and “Pages with the most to do” were counts and nothing else: the report said forty-five pieces of text had too little contrast and offered no way to see one of them. Both lists now open onto the findings behind them.

      The check’s own explanation leads the filtered list — what the fault is, who it shuts out, and what to do about it, stated once at the top rather than repeated against all forty-five rows. When a list is already one check, the rows drop everything the brief has just said and keep only what differs: which markup this one is about, and where.

      All of it is free. Being told a barrier exists and not being shown where it is helps nobody, least of all the person who meets the barrier.

    • GET /wsak/v1/issues, filterable by check, page, severity, detection and status, returning the findings, the total, and the rule’s own description. GET /wsak/v1/issues/grouped returns the same findings collapsed to distinct markup, and wsak_can_dismiss_site_wide narrows who may decide that widely.

    Fixed

    • The score no longer contradicts the list underneath it. The page checks run in the browser after the server pass has finished, and the endpoint that stored their findings returned the findings without the recomputed score or counts. So a page whose content-only scan found nothing showed “100 / 100 — 0 issues detected automatically, 0 items still need a person to check” directly above a list of contrast failures. Both halves were honestly computed and each was about a different pass, which is not a distinction anybody should have to infer from a screen disagreeing with itself.

    • Style fixes are now weighted to win, instead of being written and losing. A proposed rule used the element’s tag and one class — span.first-title — which is one class and one element of specificity. On a page built with Essential Blocks the heading was already styled by .eb-advance-heading-wrapper.eb-advance-heading-yj6uz .eb-ah-title .first-title: four classes. Our rule was written, valid, matching, and outranked, so the page did not change.

      The proposal now reads what already styles the element, and repeats the anchoring class until it outranks it. Repeating the class rather than reaching for an ancestor chain is deliberate: it cannot change which elements are matched, and a chain of builder-generated wrappers breaks on the next save.

      Where the incumbent rule is !important, no stylesheet rule can win, and the proposal says so before it is applied rather than after.

    • Everything WordPress.org asks for. The plugin passes Plugin Check with nothing reported in shipped code, and the directory assets exist: an icon at 128 and 256, a banner at 772x250 and 1544x500, and seven screenshots of the real screens.

      Both generators are checked in rather than the output alone. The icon and banner are drawn from the palette in style.scss and the exact zigzag the admin header draws, so they cannot drift from the interface they advertise; the screenshots are taken from a running site in a fixed order that matches the readme’s captions, because WordPress.org numbers those files and reads the captions positionally.

      They live in .wordpress-org/ and .distignore keeps them out of the zip — WordPress.org reads them from the SVN assets/ directory beside the plugin folder, and shipping a megabyte of banner art to every install would be a megabyte for nothing.

      Three code changes came out of the check itself. The setup’s query argument is unslashed and sanitised before it is read. The overview’s grouped counts are four whole queries rather than one with a column name interpolated into it — the allowlist guarding that was correct and the comment explaining it was true, but both needed reading, and now there is no assembled column name to verify. And the queries built from fixed placeholder fragments say so to Plugin Check’s own sniff as well as to PHPCS; that checker cannot see through any variable, so restructuring to satisfy it produced worse code and the existing explanations were extended to cover it instead.

    • The Accessibility column said “Not checked” on every row, including pages with nineteen completed scans behind them. Reported from a Pages screen where nothing had a score.

      The column fetches every scan the screen needs in one query, primed from the_posts — once, on the assumption that the first query to fire it is the list table’s. On a block theme it is not. The Pages screen runs a wp_global_styles query first, so the single lookup the class allowed itself was spent on one post nobody was going to render, came back empty, and empty was stored. The guard that stopped it running twice then read that as “already primed” and turned the real query away. Every row after it printed “Not checked”.

      Two changes. Only posts of the types this plugin scans are looked up, so a query for something that is not a page cannot consume the lookup. And what is remembered is which ids have been asked about, rather than whether anything was asked at all — “we asked and there was nothing” and “we never asked” were the same state, which is what made an empty result indistinguishable from an unprimed one. A row the batch never saw is now looked up on its own rather than reported as unchecked, because “Not checked” has to be a fact about the page rather than about our own bookkeeping.

      Still one query for the whole screen in the normal case.

    • A false positive can be put back. The control existed and was in the one place it was no use: the card you dismiss offers “not a false positive after all” straight away, but that card is on the findings list, and the finding leaves that list the moment the decision is taken. Close the screen or scan the page again and the only place it still appeared was the False positives log, which had no way to undo anything. So the way back was available for about as long as it took to change your mind immediately, and not afterwards.

      Every row in that log now offers it, and each row says whether this account may take it — reopening asks for the fix capability and then for the right to edit the page the finding is on, which is not the same answer for every row, and one flag for the whole screen would have put a control on rows where it would fail.

      It withdraws the stored decision rather than flipping a row, so it holds across future scans. A reopen that only changed the row would have lasted until the next scan and then quietly put the finding back in the log.

      Withdrawing one now also says so out loud. The row leaves the screen when it succeeds, which is the point and also meant there was nothing left to report the outcome to anybody not watching the list get shorter — true of the site-wide withdrawals too, and fixed for both.

    • Scanning covers posts and pages, and nothing else. It used to walk every public post type, which brought in things that are not pages at all: a page builder’s template library registers itself public, so Elementor’s appeared in the content picker as “My Templates”, beside Posts and Pages, as though it were somewhere a visitor could go.

      A template and a pattern have no URL. There is nothing to render in the browser pass, and roughly half the checks only mean anything about a whole document — a heading-order fault reported against a fragment that appears inside twenty pages says nothing about any of them. A pattern is worse: the same pattern is inserted into content that gets scanned properly, so its faults are already found where they actually occur, attached to a page somebody can open. Scanning the source as well doubles every finding. And nothing on a custom post type says whether it holds a document or a box of settings, so the safe default is the two types WordPress guarantees are pages.

      One list, in src/Support/ScannableTypes.php, read by every gate: the two pickers, both scan routes, the admin column, the theme profile, the coverage figure and the WP-CLI command. A test names each of them, because the failure is silent in both directions. The routes enforce it rather than the interface — post ids arrive in a request body, not from the picker that offered them — and wp wsak scan refuses an unsupported --post-type outright rather than filtering it out, since an unknown post type matches nothing and a typo would otherwise report “nothing to check” on a site full of content.

      This narrows what the plugin claims to cover; it gates nothing. A site that knows a custom type is a real page says so with wsak_post_types, in one line, and every screen and route follows.

      The site-wide coverage figure changes as a result: the denominator now counts only content a scan can reach, so “8 of 40 scanned” no longer measures the site against a target it cannot meet.

    • A setup, on the first run. Activating the plugin now opens a three-step screen instead of dropping somebody on an empty report. Nothing here scans on its own, so a fresh install’s honest state is “no data yet” — and a dashboard of zeroes says “broken” far more loudly than it says “waiting”.

      The first step is what the plugin is and, more usefully, what it will not claim on anybody’s behalf: nothing leaves the site, nothing is added to the front of it, and it will not tell you the law is settled. The second and third are the site-wide fixes and the first scan, and both render the real screen rather than a summary of it — a wizard that paraphrases the settings it is setting is two copies of the same text that will disagree within a release, and on the fixes screen the text being paraphrased away would be the caveat under each switch, which is exactly what somebody needs in front of them at the moment they flip it.

      Every step is optional and nothing is switched on for anybody. Leaving halfway is a supported outcome rather than an abandoned funnel.

      It is a view on the dashboard page rather than a page of its own, which is WordPress’s decision rather than a preference: a page with no menu entry has to be registered and then removed from the menu, and user_can_access_admin_page() resolves a page’s parent by walking the menu it was just taken out of — so it looks the page up under an empty parent, misses it, and refuses access. The page exists and nobody may open it.

    • Layout faults introduced by going full width, found by sweeping every screen rather than by looking at them. The theme findings had lost their padding entirely and sat against their own border: they render the same card as the findings list, and when that card gave its padding to the accordion inside it, the one without an accordion had none left. Only the collapsible kind gives it up now.

      Four blocks of prose had no line-length cap and had been fine only because the plugin used to be 1240px wide: the help text under every WordPress control, at nearly two hundred characters a line; the sentence qualifying the score; the note under the trend; and the accessibility statement preview, which is the document somebody is about to publish and should be previewed the way it will be read.

      And the preview frame was two pixels wider than the pane holding it — an iframe does not inherit the admin’s border-box.

    • The dashboard cards had a box and no padding, so every label in them sat hard against an edge. Flattening those cards zeroed their padding, border, radius and background so the charts inside would carry the colour on their own. The shadow did not go with them, and a shadow on its own is still a container — it draws a hairline box on all four sides. They are panels again, matching every other panel in the plugin.

      A second sweep found it. The first one checked each element against its parent’s box and each block of prose against its line length, which is why it missed this: nothing here overflowed anything, and the container drawing the box had no border and no background for the audit to notice. The check now asks whether an element draws an enclosing box at all — border, background or shadow — and then whether anything inside it reaches the edge.

    • A paragraph of English was being typeset as a code snippet. Where a theme finding had no general correction to hand over, the panel fell back to the rule’s description and set it in the dark monospaced block kept for markup, under a line telling the reader to send the change below to their developer. It was not a change: it was the explanation from further up the same card, reworded. On a real finding it came out as one unwrapped line two thousand pixels wide. The text is worth showing and now reads as the prose it is, and the sentence above it no longer points at a snippet that is not there.

    • Chart labels wrapped instead of truncated. The names of the checks are sentences, and an ellipsis landed mid-sentence on most of them — “Clickable target is smaller t…” does not say smaller than what, which is the whole content of the row. The bars already carried a full summary for a screen reader, so it was only the sighted reader who was not getting the name.

    • The report is figures on a page rather than figures in boxes. Four bordered cards holding one number each spent most of their pixels drawing containers, and the containers said nothing the whitespace was not already saying. The charts lost their borders too; their headings are small-caps labels above what they name.

      The score’s qualifier moved from under the number to beside it — they are one thought, and what a score does not mean has to travel with it. The four figures take the full width underneath. The scan-by-scan line left the three-column grid: thirty points squeezed into a third of the page is a shape nobody can read, which is the only thing a sequence of scores is for.

    • The plugin is four admin pages instead of one page wearing eight tabs. Dashboard, Scan & Fix, Settings and Statement now appear under Accessibility Kit in the WordPress menu. The Dashboard is the report. Scan & Fix holds the ways of checking and answering content — one page, your content, the theme, the images — and the record of what was marked a false positive, which belongs beside the findings it came from. The site-wide fixes are Settings; the statement is its own page.

      The previous attempt grouped the eight tabs under Find, Fix and Record headings. That put a taxonomy on screen and asked people to read it before they could find anything, when WordPress already has a place for a plugin’s shape and everyone already knows where to look for it.

      A screen with a single view shows no tab row at all: one tab is a label dressed as a choice, and it makes people hunt for the others.

    • The header says when anything was last checked, on every screen. Nothing here scans on its own, so every figure on screen is only as old as the last time somebody pressed the button, and “nothing checked yet” is a normal answer the report alone never gave. Beside it is a control that opens the screen where you choose what to check — deliberately not one that starts a site-wide run, because that is not something to set going by accident.

    • A real first screen. A new install opened on an empty report, which reads as a plugin that does not work rather than one waiting to be told what to check; testers landed there first and could not tell which it was. It now says what to do, offers the two ways of doing it, and sets out Find → Fix → Say so before anybody has committed to anything.

    • The findings list opens one finding at a time. Every card used to render everything it had — the consequence, the fix plan, the markup, the page, the path, the dismiss control — for all fifty findings at once. Testers called it a wall, and they were describing something real: the one line that differed between two cards was buried under a screenful of identical scaffolding, so fifty findings read as one finding printed fifty times.

      A closed finding is now a single row — what is wrong, and where — at 60px against the 417px an open one takes. Up and down move between them without opening anything, Home and End go to the ends, and the row that is open stays open while you work on it.

    • A finding now says where it is in words you can act on. The only location on a card was the DOM path — things like /html/body/main/div/div[2]/div/div/div/div/div/div/div/div[1]/div/h3/span. That is precisely right for the code that has to find the element again in a preview frame, and no use at all to the person reading the report: it describes a tree they cannot see, in a notation most people who write for the web have never met, and it cannot tell them whether the finding is about their headline or their cookie banner.

      Findings now lead with the element and its words — span.first-title — “About the Conference”, img — “team-photo.jpg” — and the path moves one disclosure down for whoever wants it.

      Derived from the markup already stored with each finding rather than computed during a scan, so the two passes cannot disagree about it and findings recorded before this existed get one without being rescanned.

    • Every finding now says what happens next. A card gave a severity and a detection tag — “Serious”, “Auto-detected” — and left the reader to work out from two pieces of jargon whether the plugin was going to do something, whether they had to, or whether it was even a real problem. Both describe the finding; neither describes what the person reading is meant to do about it, which is the only question they opened the list with.

      Four states, one per finding, in words: We can fix this, You fix this, Your theme needs this, Someone needs to check this — each with a sentence saying how, shown rather than collapsed behind a disclosure. All of it was already on the finding and none of it had been put into a sentence.

    • “Set aside” is now “False positives”. The old wording asked people to learn a phrase this plugin invented for something the industry already names. “This is not a problem” becomes “Add to false positives”.

    • The admin screens use the full window. They were capped at 1240px, which suited prose and squeezed the tables, code and side-by-side panels these screens are mostly made of. Line length is now held on the blocks of prose that need it rather than by narrowing everything.

    • Every finding in a rule-scoped list says which page it is on, and links to it. A list narrowed to one check gathers findings from the whole site, so a row that gave the message, the markup and an XPath was handing somebody a path and no page to apply it to. Not shown on a single page’s own scan, where the heading has already said it once and fifty repeats would say it fifty times.

    • The headline no longer counts “I could not check this” as a finding. Every finding is tagged auto-detected or needs-manual-review, and the number people actually read added the two together — quietly undoing the tagging on the one figure that gets screenshotted. The development site reported 387 open findings, of which 153 were barriers we had settled and 234 were questions we could not answer. Two figures now: Barriers found and Needs a person to look.

    • Contrast that cannot be measured is reported once per background, not once per word. A hero section with a photograph behind it defeats the measurement for every piece of text inside it, and one finding per text node turned a single unanswerable question into 213 of them — burying the 71 contrast failures that had been measured. The finding is now attributed to the element carrying the background and says how much text it covers.

    • Text nobody can see is no longer a contrast failure. Fully transparent text was composited over its own backdrop, which by definition produces the backdrop colour and a ratio of exactly 1.00:1 — so every carousel dot styled color: transparent was reported as the worst contrast failure possible.

    • A finding can no longer contradict itself in its own sentence. A control 23.98px tall was reported as “24.0 pixels, so it is not quite 24 tall enough”. The verdict was right and the number was rounded to nearest; measurements now round down, so a near miss reads as one. A test for exactly this already existed and could not fail, because its fixture used 23.6 — which renders as “23.6” whichever way it is rounded.

      Together these took the development site from 387 open findings to 128, and contrast from 293 to 50. Nothing was hidden: the unmeasurable text is still reported, counted, and named.

    • The overview’s top row no longer has a hole in it. The score card and the four figures beside it sat in a grid aligned to the start, so a 365px card stood next to a 139px row and left two hundred pixels of nothing underneath. The figures are now two by two and fill the height the score sets.

    • Three things that should never have been in the plugin package: eslint.config.cjs, webpack.config.js, .DS_Store and the .claude directory were all being copied into the zip. Found by the build guard below rather than by looking, which is the point of it.

    • The licensing SDK’s directory stopped shipping. The SDK went in 0.16.0, but its directory did not: one leftover dashboard icon stayed behind, .distignore never listed it, and bin/build.sh copies everything that file does not exclude. Every zip built since — including the one produced today — has carried that folder inside a plugin whose own readme promises no licensing SDK and no outbound requests.

      Nothing executed and nothing phoned home; it was one PNG. It was still a claim contradicted by the package it shipped in, which is the kind of thing a WordPress.org reviewer is right to ask about.

      The directory is gone, and bin/build.sh now checks what ships against an allowlist rather than trusting .distignore to have named everything it should exclude. A blocklist only stops what somebody remembered to write down, and the thing nobody remembers to write down is the thing that ships.

    • Findings stored before this version are given their identity in the background, in batches through Action Scheduler rather than by holding an admin request open while a large site’s table is rewritten. Without it a rescan of every page would be the only way to make decisions carry, and the grouped view would be empty until somebody did it.

      Rows still waiting are excluded from grouping and counted out loud, never guessed at: grouping them on their empty identity would have collected forty-five unrelated contrast failures into one group whose “set aside everywhere” retired all of them at once.

    Fixed

    • Dismissals no longer disappear when a page is rescanned. A finding set aside, and the reason given for it, were stored on the issue row the scan happened to be holding. The next scan inserted a fresh row with status open and consulted nothing, so the judgement was discarded — silently, with the only symptom being a finding quietly reappearing weeks later.

      Judgements now live in their own table, keyed on the finding rather than on the row. The issue row still carries a copy, which is what lets every existing query keep reading status off the row without knowing decisions exist; the table is the record, the column is what the last scan happens to be holding.

      This mattered more than a lost checkbox. The dismissal note is what a conformance document is eventually built from, and it was the part being thrown away.

    • Site-wide counts no longer include superseded findings. Nothing ever removed an earlier scan’s issues, so a page scanned sixteen times contributed sixteen copies of each of its faults and the totals grew the more diligently somebody used the plugin. On the development site this read 1,037 open findings where there were 123.

      The sharp edge was that the score was already right — it joins to the newest scan per page — so the one honest number on the overview sat beside a set of counts computed a different way and disagreeing with it. A scan now retires the findings of the previous scan of the same thing.

    Added

    • Scanner\Fingerprint, the stable identity a finding carries: the rule plus its markup, whitespace-normalised. Keyed on markup rather than the XPath selector, because a selector shifts the moment somebody adds a paragraph above the element — exactly when a person is most likely to rescan and least likely to notice their earlier judgement has vanished.

      Normalisation is whitespace collapse and nothing more. Stripping attributes would group “similar” markup and let one decision retire a finding about a photograph nobody had looked at, which does not produce a tidier list, it produces a page that reports itself reviewed when it was not.

    • Db\DecisionRepository and the wp_wsak_decisions table. Decisions are scoped to a page, or to the whole site with post_id 0 — the seam the decide-once flow will attach to. A page’s own decision outranks the site-wide one, so putting a single instance back cannot unpick a judgement taken about two hundred others.

    Upgrading

    Existing dismissals are copied into the decisions table before superseded findings are cleared out. The order is load-bearing and is enforced in Installer::migrate_decisions(): reversing it would destroy the one thing in that table which cannot be regenerated by scanning again.

    Open counts will drop after upgrading. Nothing has been hidden — the earlier figures were counting the same findings several times over.

Releases before v0.29.0 are in the repository’s full changelog.