Impossible OS Docs

Docs Search and Accessibility

The docs site searches the full text of every page in the reader's browser, ranks whole words above fragments, retries a misspelled term and suggests the correction, marks the match on the page a result opens, and offers a results page that can be shared as a link. Accessibility is checked twice: every build checks heading and alt-text rules statically, and the Pages workflow audits every current page of the site in a real browser, including under forced colours and reduced motion, with axe-core plus checks for the skip link, keyboard focus judged from rendered pixels, the current-page marker and motion. Retained release trees are published as they were and are outside that browser gate. The site's build and publishing are described in Documentation Site.

How does the docs search work?

Each page is split into records at its H2 and H3 headings. A record holds every word a reader sees in that section: prose, inline code, code blocks, image alt text and raw HTML text, so an API name such as boot_health_publish_json is found wherever it is written. An H3 record carries its H2 in its heading path ("Prerequisites › Fedora"), so a query naming both still lands in one record. search.json is a small manifest of pages and sections; the text lives in shards named by their content hash (search-<hash>.json, cut at about 512 KB). The manifest names each shard with its record count, so a manifest and a shard from different deploys can never be read together. Any manifest or shard over 2 MB fails the build: content is never dropped to fit. Measured 2026-09-29: 2,667 records in six shards, 2.8 MB in total (0.9 MB gzipped), and a 166 KB manifest.

The browser fetches the manifest on the first keystroke and shows title and heading matches at once; each shard's text merges in as it arrives and the results are scored again (shards landing together are scored once), with a "Searching the full text" note until the last one lands. After a shard or the manifest fails to load, the next search the reader types starts again from a revalidated manifest, since a deploy may have replaced it; nothing is retried in a loop. The best record per page is shown, linked to its section anchor, and the popup lists 12 pages. The search box is a W3C ARIA combobox over a listbox: arrow keys move aria-activedescendant, Enter opens a selected result (or, with none selected, the results page), Escape closes the list and keeps the text, and a polite status region announces the result count. Press / anywhere to reach the box; a key hint shows it. A template names the index it reads with data-search-index="2"; one without it reads the older flat index (page text cut at 4,000 characters), and search.json carries whichever the template asks for. A release rendered with --release uses its own tree's template, so it always gets the layout that template reads. A page whose browser still caches the other format recovers on its next typed search, which revalidates the manifest.

How are search results ranked?

Every query term must match the page title, the heading path or the body. A double-quoted run is one phrase term, and an unclosed quote runs to the end of the query. Each match is one of three kinds, judged on the original-case text: a whole word (an identifier part counts, so map is a word in vmm_map_page and in mapPage, and a plural or tense ending such as -s or -ed may follow), the stem of an inflected term as a whole word (interrupts finds interrupt), or a match inside a longer word (map in bitmap). Records compare first on how many terms matched as whole words, then on the field weight of the word matches (title 10, heading 4, body 1), then on stem matches, then on substring matches. A page naming the map identifier therefore ranks above one that only says bitmap, and a page matching every term as a word ranks above one whose title holds a single term. A term of one or two letters is judged as a word only in titles and headings: in body text a letter such as e occurs hundreds of thousands of times, and testing each occurrence cost about 110 ms per keystroke for e, and about 80 ms for five two-letter terms, against 7 ms without (measured 2026-09-30 over 2,816 records).

The stemming rule was decided by measurement on the live corpus (2026-09-30, 308 pages): across the 300 most frequent inflected words, the exact query missed pages that held only the base form for 270 of them, 14,899 page hits in all. A plain word-start stem match would have found 13,366 of those at 86.2% precision (the rest were other words such as design for sign); requiring the stem to end a word, optionally with an inflection, keeps only the 11,520 same-word hits (a suffix rule, not a full stemmer: irregular forms such as ran for run are not matched). Stem matches rank below every exact word match, so they add recall without displacing a precise result.

What happens when a search term is misspelled?

A search that finds nothing, once the whole index has loaded, checks for a term that matches nothing at all. A term of five or more letters is then retried against every word the index holds at edit distance 1: one letter added, removed or changed, or two neighbours swapped. interupt, intrerupt and interruptt all find interrupt. The five most frequent corrections are searched, and they rank below every other kind of match, so a correction keeps the field weights but never outranks a real one. A term that inflects (interupts) falls back to its stem when the full word has no correction.

