Skip to content
Mocky/Docs v0.2
Features

SEO and accessibility

9 min read

The quality pass next door asks whether a screen looks like machine-written slop. This asks a different question, and a harder one to answer honestly: whether the screen's markup is sound — whether a search engine can read it and whether a person using a screen reader can use it.

It is a separate report, a separate score, and a separate correction loop, and the rest of this page is mostly about why.


Where it is#

Open a project, then the Audit button in the toolbar. The panel opens on the right with every screen in the project as a thumbnail. Pick one, press Evaluate.

Nothing runs automatically. Nothing runs on generation. Opening the panel costs one localStorage read.

Leaving abandons the run. Closing the panel, or selecting another screen — from the thumbnails or from the canvas — aborts an evaluation in flight. A deep pass is a model call taking seconds, and it had no AbortController at all: it kept being paid for after the panel was shut, and the spinner it left behind was drawn over the report of the screen the user had just asked to see. The panel shows one screen's report, so a check running for a different one can only ever hide the one that was wanted.


The two halves#

Deterministic, in the browser#

Every structural question is answered locally, from an AST, with no network call at all: src/lib/audit/inspect.ts walks the screen once and hands src/lib/audit/rules.ts a summary to test.

This is the opposite arrangement from the quality pass, which detects on the server — and deliberately so. That one wraps impeccable, a Node module that reads node:fs at import and cannot be bundled for a browser. These rules are Mocky's own, and running them here means the evaluation is instant, free, and works with the backend down. That is what makes it reasonable to offer as the default and put the model behind a second button.

Rules are matched against an AST and never against source text (invariant I1). matches inside a string, inside a commented-out block the model left behind, and inside a code sample the screen itself renders. A report claiming three images have no alt when those three images do not exist is worse than no report: it sends someone hunting, and it discredits the findings that are real.

Judged, on the server#

POST /api/muse/audit asks a model the questions a pattern cannot settle: does the alt text describe what the picture shows, does a heading describe the section under it, would this link text still mean anything read out of context. The catalogue is server/muse/quality/audit-questions.js.

It is behind the Deep analysis checkbox because it spends tokens. The screen travels as data, in the user turn, under a header saying so (invariant Q5), and a verdict naming a question that was never asked is dropped rather than shown.

If it cannot run — no model, a timeout, an unparseable answer — the deterministic report is returned with a notice attached. Losing a complete, free report because a network call failed would be absurd (invariant Q1).


What the score does not mean#

Each dimension scores out of 100 and every one of them reports confidence: 'partial'. That is not modesty, it is arithmetic.

Source-only analysis knows the markup and nothing about the rendering. It cannot measure:

  • colour contrast as painted,
  • whether focus is visible,
  • tap-target size,
  • tab order as it actually runs,
  • reflow at any viewport.

Those are half of WCAG. So nothing here claims a conformance level, and the panel prints the caveat beside the number rather than in a tooltip — a number whose caveat nobody reads is a number that gets quoted without it.

Two of those bullets have since gained a partial answer, and the report is careful to say how partial. Contrast is computed where an element paints both its own text colour and its own background, and a tap target's height is deduced from the classes that declare it. Neither is a measurement of a rendered page, which is why the colour family reports medium confidence and the tap-target family low, and why neither says anything at all about the elements it could not read.

A score of 100 means "nothing wrong in what this can see". It does not mean accessible.

Scoring counts distinct rules, never elements. Twenty images with no alt is one thing to fix; scoring it twenty times would drown every other finding and make the number meaningless. The panel still lists every offender.


The breakdown by family#

Under the two scores the panel lists eight families — headings, images, links and controls, forms, colour, typography, tap targets, structure — each with its own number. It is the same findings, cut a second way: seo and a11y answer how does this screen do, and the families answer where do I go and fix it. The two genuinely do not line up. An unnamed button and a missing alt are both accessibility errors and have nothing to do with each other, while img-alt and img-alt-redundant are one afternoon in one file.

The order is fixed in RULE_FAMILIES and deliberately not derived from the report. A list sorted by score reshuffles itself on every re-run, so the row someone was reading moves under their cursor between two clicks of Evaluate.

Each row carries a confidence, and it describes the method, not the run:

FamiliesConfidenceWhy
headings, images, links, forms, structurehighmarkup — an alt, a heading level, a for/id pair are in the source or they are not
colourmediumonly the elements that paint both of their own colours could be compared
typography, tap targetslowpy-2 plus a line box is a statement about a box nobody rendered

