Colophon
How this site was built and why. The site itself is a portfolio piece; every meaningful decision is documented in writing so that it can be examined.
Stack
- Framework: Next.js 16 (App Router) on Node 20.
- Language: TypeScript.
- Database: MongoDB. Locally via Homebrew; production on OVHcloud’s managed MongoDB at Gravelines.
- Search: OpenSearch (Apache Lucene 9.x). Self-hosted, no SaaS dependency.
- Styling: Vanilla CSS organised in
@layergroups (tokens, axioms, base, layouts, components, utilities). No preprocessor, no Tailwind, no CSS Modules. Design tokens as CSS custom properties. - Hosting: OVHcloud VPS at the Gravelines data centre. All data resident in EU jurisdiction. See the Hosting and deployment section below.
- Local services: Homebrew (
brew services). No Docker.
Hosting and deployment
The site runs on a single OVHcloud VPS at the Gravelines data centre. MongoDB, OpenSearch, the Next.js server, and Caddy all live on the same 4-core, 8GB box. Each backend binds to 127.0.0.1, so the firewall only has to admit ports 22, 80, and 443. There is no separate database tier, no managed search service, no container orchestrator.
The single-box shape is a deliberate choice. The site’s traffic doesn’t justify a multi-tier deployment, and multi-tier deployments add operational surfaces — network policies, secret management, inter-tier auth, opaque platform layers — that are themselves accessibility-hostile in the small sense: hard to inspect, hard to reason about, hard to fix when something fails. A single box keeps everything legible. journalctl -u opensearch and pm2 logs a11ybobare one SSH session apart. Data residency is incidental but welcome: Gravelines sits under EU jurisdiction, which fits the broader “minimise US exposure where the choice is free” position.
Caddy handles HTTPS termination and reverse-proxies to localhost:3000. When the site moves from IP-only access to its real hostname, Caddy will provision the Let’s Encrypt certificate automatically; the Caddyfile gains a domain name and nothing else changes. pm2 keeps the Next.js server running and brings it back across reboots through a generated systemd unit. Neither tool was chosen for novelty; both were chosen because the accessibility-relevant property they share is not lying about what they’re doing.
Deployment is git pull, npm run build, pm2 restart a11ybob— done over SSH. No preview environment, no platform-as-a-service automation. A pre-push hook on the developer machine plus a GitHub Actions build job together gate mainagainst broken builds: TypeScript errors that don’t surface in next dev can still block next build, and that gap had let a broken build sit on mainonce before the gate landed. The benefit of the deploy mechanism itself is that it’s the same three commands anyone reading the repository could run on their own server. The site is documentation of itself.
Layout
The layout system is Every Layout by Heydon Pickering and Andy Bell. Twelve composable primitives — Stack, Box, Center, Cluster, Sidebar, Switcher, Cover, Grid, Frame, Reel, Imposter, Icon, Container — implemented as class-based CSS in src/styles/layouts/.
Heydon’s and Andy’s thinking is the foundation for the “intrinsic” properties of the site: responsiveness without breakpoints, content-derived sizing, logical properties throughout.
The styleguide is the working artefact this design is checked against — every token, primitive, and state used by the site, rendered live. Not a destination route in the production sense; a reference surface the design system is held accountable to.
Typography
- Body: Atkinson Hyperlegible — designed by the Braille Institute specifically for legibility under low-vision conditions. The on-brand choice for an accessibility-positioned site.
- Headings: Source Serif 4 — designed by Frank Grießhammer at Adobe, variable weight, SIL Open Font Licence. The serif/sans contrast is itself a non-chromatic signal that “this is structure, not body.”
- Code: system monospace stack (
ui-monospace,SFMono-Regular,Menlo,Consolas). Zero payload; every operating system already has a good monospace face.
The type scale uses a ratio of 1.2 (minor third), chosen so that the largest and smallest text on any page differ by no more than 3:1. Screen-magnifier users do not have to adjust zoom level when moving between headings and body text.
Multi-word link text doesn’t wrap across lines
Every <a> on the site carries white-space: nowrap. A multi-word link like See the Spotlight index or Polymorphic Task Decomposition is a single phrase that should be scannable as one unit; splitting it across two lines on a column-margin break makes it read as two disconnected fragments and obscures where the link starts and ends. The white-space rule treats internal spaces as non-breaking, so the wrap point moves to before the link rather than into it.
The trade-off is that a link longer than the article column would overflow horizontally rather than wrap. Site link text is short enough throughout that this hasn’t surfaced; if a future case overflows, the preferred fix is to reword the surrounding prose so the link is shorter (link text rarely needs to be the entire descriptive sentence) rather than override the global rule. The global rule stays.
Words never break across lines
Body paragraphs set hyphens: none, which disables automatic word-breaking and ignores authored soft hyphens too. A word like polymorphic or capability stays as a single token on one line; the line wraps before the word rather than through it.
The visual cost is a more ragged right margin than the browser’s default justified-with-hyphens layout would produce. The reading benefit is the one that matters for the users this site is built for. A screen-magnifier user reading at high zoom traverses from the end of one line back to the start of the next as a slow physical motion: the eye, the head, sometimes the whole upper body. When a word is split across that gap, the two halves don’t reassemble naturally on re-reading the way they would for a sighted user reading at normal zoom — the reader sees capa-, scrolls, sees bility, and has to consciously stitch the word together. Multiplied across a paragraph it becomes substantial cognitive friction.
Same family of decision as the no-wrap rule for link text above: optimise for read-flow at high magnification rather than for compact text-fitting at default zoom.
Links that open a new window announce it on the link
A handful of links open in a new browser window rather than navigating in place — the interactive demos, which take over keyboard, focus, and screen-reader handling and have no site chrome to navigate back through, so they need a window of their own. Every such link carries a visible “(opens in a new window)” notice, and the notice is part of the link itself, not a separate line beside it.
That placement is the whole point. Because the notice is inside the link, it is part of the link’s accessible name — so a screen-reader user moving through focusable content hears “Open the interactive terminal map, opens in a new window, link” when the link takes focus, before they activate it. A warning that sits in nearby prose, or only appears after the new window has already opened, arrives too late to be a warning. The earlier draft of this used a visually-hidden notice plus a separate sentence; making it visible and part of the link is both clearer for sighted users and more robust for everyone (WCAG 2.4.4, 3.2.5; technique G201).
The mechanics live in one NewTabLink component so the behaviour can’t drift link to link: it sets rel=“noopener”, appends the notice, and keeps the label and the notice each as one non-breaking phrase while letting the link wrap betweenthem — so the added words never force horizontal overflow, honouring the same no-mid-phrase-break rule the link and hyphenation decisions above follow.
Colour
Three motivations layered. First, body text on pure white is harsh for sustained reading — the low-vision recommendation that recurs across AT practice is closer to “cream surface, dark grey text.” The default surface on this site sits at 95% OKLCH lightness, not 100%, and the default ink at 20%, not pure black, so the starting point is gentler than the conventional defaults before any tint is applied. Visitor preferences via prefers-color-scheme and prefers-contrast override whatever the site says.
Second, per-zone hue answers the “where am I” question for visitors who can perceive subtle colour differences — borrowed from BridgePoint, the Shlaer-Mellor xtUML modelling tool that tinted Component / Class / State / Action diagrams so engineers always knew which kind they were looking at. Nothing on the site requires perceiving the tint to use it; the colour is a way-finding cue layered on top of structure that already works without it. Colour-vision deficiencies, high-contrast user stylesheets and prefers-contrast: more all flatten the palette without losing information.
Third, identical luminance across every zone. Low-vision users should not have to re-adjust display brightness or screen-magnifier contrast as they navigate between sections, so while hue and chroma vary per zone, lightness is constant: 95% surface / 20% ink in light mode, 20% / 96% in dark. Body-text contrast is identical on every page; only the hue of the underlying surface shifts. All colour values are expressed in OKLCH— a perceptually uniform colour space — for that reason: the constant-lightness constraint is enforceable across ten different hues in a way HSL and sRGB do not provide.
Search-result highlighting carries through to the article
When you click a result on /writing, the matched terms are highlighted (using the semantic <mark> element) wherever they appear in the article body. The highlights aren’t a naive client-side substring match against the URL query — they’re computed by re-querying the search index for the specific article with that query and reading back the analyser-faithful matches. So what was marked in the result snippet is exactly what’s marked in the body, including stem matches and other things the English analyser handles.
This deliberately removes ambiguity between the search result and the article body. For readers using screen magnifiers — who can only see a small portion of the page at any time — and for readers whose attention budget is constrained for any other reason, that ambiguity is a real cognitive load. The cost (one extra search request per article view, only when arriving from search) is trivial in comparison.
Why every search result carries a match-strength badge
Each search result on this site shows a small pill labelled Exact phrase, All terms, or Some terms. Three tiers, in descending strength of match.
The reason isn’t decoration. Highlighted terms in the title and snippet are useful to sighted readers, but a blind screen-reader user hears the result-card content read aloud with no indication that anything is highlighted, and a low-vision user using a screen magnifier may only see one or two words at a time and never catch sight of the highlights at all. Without something more, those users have no way to tell why a particular result was returned — whether the engine matched the exact phrase they typed, or just one word of it.
The badge encodes that information as a short label that every assistive technology can convey straightforwardly: read aloud verbatim by a screen reader, magnified together with the title, rendered as plain text by a refreshable braille display. It’s the same information the highlights carry, made independent of vision.
The badge is suppressed for single-term queries, where the three tiers all match the same documents and the distinction would carry no information.
No placeholder text — hints sit beneath inputs
You won’t see grey placeholder text inside any input on this site. Where a field needs guidance, the hint is rendered as a visible <small> beneath the input and associated via aria-describedby so screen readers announce it together with the field.
The site targets WCAG 2.2 AAA, which requires 7:1 contrast for body text. Browser-default placeholder colour doesn’t reach that — it’s deliberately muted to signal “not real content”, which is exactly the legibility compromise AAA exists to forbid. Placeholder text also vanishes the moment the user starts typing, removing context they may need to re-read; for screen-magnifier users, who navigate by zooming and panning, that vanish-on-type behaviour is particularly disorienting.
The cost of putting hints below the input is one extra line of small text. The benefit is that the guidance stays present, stays legible at AAA, and doesn’t conflict with the field’s value.
Type-ahead suggestions across every corpus
The search box offers type-ahead suggestions after two characters, drawn from article and review titles and from glossary terms (including aliases — typing Music Braille finds the entry whose canonical term is Braille Music). Suggestions are grouped by corpus and selecting one quick-jumps to the resource. Pressing Enter without picking a suggestion runs the regular search.
The combobox follows the WAI-ARIA 1.2 combobox pattern: the input has role=“combobox”, the dropdown has role=“listbox”, each suggestion is a role=“option”, and arrow keys / Enter / Escape work as expected. Without JavaScript the form still submits to the server-rendered results page exactly as it always has — suggestions are a strict progressive enhancement.
Backed by OpenSearch’s completion suggester, an FST-backed prefix matcher; suggestions return in a few milliseconds. Browser autofill of previously-submitted queries is also on (see below) and complementary — browser autofill suggests what you typed last time, the completion suggester suggests what the corpus contains.
Autocomplete is on for every input
Every text input on the site has the browser’s autocomplete behaviour turned on. The only exception is passwords (none on the site at the moment, but the rule holds): those use the password-specific autocomplete tokens so password managers can fill them, but they don’t accumulate plaintext history.
Text entry is one of the highest-cost activities on the web for many disabled users. A blind user on an explore-by-touch mobile keyboard, a switch-access user hunting through an on-screen grid, an eye-gaze user dwelling on each letter — for any of them, every keystroke saved is a real reduction in fatigue. The default for input elements should bias hard towards fewer keystrokes; turning autocomplete off should require a specific reason that outweighs that cost.
The default for HTML inputs is autocomplete=“on”. We were setting autocomplete=“off”on the search box without thinking. That’s the kind of well-meaning default that quietly punishes the users a site like this is built for.
Filters live with the results, not in a sidebar
Search-result filters appear at the top of the result column, not in a left sidebar. The currently-applied filters render as removable chips that are always visible; the rest of the filter options live behind a disclosure widget that’s closed by default.
The sidebar pattern looks tidy on a wide screen, but it fails the users this site is built for. A screen-magnifier user zoomed into the result column never sees a sidebar to its left — the filters are effectively invisible to them. A screen-reader user hits the sidebar before the results in DOM order, with no way to skip past it. On a narrow viewport the sidebar collapses on top of the results, pushing the actual content below the fold whether the user is filtering or not.
Putting filters at the top of the column they affect fixes all three: magnifier users see them in the same field of view as the results; screen-reader users encounter them in a sensible reading order; narrow-viewport users see filters and results in their actual priority order without a layout shift.
Filter facets are alpha-sorted and case-normalised
Two related decisions about how the filter options themselves are produced.
Alpha-sorted within the top-N by count. The default order returned by OpenSearch’s terms aggregation is count-desc. That keeps the biggest buckets first but makes the list hard to scan for a specific value: the reader has to read every label before giving up. The page now takes the top-N facets by count (so the long tail is still excluded) and then alpha-sorts the slice for display. Year facets keep their numeric-desc order — most-recent-first is the right reading order there.
Case-normalised at index time, not at the source. The papers in the reviews corpus carry whatever tags the authors wrote: “Assistive Technology”, “assistive technology”, and the rare “ASSISTIVE TECHNOLOGY” all appear in the source. The source data is definitively correct — that’s how the papers were tagged — so the fix isn’t to rewrite it. Instead the OpenSearch keyword fields for category (glossary), tags (reviews, articles), and domains (articles) now have a lowercase normaliser. The normaliser applies to indexed values and to the query input of term filters, so case variants collapse into one bucket for aggregation and continue to filter correctly. URL parameters are defensively lowercased in the lib too, so old mixed-case shareable links still match.
Diacritics are not yet folded. “Café” and “cafe” would still be two buckets if they appeared. The asciifoldingtoken filter that would merge them is applied to the full-text analyser but not to the taxonomy keywords, because the change is behavioural (it strips diacritics from filter values shown to the reader) and hasn’t been needed by the current corpus. If a future ingest brings in tags whose only difference is a diacritic, the normaliser gets asciifolding added alongside lowercase.
Pagination at top and bottom of every result list
Result lists carry pagination controls both above and below the list, not only below. The two instances are identical in function and labelled distinctly (Pagination, top of results and Pagination, bottom of results) so a screen reader’s landmark navigation can target either.
Pagination only at the bottom assumes the reader wants to read every result before moving on. For a screen-reader user, that means tabbing through every result’s heading and metadata to reach the page-2 link. For a screen-magnifier user, it means scrolling the visible window through tens of result cards to find the same control. Both costs are real and avoidable. A duplicate set of controls at the top of the list lets either user move between pages from the position they’re already in.
When there’s only one page of results the top instance is suppressed, since the live region above already announces the count and a second “N results” line would be noise.
The pagination current-page cue is self-contained, not relative
The conventional way to mark the current page in a paginated list is to set its number in bold. That works fine for a reader who can see the whole row at once and compare cells. It fails at high zoom: a screen-magnifier user looking at one cell with the surrounding cells off screen has no way to know whether this cell is the current one, because bold-versus-not-bold is a relativecue. So the current page on this site gets an outlined box (a 2px border in the ink colour), plus the bold weight as a secondary signal. The border padding matches the unstyled cells’ padding so cells don’t shift width when state changes. A single isolated cell now answers the question on its own.
The current page is rendered as a real <a> linking to itself, carrying aria-current=“page”and a prefixed accessible name of “Current page, page N”. The W3C Design System recommends this pattern: keeping the current page as an actual link means AT users navigating the document by links never lose track of where they are. The earlier draft used an inert <span> which dropped out of the link list entirely.
The count line (Results 1–10 of 17) and the row of page links sit in one horizontal cluster rather than two stacked rows. At 400% zoom the difference is meaningful — vertical traversal between the count and the controls reads as one widget, not two. Previous and Next carry the destination page number in their accessible name (“Previous, page 2” rather than “Previous”) so voice-control users can speak the destination as well as the direction, and screen-reader users hear where the link will take them before they activate it. Gaps between page numbers announce the skipped range explicitly via a visually-hidden span (“skipping pages 4 to 12”) rather than relying on the visible ellipsis alone, which screen readers omit in browse mode.
Modals are native <dialog>, not a custom widget
Every modal on this site — the Playground’s Help, Fix, and Reset confirmations, and the image-zoom modal on the About page — is a native HTML <dialog> opened with showModal(). The browser handles focus trapping, Escape-to-close, backdrop rendering, and the correct AT role; nothing is reimplemented in JavaScript.
The custom-modal route — a div overlay with a role=“dialog”and a manual focus-trap loop — is the more common choice in modern frameworks. It also goes wrong constantly. Focus traps miss edge cases (iframes, shadow DOM, dynamically-added focusables); Escape handling collides with other keybinds; restoring focus to the trigger on close is forgotten; the backdrop click leaks through to the page underneath. Each of those failures is a real accessibility regression for keyboard and screen-reader users, and each is solved for free by the browser’s <dialog> element.
The cost was small and bounded: the universal max-inline-size axiom needed dialogon its exception list, and the dialog’s sizing rule had to use fit-content capped at 80ch rather than an explicit width so short confirm dialogs render compact instead of stretching. Two CSS lines, against an entire category of accessibility bugs the platform now handles.
Image-zoom triggers carry a visible label, not just an aria-label
The About page carries seven figures spanning a four-decade career timeline — period photographs, architecture diagrams and project screenshots. At thumbnail width the labelled diagrams in particular are too small to read in detail. Tapping or clicking any thumbnail opens the image at near-viewport size in a native <dialog>, with the same focus trap, Escape-to-close and focus-return contract as the Playground’s dialogs above.
The interesting choice is the trigger. Image-only buttons routinely fail SC 2.5.3 Label in Name: the button carries an aria-labeldescribing the action, but voice-control users cannot speak that label because there is no visible text matching it. A persistent “View larger” badge sits in the bottom-right corner of every thumbnail; the badge text matches the start of the trigger’s accessible name — View larger: {trigger label} — where the trigger label is a short identifier for the figure (e.g. the Ascotel Crystal terminal, the PTD task tree). A voice-control user can say “View larger” to activate, and the engine’s numbered-overlay disambiguation handles the case where multiple triggers match at once. The badge is solid ink-on-surface (not semi-transparent) so it meets AAA 7:1 contrast against any underlying image.
The dialog uses aria-label for the same short trigger label and aria-describedbypointing at the in-dialog caption for the longer description, so SR users hear the figure’s caption as the dialog’s description rather than its name. An earlier draft put the full caption directly into the dialog’s accessible name (and into the trigger’s aria-label), which meant a 60-word string was read on every focus and on every dialog open. The short-name / long-description split fixes the noise without losing the description.
The figure is a composition of the Every Layout primitives already documented: Stackfor the figure’s internal spacing, Frame (cover for photographs, contain for diagrams) holding the image, and Sidebar or Grid placing the figure against the surrounding prose. For raster images the trigger button isthe Frame — click-anywhere on the image opens the dialog. For inline SVG content the Frame is a plain container and the trigger is an overlay button positioned absolutely in the Frame’s bottom-right corner; visually identical, structurally a sibling. See the next section for why that distinction matters. The dialog is the standard native <dialog>— nothing custom, no JavaScript focus-trap, no portal.
Structural diagrams are inline SVG, with the Graphics module preserved as a worked example
The PTD task tree on /research/polymorphic-task-decomposition is hand-authored inline SVG, not a raster image. Three reasons make the choice worth keeping even when the AT path is ultimately the same as a raster image with alt text: (a) inline SVG inherits the page’s CSS custom properties — strokes and text use currentColor; the wrapping CSS sets color on the root SVG element to var(--ink), so the diagram automatically picks up the research zone’s ink tint and adapts to Windows High Contrast / forced-colors mode through CSS system colour resolution; node-box fills use var(--surface-1) with a @media (forced-colors: active)override. (b) The diagram scales vector-perfect at any zoom level, including the dialog’s near-viewport size. (c) Text labels are real text — selectable, indexed by find-in-page, copy-paste-able. None of these are available to an external SVG referenced through <img src>: the browser renders external SVGs in their own document context with no access to the parent page’s tokens or media queries.
The SVG also carries a complete example of the WAI-ARIA Graphics module structure inside it: role=“graphics-object” on every grouped node (root task, polymorphs, sub-tasks, modality enclosures) and role=“graphics-symbol” on every leaf modality icon, each labelled with aria-label. The intention of those roles is to let AT users navigate the tree structure node by node — hearing “Root task: Delete File,” “First polymorph: Direct Manipulation,” “sub-task: Select File, performed before Select Delete,” and so on, walking the diagram as a navigable structure rather than receiving it as one opaque image.
That contract turned out to be aspirational. AT support for the Graphics module (defined in 2018) is patchier than the spec suggests in 2026: VoiceOver on macOS and iOS handles it reasonably well; NVDA on Windows is inconsistent across browser combinations; JAWS on Windows largely doesn’t implement it; TalkBack on Android walks SVG elements individually regardless of roles, announcing pixel-coordinate geometry of the inner <rect> and <text>elements rather than the parent group’s aria-label. Tested directly on this site. Two of the most-used AT engines (JAWS and TalkBack) don’t honour the module at all, which means the carefully-placed roles inside the SVG don’t reach the AT users who depend on those engines.
So the SVG’s root currently carries role=“img”— atomic-image semantics — with descriptive <title> and <desc> children carrying the accessible name and description. Every AT engine announces those. The structural Graphics-module roles remain in the markup as a worked example of the spec — the diagram would become navigable in a more mature ARIA-support landscape by simply removing the role=“img”override; the one-line change is left explicit in the source so the upgrade path is obvious. The site is honest about shipping what AT support can reliably deliver today rather than claiming a navigation experience most users don’t actually receive.
Three earlier drafts of this figure overstated the AT delivery. The first hid the SVG inside an interactive <button>(the “View larger” trigger), which makes every inner role unreachable regardless of engine support — the button is an AT leaf, and SR users stop at its name. The second declared role=“graphics-document”on the root with the implication that VoiceOver and NVDA users would get rich navigation — true for some configurations, untrue for most. Both got fixed as they were caught, but the pattern is worth naming: AT contracts you commit to in markup are only as real as the AT engines that honour them, and assuming “the spec exists therefore it works” is a recurring trap. The trigger-outside-button fix from the first round is preserved in ImageFigure’s content-mode path; when the Graphics module is widely supported, it will do its job correctly.
The ImageFigure component was extended with an optional content prop that accepts a ReactNode in place of the <img> element. The trigger and the dialog render the content as separate React subtrees, so any useId() inside the content generates distinct IDs in each location with no DOM ID collision.
Weight, not colour, marks the destructive action
The site palette is monochrome — ink, surface, rule, a single accent. There is no red. So when a confirm dialog needs to distinguish a destructive button (Reset, discard, delete) from a safe one (Cancel), the conventional red-versus-grey treatment isn’t available. The destructive button is solid ink fill with a heavier border; the safe button is the standard outlined pill. The destructive action carries more visual mass, which is the same signal red-versus-grey encodes — “this one has consequences” — translated into the dimension the palette actually offers.
This isn’t only a stylistic concession. Colour alone fails WCAG 1.4.1 (Use of Colour) for users with colour-vision deficiencies, who may not see a red-versus-grey distinction at all. Forcing the design to encode emphasis non-chromatically from the start produces the same affordance for everyone, rather than a primary signal that fails for some users plus a redundant fallback nobody notices.
Destructive confirm dialogs focus Cancel, not Confirm
When the Playground’s Reset confirmation opens, the initial keyboard focus lands on Cancel — not on the Reset button that would discard the user’s edits. This is the opposite of what window.confirm()does and the opposite of the default for “OK / Cancel” dialogs in most operating systems.
The reason is the cost of an accidental Enter for users of switch access, eye-gaze input, and other assistive input methods. Those interaction modes inherently dwell on a key longer than intentional typing does; a destructive action one-keystroke-away from focus on dialog open is a real risk of lost work. The user has already pressed the Reset button to openthe dialog; requiring a second deliberate motion to confirm isn’t friction, it’s the safety margin. Non-destructive confirms still focus the affirmative action, where Enter-to-accept matches user expectation and the cost of a stray Enter is recoverable.
The same reasoning underpins the choice to add tabindex=“-1”targets and skip links inside long tool surfaces: anywhere a keyboard user’s next intentional action is many tab stops away from their current position, that’s a measurable cost we can erase by giving them a shortcut, and dialogs are the case where the cost of not doing it is highest.
No marketplace listings — distribution is the repo itself
Bob’s tools that ship as browser-or-editor extensions — Paradise’s VS Code plugin, the Carnforth Chrome DevTools extension — are distributed as a checkout or download from the source repo, never via a marketplace listing (the Visual Studio Marketplace, the Chrome Web Store). The choice is intentional and permanent, not a stopgap awaiting a listing that will eventually arrive.
The reasoning fits the rest of the site’s framing. Marketplaces interpose a third party between the author and the user: they introduce policy surface (content moderation, name-collision rules, region-blocking), they introduce a brand (“available on the Marketplace” signals a commercial-product posture the work isn’t), and they introduce a release ceremony that doesn’t match how Bob actually develops the code — small experimental commits, frequent reframings, version numbers that mean something to the author rather than to a listings catalogue. Distributing from the repo keeps the artefact aligned with the way it was made.
Practically: install steps live in each tool’s page (the VS Code plugin via code --install-extension paradise-a11y.vsix; Carnforth as a Chrome developer-mode unpacked extension). Users who want guaranteed currency clone and rebuild from main. Users who don’t want to do that aren’t the target audience for the tools.
All my code as readable source, not as compiled libraries
The site ships several substantial pieces of working code: the Paradise multi-model accessibility analyser engine that powers the Playground; a virtual screen reader, switch-access simulator, and session recorder/replayer also in the Playground; and a TypeScript port of the original PhD-era Action Language execution engine that runs the worked examples on /playgrounds/action-language. All of that is in the repository as readable TypeScript source, not as compiled .js and .d.ts snapshots.
The lib-snapshot pattern is convenient. It keeps the site repository small; it lets a single canonical source be consumed elsewhere; it sidesteps strict-compilation drift between projects. It is also opaque. A reader who clicks “view source” on the analyser engine, or who pulls the repo to learn from what was built, gets a wall of emitted JavaScript that is technically correct and humanly unreadable. The site is meant to be evidence; opaque evidence is not evidence.
The cost of carrying source instead of libs is one re-port pass per engine when it changes upstream, plus the occasional fix when stricter TypeScript settings surface an error the upstream tolerated. Both costs have been paid; both turn out to be small. The benefit is that everything load-bearing on the site is code anyone can read, run, modify, and learn from.
The Playground includes simulators, not just diagnostics
The natural shape for an accessibility tool is finds bugs and lists them. The Playground does that — the Paradise analysers report what they detect, with confidence levels, suggested fixes, and links into the analyser documentation. But the Playground also includes three things that are not diagnostic at all: a virtual screen reader that walks the rendered page the way NVDA, JAWS, or VoiceOver would; a switch-access simulator with single-switch auto-scan and dual-switch step-scan modes; and a session recorder/replayer that captures a screen-reader walk for replay later.
Those are present because diagnosing a bug from a list entry tells a sighted developer that something is wrong; it does not tell them what the experience of using the page is actually like. A switch-access user navigating thirty stops to reach an action a mouse user takes in one click is paying a real cost; the analyser’s warning about keyboard-trap depth does not convey that cost. The simulator does. Sliding the scan-speed slider down to match a real user’s speed makes the cost visible — visceral, even — in a way no diagnostic message can match.
The simulators are deliberately accurate enough to teach and not so accurate as to be a substitute for the real assistive technology. They are scaffolding for empathy and for design judgement, not test instrumentation.
Living code, not screenshots
Everywhere on the site that running code would teach better than a static description, the running code is what landed. /playgrounds/action-language has four worked examples that execute in the browser against an in-page TypeScript port of the original doctoral Action Language execution engine; visitors can edit the XML and re-run, watch the action tree re-parse live, and step through the structured execution trace. /playgrounds/paradiseruns the Paradise analyser engine in-browser and re-analyses the visitor’s code on every keystroke. The simulators referenced above operate against a sandboxed render of the user’s own buffers.
The cost of running code is real: a heavier JavaScript bundle, a need to handle the failure modes that code carries, accessibility care over interactive surfaces that prose pages do not require. The benefit is that the artefacts demonstrate themselves. The reader does not have to take the page’s word for what the engine does; they can run it and see.
The boundary is honest. Where the site explains a decision or articulates a position, prose does the work and code is referenced from text. Where the site demonstrates a working artefact, the artefact is the thing on the page. Decision pages are not dressed up as code playgrounds; code playgrounds are not buried under decision prose.
CodeMirror 6 across both code-editor surfaces
The site has two code-editor surfaces: the analyser Playground at /playgrounds/paradise with HTML, JavaScript, and CSS buffers, and the Action Language playground at /playgrounds/action-language with XML. Both use CodeMirror 6.
The analyser Playground was originally built on Monaco, the editor that powers VS Code. Monaco is an extraordinary piece of engineering — multi-cursor editing, IntelliSense, deep semantic tokenisation, language-server protocol support — and on most accessibility metrics it is fine. On several criteria the site targets, it is not. Monaco binds Tab to indent by default, which traps keyboard-only users inside the editor; the escape is Ctrl+M, which the user must know in advance. WCAG 2.1.2 (No Keyboard Trap) is hard to claim under that condition. Monaco’s built-in themes cap at AA contrast (4.5:1 for normal text) rather than the 7:1 the site targets across the board, and the editor controls its own internal styling deeply enough that user stylesheet overrides for foreground and background — which AAA criterion 1.4.8 requires — cannot reliably take effect.
CodeMirror 6 is smaller, more modular, and accessibility-friendly by default. Tabmoves focus out of the editor without special configuration; the surface is themeable via ordinary CSS rather than internal class systems; the bundle is small enough to ship without code-splitting acrobatics. The trade is loss of Monaco’s richer language-aware features — no IntelliSense suggestions, no multi-cursor, simpler tokenisation. For a Playground whose purpose is to demonstrate accessibility analysis rather than to be a production editor, that trade is clean.
The migration happened in one pass after the second editor surface (the Action Language playground) shipped on CodeMirror 6 and the asymmetry became load-bearing in the colophon. Better to consolidate before the next interactive surface lands than after. The site now ships one editor library across both code-editing surfaces, and both surfaces meet AAA across the criteria a code editor can plausibly meet.
Syntax highlighting by weight and italic, not by colour
Both code editors render syntax highlighting in monochrome. Keywords, function names, type names, numbers, and HTML tag names are bold. Strings, attribute values, regular expressions, and comments are italic. Comments and operators take the muted ink colour; everything else takes the standard ink. No rainbow.
This follows the same logic as the destructive-button variant earlier in the colophon: the site palette is monochrome, so weight and italic do what colour does in conventional sites — convey a token’s syntactic role through a non-chromatic dimension. A user with red-green or blue-yellow colour-vision deficiency reads the highlighted code at the same fidelity as anyone else; a user reading on a high-contrast user stylesheet does not lose the structural cue when the conventional palette is overridden; every token resolves to var(--ink) or var(--ink-muted) over the editor surface, both of which sit at AAA contrast.
The cost is one specific loss compared to a conventional rainbow theme: the eye-catching distinct hue per token categorythat experienced developers learn to scan against. The gain is that the highlighting is universal — works for everyone, in every theme override, on every user stylesheet, without any further intervention. Same trade as everywhere else on the site where colour was tempting but weight earned its place instead.
Depth split across navigable surfaces
The single deepest piece of writing on the site is The Measure of Accessibility, treating what accessibility is, how to measure it, and why the answer matters. It runs to roughly nine thousand words. It is not a single page.
The collection is six pages plus an index. Each page stands alone — The Question, Functional Accessibility, Intrinsic Accessibility, Equivalent Experience, the Shlaer-Mellor lens, Communities of Practice. A reader who wants only the political framing can stop after page 1; a reader who wants the formal definition can stop after page 3; a reader who wants the whole position reads all six. The same approach shapes the Spotlight projects: three pages with a shared six-part structure (person, constraint, insight, artefact, teaching, coda) so each project is its own short essay rather than a section of a longer one.
The single-page alternative is briefly tempting: one monolithic chapter that reads end-to-end without navigation. The cost is that the reader cannot enter the position partway through, cannot share a specific argument as a link, and cannot read the formal treatment without committing to the whole arc. Splitting into navigable surfaces preserves the linear read for those who want it and gives every other reader a meaningful entry point.
Family privacy: relational nouns by default
Several of the technical projects on the site were built for specific named family members — cousin, mother, father — whose stories are part of the record. On the public surfaces those people are referred to with relational nouns only: my cousin, my mother, my father. They are not named.
The convention is deliberate. The story of who the tools were for is part of why the tools exist; that is worth telling. Each named person is a separate consent question, and consent for being mentioned in a private telling is not consent for being on a public website indefinitely. Defaulting to relational nouns tells the story without making the call on someone else’s behalf.
The husband, Taodi, is named freely because he is already public elsewhere. Friends in Singapore appear as a community rather than as individuals for the same reason: the story is theirs as much as the writer’s. If a named individual signs off on public mention, the relevant page can be updated; the default is silence.
Voice: direct, present tense, no provenance scaffolding
The research-and-position writing on this site is written to the reader, in the present tense, without the scaffolding that long-arc personal research typically accumulates. There is no “in my doctoral work I argued…” and no “the chapter on X says…” weighing down the prose. The position is stated; the argument is made; the reader is the audience.
The convention emerged during the drafting of The Measure of Accessibility. The early drafts referred back constantly — the opening line of the chapter is, this page expands the same material as. The reader already knows whose research the writing is from; the site says so once at the entry points and trusts that once is enough. Saying it on every page is noise.
The exception is verbatim quotation. Where a passage from an original chapter or a published paper carries the prose better than a paraphrase would, it is quoted as a quote — without the “from the chapter:” setup line that pretends the reader needs to be told it is a quotation. The italics and the blockquote markup do that work; the prose carries the substance.
Decision log
Each load-bearing decision has its own document under docs/decisions/ in the repository. Summaries follow; click through for the full rationale.
- 0001 — Initial stack
Next.js 16 (App Router) on Node 20, TypeScript, npm. MongoDB for content, OpenSearch (Apache Lucene) for search. Vanilla CSS in @layer groups — no preprocessor, no Tailwind, no CSS Modules.
- 0002 — OpenSearch index design
Three indexes (reviews, glossary, articles) with a custom English analyzer, multi-field text/keyword mappings, drop-and-recreate semantics. Glossary-derived synonym expansion deferred until the search UI exists.
- 0003 — Articles schema and versioning
Two collections: articles (with a status field for draft/published) and article_versions linked by articleId. Title and summary snapshotted on both for cheap rendering. Domains as an array — accessibility crosses ontologies.
- 0004 — Design system principles
Seven principles: AAA contrast as the floor, typography as the primary UI, all prefers-* honoured, focus appearance meeting WCAG 2.4.11, native HTML first, rich JS done accessibly with progressive enhancement, reading mode for long-form articles. Layout foundation: Every Layout by Heydon Pickering and Andy Bell.
- 0005 — Zonal surface tinting
Subtle BridgePoint-style tinting per main-nav landing page. Ten zones, each at perceptually-identical OKLCH lightness so body-text contrast holds across every hue. Sub-pages inherit their landing's zone via a section layout. Initial four-zone version (2026-05-05) collapsed multiple landings into one colour; amended to eleven (2026-05-15) so each main-nav surface reads as its own; the Talks zone was temporarily withdrawn 2026-05-16 alongside its page.
- 0006 — Type scale capped at 3:1
Modular scale with ratio 1.2 (minor third) chosen so that the largest and smallest text on any page differ by no more than 3:1 — ensuring screen-magnifier users do not have to adjust zoom when moving between headings and body text.
- 0007 — Tiered relevance scoring for full-text search
Three should-clauses with descending boosts (phrase 10×, all-terms 4×, any-term 1×) so that exact phrase matches outrank partial matches by score, not by filtering. The match-strength badge surfaces the tier verbatim so assistive technology can convey it.
- 0008 — Trial deployment to OVH VPS
First end-to-end deploy: a single OVHcloud VPS at Gravelines running self-hosted MongoDB, OpenSearch, Next.js under pm2, and Caddy as the reverse proxy. IP-only trial — DNS, TLS, and managed-Mongo cutover deferred. Three things that broke and how they resolved are recorded for next time.
Source and licence
The full source is at github.com/bobdodd/a11ybob-website, released under the GNU GPL v3. Issues and pull requests welcome.
The writing on this site is licensed under Creative Commons BY-SA 4.0. Citation and reuse are welcome; please credit and link back.
Where AI fits in the build and the work
This site is built with AI assistance — as a collaborator on implementation, code structure, and the mechanics of the design system. The intellectual content is mine: the positions, the frameworks, the writing, the design choices. The tooling work that gets those ideas into running code happens in conversation with an AI assistant. That collaboration is disclosed here rather than hidden because the colophon’s purpose is to make the build examinable.
Separately, AI also appears in the substantive accessibility work this site describes. The Dictaphone tool documented at /lived-user-testing and the proof-of-concept analyses at /automated-testing use multimodal LLMs for accessibility analysis on behalf of CNIB Access Labs and Bob’s own research lines. That work is the subject of several pages and is covered there in detail.
The accessibility industry’s relationship with AI right now is uneasy. The fair criticism is real — there are companies extracting practitioner expertise into models without credit or compensation. The position on this site is to use the tools for what they’re genuinely useful for (translation between idea and code; surfacing issues humans don’t notice on their first pass) while keeping the intellectual work and the judgement-of-quality with the human whose name is on the work. This colophon is part of that judgement made visible.
Acknowledgements
Heydon Pickering and Andy Bell, for Every Layout, which made the layout decisions of this site mostly settled before they had to be made. The Braille Institute, for releasing Atkinson Hyperlegible. Adobe and Frank Grießhammer, for releasing Source Serif 4. The W3C Accessibility Guidelines Working Group and the WAI for the standards the site is built against.