Some terms are never corrected. A quoted term is exact by request, a term under five letters would match too much, and a changed digit is a different thing (kernel7 is not kernel6). While shards are loading, or after one has failed, the index cannot say a term matches nothing, so no correction is offered. If one term in the query has no correction, the search stays empty.

The results page says which term found nothing and offers a "Did you mean" link for the corrected query, built from what the best result actually matched, so following it always finds something. The popup and the screen reader status say the same. Each result link's ?highlight= carries the correction that record holds, section text first, so the opened page marks the word it found. A correction found only in a page title has nothing to mark in the section, as with an exact title match.

The vocabulary is built on the first misspelling and kept for that index. It is built in slices of at most 8 ms between timer turns, never inside the keystroke that asked: one synchronous pass over the full index cost 370 ms on the 2026-11-07 corpus (309 pages) and about 310 ms when measured again on 2026-11-08, long enough to freeze typing. The budget holds inside a record too, since one record holds 232,323 characters. Until it is ready the search shows its plain result; the finished vocabulary then redraws the query that waited for it, as a landed shard does, and nothing else: an exact search typed in the meantime keeps its expanded results and its focus. Each later misspelling costs about 25 ms. A reader who never misspells pays nothing. Corrections checked on that corpus: kernal found kernel, schedular found scheduler, memmory found memory, filesytem found filesystem and bootloadr found bootloader.

What does a search result open?

Result links carry the query as ?highlight=, and the opened page marks the first match in the linked section (from its heading to the next H2 or H3, or the text before the first heading when the link has no fragment) with <mark>, preferring a whole-word match. The section is read the way the index reads it, with text nodes joined and whitespace collapsed, so a phrase such as "call boot_health_publish_json" that runs into inline code is marked in each piece it covers. Only text nodes are split, so code keeps its text, and diagrams, scripts and the title are never touched. Choosing another section of the same page from the results changes only the fragment, so the old mark is removed and the new section is marked without a reload. Snippets in the popup mark every term, escaping the text around each mark separately.

search.html?q= is the results page: every matching page with all of its matching sections, best first. Unlike the popup it is drawn once, when the full text has loaded, so a list a keyboard reader is moving through never re-ranks underneath them. Every index request, the manifest and each shard, must answer within 15 seconds or it is aborted and counts as failed, so a stalled request ends in the matches found so far (or "Search is unavailable") with a retry note instead of "Searching" for good, and a retry never leaves the old requests running. It is drawn in batches of about 100 sections (a page is never split, so a batch can run a little over) with a "Show more results" button that adds the next batch after the last (a broad query such as e matches nearly 2,800 sections, and drawing them all at once built close to 1 MB of markup). Typing in the box on that page searches again once the reader pauses for 150 ms and updates the address, so a search can be shared as a link. The build generates it only when the template's search box names it (data-search-page="search.html"), so an older release template is unaffected; it is noindex and so absent from sitemap.xml, and it is not listed in the navigation.

Which accessibility rules does the build check?

Every docs page of the current tree must have exactly one H1, first; no heading may be more than one level deeper than the one before it; and every image needs alt text. A Markdown image is judged by the alt text it renders to (![`x`](y) renders an empty one), so it always needs plain alt text; raw HTML may write alt="" for a decorative image but may not omit the attribute. Headings must be Markdown: a raw HTML <h2> gets no anchor, contents entry or search section, so it fails the check. A release is published as it was and is not re-judged.

How is accessibility audited in a browser?

The static check above cannot see colour, focus or layout, so scripts/site/a11y/audit.mjs loads every page of the built site in Chromium and runs axe-core over it with the WCAG 2.2 A and AA rules plus axe's best-practice rules. That covers the docs pages and the landing pages: home, 404, the two design mockups and the boot error pages. Each page runs in seven variants: 1280 and 375 pixels wide in the light and dark themes, forced colours at desktop dark and phone light, and reduced motion at desktop light.

It adds checks axe does not make. The first Tab lands on a visible skip link whose target exists. Every element Tab can reach shows a focus indicator, on screen and not covered by another element (so the phone-width navigation drawer is inert while closed, takes focus when opened, and closes when focus lands outside both it and its menu button, including through the skip link, or on Escape; the audit checks this by opening it from the keyboard, tabbing and walking every link with its groups expanded, skipping, closing and resizing). On docs pages the navigation marks exactly the current page with aria-current="page", and marks nothing on a page it does not list. Under reduced motion nothing may move: no keyframe animation, no transition of position or size, no smooth scrolling and no running animation. A colour fade is allowed. The search surfaces are audited in their populated states: the open combobox; the results page, including pressing "Show more results" from the keyboard (focus must land on the first new result) and the focus walk over every generated link; and a page opened from a result. Only the local server is reachable, so fonts and the diagram library never decide a verdict.