A row can carry no number at all, and that is the reason the section exists. A family with nothing to examine reads not applicable; one whose subjects the source does not describe well enough reads not measured. Neither reads 100. A screen with no form and a screen whose forms are perfect produce the same empty finding list, and only one of them has earned full marks — invariant Q4, applied one level below the panel's own caveat. The two words are kept apart because they are different facts about the screen: "there was nothing here to check", and "there was, and the source does not say enough to decide".

Judged findings are filed as well. Their rule ids exist only in server/muse/quality/audit-questions.js, so each question declares its own family there and the browser drops any name it cannot place — the same discipline as dropping a verdict that names a question nobody asked. Without it, a model finding about headings would take points off SEO while the headings row still read 100, and the breakdown would contradict the number directly above it.


The exported document#

A Mocky screen is one self-contained React component. It has no , no </code>, no URL and no routing — the generation prompt forbids all of it. So the document-level half of SEO cannot be graded on a screen, because it does not exist there. It exists in what the export writes, and it is checked separately at the bottom of the panel, once for the project rather than repeated on every screen.</p> <p>Building that check found three real defects in <code>indexHtml</code>, all of them invisible because nobody reads their own export's <code><head></code>:</p> <div class="lm-table"><table><thead><tr><th>Was</th><th>Now</th></tr></thead><tbody><tr><td><code><title></code> was the <strong>slugified package name</strong> (<code>my-shop</code>)</td><td>the project's real name</td></tr><tr><td><code>lang="en"</code> hardcoded, including on French projects</td><td>follows the interface language</td></tr><tr><td>no <code><meta name="description"></code> at all</td><td>written when the project names a product</td></tr></tbody></table></div> <p><code>lang</code> was not a cosmetic default. It is what tells a screen reader which language to pronounce, so every French export was being read aloud in English.</p> <hr> <h2 id="correcting">Correcting<a class="lm-anchor" href="#correcting" aria-label="Link to this section">#</a></h2> <p>Each enforceable finding carries a <strong>Fix</strong> button, and there is a <strong>Fix all</strong>. Both go through <code>runPolishLoop</code> — the same four stop conditions as the quality pass, because "did this pass actually land" is worth solving once — with <code>AUDIT_FIX_PROMPT</code> and the audit as its check.</p> <p>The prompt is a third sibling of <code>FIX_PROMPT</code> and <code>POLISH_PROMPT</code>, and merging any two of them breaks both. Each one's central instruction is wrong for the others:</p> <div class="lm-table"><table><thead><tr><th></th><th>Says</th><th>Because</th></tr></thead><tbody><tr><td>Repair</td><td>fix only the error, do not restyle</td><td>a crash is not a design problem</td></tr><tr><td>Polish</td><td>fix these findings, visual change expected</td><td>a slop finding <strong>is</strong> a styling problem</td></tr><tr><td>Audit fix</td><td>fix the markup, the screen must look identical</td><td>a semantics pass that redesigns has failed even with every finding gone</td></tr></tbody></table></div> <p><strong>Fix is unavailable while the project is generating.</strong> A correction is a screen mutation, and every screen mutation in Mocky shares one <code>AbortController</code>, one busy flag and one progress overlay — so a second one does not run beside the first, it overwrites it. This was the one of the five that did not check, and started during a regeneration it took the Stop button hostage: Stop cancelled the correction, the regeneration became unstoppable, and the progress overlay moved to whichever screen the audit panel was pointed at. The buttons are now disabled while something else runs, and say why.</p> <p>Advisory findings are never corrected automatically. Some rules legitimately contradict a design: a landing page really can be one <code><section></code> with no <code><nav></code>, and forcing landmarks into it would be markup added for the report's sake. Same reasoning as <code>policy.js</code> in the quality pass (invariant Q2).</p> <p>After a correction the screen's report is discarded rather than kept. It describes source that no longer exists, and a stale score shown beside changed code is worse than no score.</p> <p><code>Screen.quality</code> is <strong>not</strong> written by this. That field records the /20 design audit, and putting an accessibility score in it would make two different measurements share one number.</p> <hr> <h2 id="files">Files<a class="lm-anchor" href="#files" aria-label="Link to this section">#</a></h2> <div class="lm-table"><table><thead><tr><th>Path</th><th>What</th></tr></thead><tbody><tr><td><code>src/lib/audit/inspect.ts</code></td><td>AST walk → facts about the markup</td></tr><tr><td><code>src/lib/audit/rules.ts</code></td><td>the rule catalogue and the scoring</td></tr><tr><td><code>src/lib/audit/index.ts</code></td><td><code>auditScreen</code>, <code>auditExport</code>, the deep pass client</td></tr><tr><td><code>src/components/AuditPanel.tsx</code></td><td>the panel, the thumbnail picker, the fix buttons</td></tr><tr><td><code>server/muse/quality/audit-questions.js</code></td><td>the judged questions</td></tr><tr><td><code>server/muse/quality/audit-judge.js</code></td><td>one model call, verdicts filtered</td></tr><tr><td><code>src/i18n/parts/audit.ts</code></td><td>every rule name and description, in both languages</td></tr></tbody></table></div> </div> <div class="lm-end"><div class="lm-fb" data-endpoint=""><span class="lm-fb-q">Was this page helpful?</span><div class="lm-fb-btns"><button class="lm-btn lm-btn-sm" type="button" data-lm="fb" data-value="yes"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-thumbUp"/></svg>Yes</button><button class="lm-btn lm-btn-sm" type="button" data-lm="fb" data-value="no"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-thumbDown"/></svg>No</button></div><form class="lm-fb-form" hidden><label for="lm-fb-text">What was missing?</label><textarea id="lm-fb-text" rows="3"></textarea><button class="lm-btn lm-btn-sm lm-btn-primary" type="submit">Send</button></form><p class="lm-fb-thanks" hidden>Thanks for your feedback!</p></div><div class="lm-edit"><a href="https://github.com/PetitOursManu/Mocky/edit/main/docs/seo-accessibility.md" target="_blank" rel="noopener"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-pencil"/></svg>Edit this page</a><a href="https://github.com/PetitOursManu/Mocky/issues" target="_blank" rel="noopener"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-flag"/></svg>Report an issue</a></div><nav class="lm-pager" aria-label="Previous / Next"><a class="lm-pager-a" href="/en/video-export/" rel="prev"><small><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-left"/></svg>Previous</small><strong>Motion Ultra — films</strong></a><a class="lm-pager-a lm-pager-next" href="/en/deployment/" rel="next"><small>Next<svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-right"/></svg></small><strong>Deployment</strong></a></nav></div> </article> </div> <footer class="lm-foot"> <span class="lm-foot-mark"><svg class="lm-mark" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.5" fill="var(--lm-primary)"/><circle cx="12" cy="12" r="9" fill="none" stroke="var(--lm-primary)" stroke-opacity=".35" stroke-width="1.6"/></svg>Documentation powered by <strong>Lumy</strong></span> <span class="lm-grow"></span> <a href="/llms.txt">llms.txt</a> <button type="button" data-lm="ai-tools">MCP</button> </footer> </main> <aside class="lm-toc" aria-label="On this page"> <p class="lm-toc-t"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-list"/></svg>On this page</p><div class="lm-toc-track"><span class="lm-toc-mark" aria-hidden="true"></span><ul><li><a href="#where-it-is" data-id="where-it-is">Where it is</a></li><li><a href="#the-two-halves" data-id="the-two-halves">The two halves</a></li><li class="lm-toc-sub"><a href="#deterministic-in-the-browser" data-id="deterministic-in-the-browser">Deterministic, in the browser</a></li><li class="lm-toc-sub"><a href="#judged-on-the-server" data-id="judged-on-the-server">Judged, on the server</a></li><li><a href="#what-the-score-does-not-mean" data-id="what-the-score-does-not-mean">What the score does not mean</a></li><li><a href="#the-breakdown-by-family" data-id="the-breakdown-by-family">The breakdown by family</a></li><li><a href="#the-exported-document" data-id="the-exported-document">The exported document</a></li><li><a href="#correcting" data-id="correcting">Correcting</a></li><li><a href="#files" data-id="files">Files</a></li></ul></div> <div class="lm-toc-extra"> <button type="button" data-lm="ask"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-sparkles"/></svg>Ask about this page</button> <button type="button" data-lm="source"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-file"/></svg>View source</button> <a href="https://github.com/PetitOursManu/Mocky/edit/main/docs/seo-accessibility.md" target="_blank" rel="noopener"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-pencil"/></svg>Edit this page</a> </div> </aside> </div> <div class="lm-overlay lm-search" id="lm-search" hidden><div class="lm-search-card" role="dialog" aria-modal="true" aria-label="Search"><div class="lm-search-in"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-search"/></svg><input id="lm-search-input" type="search" autocomplete="off" spellcheck="false" placeholder="Search the docs…" aria-controls="lm-search-res"><kbd>Esc</kbd></div><div class="lm-search-res" id="lm-search-res" role="listbox"></div><div class="lm-search-foot"><span><kbd>↑</kbd><kbd>↓</kbd>move</span><span><kbd>↵</kbd>open</span><span class="lm-grow"></span><span>Typo-tolerant</span></div></div></div><div class="lm-overlay lm-sheet-wrap" id="lm-ai" hidden><aside class="lm-sheet" role="dialog" aria-modal="true" aria-labelledby="lm-ai-title"><header class="lm-sheet-head"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-sparkles"/></svg><h2 id="lm-ai-title">Docs assistant</h2><span class="lm-grow"></span><button class="lm-icon-btn" type="button" data-lm="close" aria-label="Close"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-x"/></svg></button></header><div class="lm-ai-msgs" id="lm-ai-msgs"><p class="lm-ai-welcome">Ask anything about this documentation. Answers come from these pages, with links to them.</p></div><form class="lm-ai-form" id="lm-ai-form"><input id="lm-ai-input" autocomplete="off" placeholder="Ask a question…" aria-label="Ask a question…"><button class="lm-btn lm-btn-primary" type="submit" aria-label="Send"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-send"/></svg></button></form><p class="lm-ai-note">Answers from these docs only. No account needed.</p></aside></div> <div class="lm-overlay" id="lm-dialog" hidden><div class="lm-dialog" role="dialog" aria-modal="true" aria-labelledby="lm-dialog-title"><div class="lm-dialog-head"><h2 id="lm-dialog-title"></h2><button class="lm-icon-btn" type="button" data-lm="close" aria-label="Close"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-x"/></svg></button></div><div class="lm-dialog-body" id="lm-dialog-body"></div></div></div> <div class="lm-lightbox" id="lm-lightbox" hidden role="dialog" aria-modal="true" aria-label="Enlarge"><div class="lm-lb-bar"><span>Pinch or scroll to explore the image.</span><button class="lm-icon-btn" type="button" data-lm="close" aria-label="Close"><svg class="lm-i" aria-hidden="true" focusable="false"><use href="#lm-i-x"/></svg></button></div><div class="lm-lb-scroll"><img id="lm-lb-img" alt=""></div></div> <div class="lm-gloss" id="lm-gloss" role="tooltip" hidden></div> <div class="lm-toast" id="lm-toast" role="status" aria-live="polite"></div> <script type="application/json" id="lm-data">{"lang":"en","base":"/","slug":"seo-accessibility","title":"SEO and accessibility","search":"/_lumy/search-en.json?v=94fef442","source":"/en/seo-accessibility/index.md","strings":{"copy":"Copy","copied":"Copied","pageCopied":"Page copied as Markdown","linkCopied":"Section link copied","noResults":"No results for","suggestions":"Suggestions","resultPages":"Pages","resultSections":"Sections","resultGlossary":"Glossary","glossary":"Glossary","glossaryMore":"Open the glossary","sourceTitle":"Page source","sourceIntro":"This is all the author writes: Markdown, plus a few ::: blocks.","aiToolsTitle":"Use these docs with an AI assistant","aiToolsIntro":"Give an assistant the whole documentation as plain text, or connect it through MCP.","llmsTxt":"Index of every page, for LLMs","llmsFull":"The whole documentation in one file","mcpRemote":"Connect an MCP client to this site:","sources":"Sources","assistantError":"The assistant could not answer. Try again in a moment.","feedbackThanks":"Thanks for your feedback!","stepToggle":"Mark step {n} as done","signIn":"Sign in","signOut":"Sign out","dashboard":"Dashboard","account":"Account"},"glossary":{},"glossaryUrl":"/en/glossary/","assistant":{"endpoint":"/_lumy/api/ask"},"feedback":{"endpoint":"/_lumy/api/feedback"},"llms":{"index":"/llms.txt","full":"/llms-full.txt"},"mcp":{"url":"https://mocky-docs.emanuelvigreux.fr/_lumy/mcp"},"server":{"registration":false,"private":false},"dev":false}</script> </body> </html>