How is a focus indicator judged?

From rendered pixels, not computed styles. Elements that share a signature render their focus change alike, so the audit measures one element per signature in each variant and applies the verdict to all of them. The signature holds the element's tag, classes and parent; its look and its pseudo-elements' look before and after focus; the backgrounds behind it; and which edges of its ring region an overflow ancestor or the viewport clips; and its shorter side, since the pass mark scales with it. The audit takes a screenshot of the region around the element, unfocused and focused, with animations stopped, and refuses a pair if the page moved between them. A pixel counts when the two states differ by at least 3:1 (the ratio of WCAG 1.4.11, measured between states as WCAG 2.4.13 does).

An indicator passes when those pixels cover at least a 2-pixel line along the box's shorter side and reach that far. A full ring, an underline and a single edge of the box pass, and a speck does not. This is the audit's own rule for a visible indicator. It is deliberately looser than the 2-pixel perimeter of WCAG 2.4.13, a AAA criterion that would reject a one-edge indicator. One limit remains: another element that covers only the ring, not the element's centre, is invisible to the signature, so it is judged by whichever element was measured.

Pixels catch what the old computed-colour model could not. It rejected a valid ring over a permanent inset shadow, and it passed the home page's FAQ questions, whose ring was clipped entirely by overflow: hidden on their container.

What does the control prove?

A control run first plants 31 defects into copies of real pages and fails unless every one is reported: low contrast; a focus ring that is removed, transparent, worn at all times, a transparent shadow, a transparent extra shadow layer over a permanent one, a transparent underline, a ring the colour of the page behind it, an inset ring the colour of the control itself, an unpainted border, or missing only in the dark theme or only in the open phone drawer; a speck; a ring its container clips; a ring its own clip-path hides beside a visible twin of the same look; a ring only a shadow draws, which forced colours remove; an indicator that is display: none or moved off the page beside its visible twin; a fixed-size bar 2 pixels too small for its box; a link under an overlay; a link in an off-screen drawer; a result link on the results page with no focus ring; a missing skip link; two links marked current; a current link on a page the navigation does not list; a keyframe animation, a sliding transition and a nested smooth scroller under reduced motion; and low contrast, a missing skip link and a missing ring on the home page.

It also plants correct indicators that must not be reported: a left-border indicator, an inset ring, a ring over a permanent inset shadow, the unclipped twins of the clipped rings, the correct twin of those bars, and a link a wide table partly clips (browsers leave a partly clipped focused link where it is, so the template scrolls it into view). A failed measurement is its own finding and fails the control, so it can never count as a reported defect. The forced-colours plant must be reported there and nowhere else.

axe's contrast rule is off under forced colours. axe-core 4.13 compares the authored text colour with the forced background (measured: #f0f4ff on #ffffff, 1.1:1) while the browser paints the system text colour (black), so the verdict is false. Both colours there are the reader's own choice. This is a workaround for a tool limit, marked as one in the script, and every other axe rule still runs.

The Pages workflow runs the control and the audit in an a11y job beside the build, and the deploy needs both. The first docs run on 2026-09-30 found 38,653 violations, all fixed at source: the muted text colour at 4.41:1, links nested in the navigation's <summary> elements, links marked by colour alone, code blocks and tables a keyboard could not scroll, unlabelled task checkboxes, light-theme alert titles, and two tables broken by an unescaped |. The first run with the landing pages on 2026-11-07 found 1,839 findings, also fixed at source: no skip link or <main> on any landing page, the muted text colour at 3.42:1, a 404 page with no heading, closed panels in the desktop mockup that still took focus, a phone taskbar whose buttons overlapped, no reduced-motion rule anywhere, the clipped FAQ rings, and the light theme's pressed accent at 4.3:1 under white text, corrected in docs/design/tokens.json. A full run takes 10 to 17 minutes (measured 625 to 997 seconds; the slowest was 2026-11-08, sharing the host with the site tests).

axe-core 4.13.0 (MPL-2.0) and playwright-core 1.63.0 (Apache-2.0) are pinned by scripts/site/a11y/package-lock.json. They are development tools only, never part of the site or the OS image.

Impossible OS is free software under the GPL-3.0-only license. Last updated Edit this page on GitHub