Technical Documentation: LadybirdBrowser/ladybird
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (README.md)
Ladybird
Ladybird is a truly independent web browser, using a novel engine based on web standards.
[!IMPORTANT]
Ladybird is in a pre-alpha state, and only suitable for use by developers
Features
We aim to build a complete, usable browser for the modern web.
Ladybird uses a multi-process architecture with a main UI process, several WebContent renderer processes,
an ImageDecoder process, and a RequestServer process.
Image decoding and network connections are done out of process to be more robust against malicious content.
Each tab has its own renderer process, which is sandboxed from the rest of the system.
At the moment, many core library support components are inherited from SerenityOS:
- LibWeb: Web rendering engine
- LibJS: JavaScript engine
- LibWasm: WebAssembly implementation
- LibCrypto/LibTLS: Cryptography primitives and Transport Layer Security
- LibHTTP: HTTP/1.1 client
- LibGfx: 2D Graphics Library, Image Decoding and Rendering
- LibUnicode: Unicode and locale support
- LibMedia: Audio and video playback
- LibCore: Event loop, OS abstraction layer
- LibIPC: Inter-process communication
How do I build and run this?
See build instructions for information on how to build Ladybird.
Ladybird runs on Linux, macOS, Windows (with WSL2), and many other *Nixes.
How do I read the documentation?
Code-related documentation can be found in the documentation folder.
Get in touch and participate!
Join our Discord server to participate in issue and development discussions.
Please read Getting involved with Ladybird if you're new to Ladybird and want to help.
Before opening an issue, please see the issue policy and the detailed issue-reporting guidelines.
The project participation guidelines can be found in CONTRIBUTING.md.
License
Ladybird is licensed under a 2-clause BSD license.
Documentation README (`Documentation/README.md`)
Ladybird Documentation
Ladybird development moves quickly, so some of these might be out of date. Please let us know if something here is wrong.
If you have any questions that are not answered here or in the FAQ, you are welcome to ask on Discord.
Building and Running
Configuring Editors
Development
- Project Participation
- Code Policy
- Coding Style
- Common Patterns
- Guidelines for Text in UI
- EventLoop
- Smart Pointers
- String Formatting
Browser/LibWeb
- General Architecture
- Omnibox Design
- LibWeb: From Loading to Painting
- LibWeb: Browsing Contexts and Navigables
- How to Add a CSS Property
- How to Add an IDL File
- LibWeb Code Style & Patterns
- Investigating Websites with WebDriver
- CSS Generated Files
- Media Pipeline Design
Documentation AddNewIDLFile (`Documentation/AddNewIDLFile.md`)
Adding a new IDL file
Ladybird's build system does a lot of work of turning the IDL from a Web spec into code, but there are a few things you'll need to do yourself.
For the sake of example, let's say you're wanting to add the HTMLDetailsElement.
- Create
LibWeb/HTML/HTMLDetailsElement.idlwith the contents of the IDL section of the spec. In this case, that would be:
[Exposed=Window]
interface HTMLDetailsElement : HTMLElement {
[HTMLConstructor] constructor();
[CEReactions] attribute boolean open;
};Add the IDL file to
LibWeb/idl_files.cmake.Forward declare the generated class in
LibWeb/Forward.h:HTMLDetailsElementin its namespace.
Documentation AdvancedBuildInstructions (`Documentation/AdvancedBuildInstructions.md`)
Advanced Build Instructions
This file covers a few advanced scenarios that go beyond what the basic build guide provides.
Ninja build targets
The Meta/ladybird.py script provides an abstraction over the build targets which are made available by CMake. The
following build targets cannot be accessed through the script and have to be used directly by changing the current
directory to Build/release and then running ninja <target>:
ninja check-style: Runs the same linters the CI does to verify project style on changed filesninja lint-shell-scripts: Checks style of shell scripts in the source tree with shellcheckninja all_generated: Builds all generated code. Useful for running analysis tools that can use compile_commands.json without a full system build
When testing is enabled, the default build also compiles the main Rust
workspace's test binaries and Flap's test targets on supported platforms.
It does not run them. Use ninja rust-tests to compile just these Rust tests,
or ctest -R '^(RustWorkspace|Flap)$' --output-on-failure to run the suites.
CMake build options
There are some optional features that can be enabled during compilation that are intended to help with specific types of development work or introduce experimental features. Currently, the following build options are available:
ENABLE_ADDRESS_SANITIZER: builds in runtime checks for memory corruption bugs (like buffer overflows and memory leaks) in Lagom test cases.ENABLE_MEMORY_SANITIZER: enables runtime checks for uninitialized memory accesses in Lagom test cases.ENABLE_UNDEFINED_SANITIZER: builds in runtime checks for undefined behavior (like null pointer dereferences and signed integer overflows) in Lagom and Ladybird.UNDEFINED_BEHAVIOR_IS_FATAL: makes all undefined behavior sanitizer errors non-recoverable. This option reduces the performance overhead ofENABLE_UNDEFINED_SANITIZER.ENABLE_COMPILER_EXPLORER_BUILD: Skip building non-library entities in Lagom (this only applies to Lagom).ENABLE_FUZZERS: builds fuzzers for various parts of the system.ENABLE_FUZZERS_LIBFUZZER: builds Clang libFuzzer-based fuzzers for various parts of the system.ENABLE_FUZZERS_OSSFUZZ: builds OSS-Fuzz compatible fuzzers for various parts of the system.ENABLE_ALL_THE_DEBUG_MACROS: used for checking whether debug code compiles on CI. This should not be set normally, as it clutters the console output and makes the system run very slowly. Instead, enable only the needed debug macros, as described below.ENABLE_COMPILETIME_FORMAT_CHECK: checks for the validity ofstd::format-style format string during compilation. Enabled by default.ENABLE_JS_JIT: builds the optimizing JIT for JavaScript. Enabled by default on x86-64 and AArch64 Linux and macOS, the platforms it generates code for. Without it, LibJS runs JavaScript in its interpreter alone, and the interpreter is built without the feedback collection and tier-up checks that only the JIT needs. See the configuration of the JIT.INCLUDE_WASM_SPEC_TESTS: downloads and includes the WebAssembly spec testsuite tests. In order to use this option, you will need to installprettierandwasm-tools.INCLUDE_FLAC_SPEC_TESTS: downloads and includes the xiph.org FLAC test suite.LADYBIRD_CACHE_DIR: sets the location of a shared cache of downloaded files. Should not need to be set manually unless managing a distribution package.ENABLE_NETWORK_DOWNLOADS: allows downloading files from the internet during the build. Default on, turning off enables offline builds. For offline builds, the structure of the LADYBIRD_CACHE_DIR must be set up the way that the build expects.ENABLE_CLANG_PLUGINS: enables Clang plugins which analyze the code for programming mistakes. See Clang Plugins below.
Many parts of the codebase have debug functionality, mostly consisting of additional messages printed to the debug console. This is done via the <component_name>_DEBUG macros, which can be enabled individually at build time. They are listed in this file.
To toggle or change a build option, see the CMake Cache Manipulation section below.
CMake Cache Manipulation
CMake caches variables and options in the binary directory. This allows a developer to tailor variables that are set() within the persistent configuration cache.
There are three main ways to manipulate the cache:
cmake path/to/binary/dir -DVAR_NAME=Valueccmake(TUI interface)cmake-gui
Options can be set via the initial cmake invocation that creates the binary directory to set the initial cache for the binary directory.
Once the binary directory exists, any of the three options above can be used to change the value of cache variables.
For example, boolean options such as ENABLE_<setting> or <component_name>_DEBUG can be enabled with the value ON and disabled with OFF:
# Reconfigure an existing binary directory with process debug enabled
$ cmake -B Build/ladybird -DPROCESS_DEBUG=ONFor more information on how the CMake cache works, see the CMake guide for Running CMake. Additional context is available in the CMake documentation for
variables and set().
Tests
For information on running host and target tests, see Testing. The documentation there also contains useful information for debugging CI test failures.
Clang-format updates
Some OS distributions don't ship bleeding-edge clang-format binaries. Below are 2 options to acquire an updated clang-format tool, in order of preference:
- If you have a Debian-based (apt-based) distribution, use the LLVM apt repositories to install the latest release of clang-format.
- Compile LLVM from source as described in the LLVM documentation here.
Clangd Configuration
The repository has a .clangd configuration file in the root directory. One of the configuration stanzas in that file
specifies the location for a compilation database. Depending on your build configuration (e.g. Release, Debug, Sanitizer),
the path to the compilation database in that file may not be correct. You may edit the .clangd file to point at your
build directory.
Clang Plugins
Clang plugins are used to validate the code at compile time. Currently, they are used to detect JavaScript-related
garbage collection faux pas, such as neglecting to visit a garbage-collected type.
In order to enable Clang plugins, you will need Clang's development headers installed. For example, on Ubuntu this is
the libclang-dev package.
When Clang plugins are enabled, it is recommended to have the following environment variable set for ccache:
export CCACHE_COMPILERCHECK="%compiler% -v"By default, ccache will include the plugins themselves in file hashes. So if a plugin changes, the hash of every file
will change, and you will be stuck with an uncached build. This setting will prevent ccache from using plugins in the
file hashes.
Building the Flatpak
Ladybird has an in-tree Flatpak manifest that can be used to build a Flatpak package for Ladybird. The recommended way
to build the Flatpak is to use the flatpak-builder tool, which is available in most distributions. See the
Flatpak setup documentation on how to configure your environment for user Flatpak
builds, and to configure the Flathub repo. The Ladybird Flatpak manifest atMeta/CMake/flatpak/org.ladybird.Ladybird.json can be built with the following command:
flatpak-builder --user --force-clean --install-deps-from=flathub \
--ccache --repo=Build/repo --install Build/flatpak \
Meta/CMake/flatpak/org.ladybird.Ladybird.jsonThis command will build the Flatpak bundle and install it into the local Flatpak repository at Build/repo. Expect this
to take a long time, as it will download and build all the dependencies of Ladybird as well. flatpak-builder will drop
caches and build files in .flatpak-builder, as well as in the two subfolders Build/repo and Build/flatpak.
The Flatpak can be run with the following command:
flatpak run --user org.ladybird.LadybirdIn order to debug the Flatpak, you can pass the --devel and --command=sh flags to flatpak run. This will drop you
into a shell in the Flatpak sandbox:
flatpak run --user --command=sh --devel org.ladybird.LadybirdDebugging without any optimizations
It’s possible that when trying to inspect certain frame variables in your debugger, you’ll get an error similar to the following:
error: Couldn't look up symbols: __ZN2AK6Detail10StringBaseD2Ev
Hint: The expression tried to call a function that is not present in the target, perhaps because it was optimized out by the compiler.
If you do run into such an error, the rest of this section explains how to deal with it.
[!WARNING]
You probably only want to make the build-file change described below while you’re in the middle of examining the state of a particular build in your debugger — and then you’ll want to revert it after you’re done debugging. You otherwise probably don’t want to have the build-file change in place while you’re running WPT tests or in-tree tests and checking the results.
At your command-line prompt in your shell environment, copy and paste the following:
diff$ patch -p1 <<EOF diff --git a/Meta/CMake/compile_options.cmake b/Meta/CMake/compile_options.cmake index 0bb2be833b6..modified 100644 --- a/Meta/CMake/compile_options.cmake +++ b/Meta/CMake/compile_options.cmake @@ -235,7 +235,7 @@ if (CMAKE_BUILD_TYPE STREQUAL "Debug") if (NOT MSVC) add_cxx_compile_options(-ggdb3) - add_cxx_compile_options(-Og) + add_cxx_compile_options(-O0) endif() elseif (CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo") if (NOT MSVC) EOF…or else copy and paste that patch, and apply it in whatever way you normally use for applying patches.
That will patch the build config in such a way as to disable all compiler optimizations and make all debug symbols always available.
At your command-line prompt in your shell environment, run the following command:
textgit update-index --skip-worktree Meta/CMake/compile_options.cmakeThat will cause git to ignore the change you made to that build file. Otherwise, if you don’t run that command, git will consider that build file to have been modified, and you might then end up inadvertently committing the changes to that build file as part of some actual code change you’re making to the sources that you’re in the process of debugging.
Run a build again with the
Debugpreset, and then go back into your debugger. You’ll now be able to inspect any frame variable that you weren’t able to previously.
After you’ve finished debugging your code changes with that build, you can revert the above changes by doing this:
At your command-line prompt in your shell environment, run the following:
textgit update-index --no-skip-worktree Meta/CMake/compile_options.cmake \ && git checkout Meta/CMake/compile_options.cmake
That will restore your git environment to the state it was in before you patched the build file.
Debugging with Vulkan Validation Layers
To turn on Vulkan validation layers for the release build, for example, use:
cmake -B Build/release -DVULKAN_VALIDATION_LAYERS_DEBUG=ONand then build normally.
When running Ladybird the message Vulkan validation layers: active will be visible during the creation of the VulkanContext if the Vulkan validation layers are properly setup.
If instead Vulkan validation layers: not available appears then the system is most likely missing the validation layers. On Ubuntu adding the package vulkan-validationlayers will add them in, other distros may also have a package but the name could be different (for example, on Arch, Fedora and NixOS the name is vulkan-validation-layers.
Another way to get the validation layers is to use the Vulkan SDK, for more info see:
https://github.com/KhronosGroup/Vulkan-ValidationLayers
If absolutely no output related to the Vulkan validation layers is received, then the initial cmake command in this section was not performed or the VulkanContext was not created successfully or was not created at all.
Documentation BrowsingContextsAndNavigables (`Documentation/BrowsingContextsAndNavigables.md`)
LibWeb: Browsing Contexts and Navigables
NOTE: This document is a work in progress!
Introduction: How does code execute, really?
Before we can dive into how LibWeb and Ladybird implement the HTML web page navigation operations,
we need to dive into some fundamental specification concepts. Starting with, how does code actually
execute in a (possibly virtual) machine? Next we'll look at what that means for the ECMAScript
Specification (JavaScript), and finally how the ECMAScript code execution model ties into the
HTML specification to model how to display web content into a browser tab.
Native Code Execution: A Primer
When modeling the execution of a native program written in a popular systems language like
C, C++, or Rust, most systems programmers should be familiar with the concepts of threads
and processes. In a "hosted" environment, the execution of one's userspace program generally
starts with an underlying operating system creating a process for the application to run in.
This process will contain a memory space for program data and code to live in, and an initial,
or main thread to start execution on. In order for the operating system to change which
thread is executing on a particular CPU core, it needs to save and restore the Execution Context
for that thread. The Execution Context for a native thread generally consists of a set of
CPU registers, any floating point state, a program counter that tracks which instruction should
be loaded next, and a stack pointer that points to the local data the thread was using to track
its function call stack and local variables. The programmer can also request additional threads
through a system call, providing a set of thread attributes and a function to call as the entry
point.
For traditional compiled programs, the concept of accessing variables and functions is split into
two phases. At compile time, local variables and arguments are folded into stack slots and
allocated into registers. Exported variables and functions are written into the executable object
file (ELF, Mach-O, PE, etc.) and are visible to external tools as symbols, as referenced by a
symbol table contained within the object file format. Normally local variable and argument
names and locations are lost in the compile+link steps, but the compiler can be configured to
emit extra debug information to allow debuggers to access and modify them at runtime. In order
to support something like the dynamic imports of interpreted languages, the programmer has to
call a platform-specific function to load the new module (e.g. dlopen or LoadLibrary).
But after the module is opened, in order to actually refer to any exported symbols from that module the
programmer has to retrieve the address of each symbol through another platform specific function
(e.g. dlsym or GetProcAddress), once per symbol.
ECMAScript Execution Model: Realms and Agents
The ECMAScript specification has analogs for almost all of these concepts in the section on
Executable Code and Execution Contexts.
Working in the other direction from the native code explanation, ECMAScript describes the accessibility
and scopes of functions, variables, and arguments in terms of Environment Records.
Note that these Environment Records are not actually visible to executing code, and are simply a mechanism
used by the specification authors to model the language. Every function and module has a type
of Environment Record that contains the variables, functions, catch clause bindings, and other
language constructs that affect which names are visible at any location in the code. These Environment Records
are nested, in a tree-like structure that somewhat matches the Abstract Syntax Tree (AST).
The root of the tree of Environment Records is the Global Environment Record, which corresponds to the
Global Object and its properties. In JavaScript, there is always a this value representing the current
object context. At global scope, the Global Object normally takes that responsibility. In a REPL, that might
be some REPL specific global object that has global functions to call for doing things like loading
from the filesystem, or even be as complex as Node or Bun. In a Browser context, the Global object is
normally the Window, unless there's a Worker involved. For historical reasons the global this binding for
Window contexts is actually a WindowProxy that wraps the Window. This concept is quite different from a native
executable, where there's no actual object representing the global scope, simply symbols that the
linker and loader make available to each module.
While the Global Object and its Global Environment represent the root of the tree of identifiers visible
to the executing JavaScript code, the Global Object isn't sufficient to model all the state around
a conceptual thread of execution in ECMAScript. This is where the two concepts of Realms
and Execution Contexts come into play.
A Realm Record is a container that holds a global object,
its associated Global Environment, a set of intrinsic objects, and any host (also called an embedder
in some specification documents) defined extra state that needs to be associated with the realm.
In LibWeb, the Host Defined slot holds an object that has the HTML Environment Settings Object for each realm,
as well as all the prototypes, constructors, and namespaces that need to be exposed on the Global Object
for Web APIs. On top of the Realm abstraction, ECMAScript uses the Execution Context to model the state
of execution of one particular script or module. Each Execution Context belongs to an execution context stack
with the topmost context named the running execution context.
An Execution Context has information about the current function, the script or module that the current code block belongs to,
additional Environment Records required to access names in the current scope, any running generator state,
and most importantly to the thread analogy, the state needed to suspend and resume execution of that script.
As with Environment Records, Realms and Execution contexts are not directly accessible to running JavaScript code.
The final missing piece for the JavaScript execution model is how these stacks of Execution Contexts
are actually scheduled to run by the ECMAScript implementation. In the most common case, this means directly
mapping the ECMAScript model to the earlier native concepts of threads and processes in a way that
allows for flexibility in the implementation strategies. The last thing that the specification authors want
to do is constrain implementations so much that innovation and experimentation becomes impossible.
The method for this mapping is the two related specification mechanisms Agents
and Agent Clusters. The Execution Context stack mentioned
above actually belongs to an Agent, which holds said stack, a set of metadata about the memory model,
and a shared reference to an executing thread.
According to ECMAScript, there should always be at least one Execution Context on the stack, to allow concepts
such as the running execution context to always refer to the topmost Execution Context of the surrounding agent.
However, the HTML specification opts to remove the default execution context from the execution context stack
at creation, and instead manually pushes and pops execution contexts for script, module, and callback execution.
The relationship between Realms and Agents is not 1-1, but N-1. In the ECMAScript specification, this manifests
as a part of the Shadow Realm proposal, while the Web platform
requires multiple Realms per Agent to specify the historical behavior of ```` and related elements.
An Agent holds a stack of Execution Contexts, with the topmost entry being the running execution context.
Each Execution Context holds a Realm and a specific script's context, including the current function and
any state required to pause and resume the execution for that context. The Realm holds the Global
Object for the Execution Context, and any ECMAScript or host-specific intrinsics required to create the
desired environment for code to run in. More loosely, an Agent is a specification artifact that somewhat
maps the execution of a JavaScript script or module to a native thread of execution. But the specification
does so in a way that allows a host/embedder to choose to switch out which Agent is currently executing
its running execution context on that native thread, and which Realm within that Agent owns the running execution
context.
SharedArrayBuffers and Atomics add a special kind of wrinkle to the ECMAScript specification. Defining
how that work required the formalization of a memory model, similar to what C++11 and C11 and Java 5 had
to do before them. The Agent Cluster is the formalism that ties the memory model back to the execution
model. As described in the specification, an Agent Cluster is a set of Agents that can communicate
via shared memory. The exact mechanism is unspecified, but the hard rule is that all Agents within
a particular Agent Cluster must observe the same order of reads and writes to SharedArrayBuffers and
as a result of ECMAScript Atomic objects.
The net result of all this memory model and atomic specification language is that loosely, an Agent models
a "candidate execution" of some code module that can execute on a thread, and any suspended execution
contexts from things like generators or async that are part of that module and its dynamic imports.
An Agent Cluster models the interaction of agents that share the ability to communicate via shared memory.
The simplest reading of this is that the specification authors had in mind the type of memory sharing
that threads within the same process have in native code execution. So an Agent Cluster loosely models
a collection of Agents (read: threads) that execute independently of each other within the same implementation
defined manner for sharing memory between different threads (read: process).
HTML Execution Model: Global Scopes
The Document Object Model (DOM) specifications are written in such a way that implementers can
create language bindings for any language to access the page. However, experience has shown that the
most popular way to script web content in modern web browsers is through JavaScript bindings. As such,
the HTML specification is specifically tailored to meet the constraints of JavaScript execution in its
scripting APIs and related concepts. Great care is taken to ensure that JavaScript written by different
authors cannot interfere with each other, and that arbitrary scripts cannot exfiltrate information about
the page content to third-party destinations.
The HTML specification therefore has a section on Agents and Agent Clusters
at the start of the section on how scripting behaves on the Web platform.
TODO: Finish this section
HTML Navigation: Juggling Origins
Global Scopes, Browsing Contexts, Browsing Context Groups, Navigables, and Traversable Navigables
TODO:
- Agents defined by the HTML Spec
- Global Objects (Global Scopes) defined by the HTML Spec
- Agents and Browsing Context Groups
- Navigables and their relationship to Browsing Contexts
- Walk through construction of a browser tab, its traversable navigable, and its navigation both same and
cross-origin - Walk through construction of a browser tab with a nested browsing context and what happens when the
nested context within its navigable container navigates on its own
Documentation BuildInstructionsLadybird (`Documentation/BuildInstructionsLadybird.md`)
Ladybird browser build instructions
Build Prerequisites
Qt6.10+ development packages, nasm, additional build tools, and a C++23 capable compiler are required.
[!NOTE]
Some distributions still package a Qt6 older than 6.10; for example, Debian 13 (trixie) ships Qt 6.8 — so configuring against it fails. If your Qt6 is older than 6.10, install a newer one from a newer distribution release, or directly from the Qt online installer, and then point the build to it usingCMAKE_PREFIX_PATH.
A Rust toolchain is also required. You can install it via rustup.
We currently use gcc-14 and clang-21 in our CI pipeline. If these versions are not available on your system, seefind_compiler.py for the minimum compatible version.
CMake 3.30 or newer must be available in $PATH.
Debian/Ubuntu:
sudo apt install autoconf autoconf-archive automake build-essential ccache cmake curl fonts-liberation2 git glslang-tools libdrm-dev libgl1-mesa-dev libncurses-dev libpulse-dev libtool libxcb-icccm4-dev nasm ninja-build pkg-config python3-venv qt6-base-private-dev qt6-positioning-dev qt6-tools-dev-tools qt6-wayland tar unzip zipCMake 3.30 or newer:
- Recommendation: Install
CMake 3.30or newer from Kitware's apt repository:
[!NOTE]
Kitware’s apt repository is Ubuntu-only.
# Add Kitware GPG signing key
wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc 2>/dev/null | gpg --dearmor - | sudo tee /usr/share/keyrings/kitware-archive-keyring.gpg >/dev/null
# Optional: Verify the GPG key manually
# Use the key to authorize an entry for apt.kitware.com in apt sources list
echo "deb [signed-by=/usr/share/keyrings/kitware-archive-keyring.gpg] https://apt.kitware.com/ubuntu/ $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/kitware.list
# Update apt package list and install cmake
sudo apt update -y && sudo apt install cmake -yC++23-capable compiler:
- Recommendation: Install clang from LLVM's apt repository:
# Add LLVM GPG signing key
sudo wget -O /usr/share/keyrings/llvm-snapshot.gpg.key https://apt.llvm.org/llvm-snapshot.gpg.key
# Optional: Verify the GPG key manually
# Use the key to authorize an entry for apt.llvm.org in apt sources list
echo "deb [signed-by=/usr/share/keyrings/llvm-snapshot.gpg.key] https://apt.llvm.org/$(lsb_release -sc)/ llvm-toolchain-$(lsb_release -sc)-21 main" | sudo tee -a /etc/apt/sources.list.d/llvm.list
# Update apt package list and install clang and associated packages
sudo apt update -y && sudo apt install clang-21 clangd-21 clang-tools-21 clang-format-21 clang-tidy-21 lld-21 -y- Alternative: Install gcc from Ubuntu Toolchain PPA:
sudo add-apt-repository ppa:ubuntu-toolchain-r/test
sudo apt update && sudo apt install g++-14 libstdc++-14-devArch Linux/Manjaro:
sudo pacman -S --needed autoconf-archive base-devel ccache cmake curl git less libgl libpulse nasm ninja python qt6-base qt6-positioning qt6-tools ttf-liberation tar unzip xcb-util-wm zipFedora or derivatives:
sudo dnf install autoconf-archive automake ccache cmake curl git glslang libdrm-devel liberation-sans-fonts libglvnd-devel libtool nasm ncurses-devel ninja-build patchelf perl-FindBin perl-IPC-Cmd perl-lib perl-Time-Piece pulseaudio-libs-devel qt6-qtbase-private-devel qt6-qtpositioning-devel qt6-qttools-devel qt6-qtwayland-devel tar unzip xcb-util-wm-devel zip zlib-ng-compat-staticopenSUSE:
sudo zypper install autoconf-archive automake ccache cmake curl gcc14 gcc14-c++ git liberation-fonts libglvnd-devel libpulse-devel libtool nasm ncurses-devel ninja qt6-base-private-devel qt6-positioning-devel qt6-tools-devel qt6-wayland-devel tar unzip xcb-util-wm-devel zipIf one or more of the base repository packages are flagged as having an out-of-date version during the build process, you may need add the devel:tools:building repository. For example, on Leap 15.6, the autoconf package might be version 2.69, whereas the gperf package requires 2.70 to build.
For Leap 15.6, run the following command to add this repo (you will need to adjust the URL for other openSUSE versions):
sudo zypper addrepo https://download.opensuse.org/repositories/devel:tools:building/15.6/devel:tools:building.repo
sudo zypper refreshBelow is an example printout that might be received depending which versions of autoconf are available for installation:
> sudo zypper install autoconf
Loading repository data...
Reading installed packages...
'autoconf' is already installed.
There is an update candidate for 'autoconf' from vendor 'obs://build.opensuse.org/devel:tools', while the current vendor is 'SUSE LLC <https://www.suse.com/>'. Use 'zypper install autoconf-2.72-80.d_t_b.1.noarch' to install this candidate.
Resolving package dependencies...
Nothing to do.
> sudo zypper install autoconf-2.72-80.d_t_b.1.noarchThe build process requires at least python3.7; openSUSE Leap only features Python 3.6 as default, so it is recommendable to install the package python312 and create a virtual environment (venv) in this case.
A virtual environment can be created in your home directory and once the source command is issued python3 --version will show that the current version is python 3.12 within the virtual environment shell session.
python3.12 -m venv ~/python312_venv
source ~/python312_venv/bin/activate
python3 --versionThis virtual environment can be created once and reused in future shell sessions by sourcing the generated file ~/python312_venv/bin/activate again.
Void Linux:
sudo xbps-install -Su # (optional) ensure packages are up to date to avoid "Transaction aborted due to unresolved dependencies."
sudo xbps-install -S git bash gcc python3 curl cmake libtool zip unzip linux-headers make pkg-config autoconf automake autoconf-archive nasm ncurses-devel MesaLib-devel ninja pulseaudio-devel qt6-base-private-devel qt6-position-devel qt6-tools-devel qt6-wayland-devel xcb-util-wm-develNixOS or with Nix:
A Nix development shell is maintained here,
in the nix-environments repository. If you encounter any problems
building with Nix, please create an issue there.
macOS:
Xcode 15 or clang from homebrew is required to successfully build ladybird.
xcode-select --install
brew install autoconf autoconf-archive automake ccache cmake libtool nasm ninja pkg-config qtIf you wish to use clang from homebrew instead:
brew install llvm@21[!NOTE]
It is recommended to add your terminal application (i.e. Terminal.app or iTerm.app) to the system list of developer tools.
Doing so will reduce slow startup time of freshly compiled binaries, due to macOS validating the binary on its first run.
This can be done in the "Developer Tools" section of the "Privacy & Security" system settings.
Windows:
WSL2 is the supported way to build Ladybird on Windows. A native build is also possible, however it is still experimental,
and there are several issues.
WSL2
Create a WSL2 environment using one of the Linux distros listed above. Ubuntu or Fedora is recommended.
Install the required packages for the selected Linux distro in the WSL2 environment.
WSL1 is known to have issues. If you run into problems, please use WSL2.
MinGW/MSYS2 are not supported.
Clang-CL (experimental)
[!NOTE]
There are still windows specific issues and the functionality is limited.
In order to get pkg-config available for the vcpkg install, you can use Chocolatey to install it.
To install Chocolatey, see https://chocolatey.org/install.
Then Install pkg-config using chocolatey.
choco install pkgconfiglite -yIn a VS command prompt build using ladybird.py:
py Meta\ladybird.py buildAndroid:
On a Unix-like platform, install the prerequisites for that platform and then see the Android Studio guide.
Or, download a version of Gradle >= 8.0.0, and run the gradlew program in UI/Android
FreeBSD
pkg install autoconf-archive automake autoconf bash cmake ccache curl gmake gn libdrm libtool libxcb libxkbcommon libX11 libXrender libXi nasm ninja patchelf pkgconf pulseaudio python3 qt6-base qt6-positioning tar unzip zip[!NOTE]
zip,unzip, andtarare required by the vcpkg bootstrap step. If any of these are missing,
the build will fail with a PythonCalledProcessErrortraceback rather than a clear error message.
Build steps
Using ladybird.py
The simplest way to build and run ladybird is via the ladybird.py script:
# From /path/to/ladybird
./Meta/ladybird.py runOn macOS, to build using clang from homebrew:
CC=$(brew --prefix llvm)/bin/clang CXX=$(brew --prefix llvm)/bin/clang++ ./Meta/ladybird.py runYou may also choose to start it in gdb using:
./Meta/ladybird.py gdb ladybirdThe above commands will build a Release version of Ladybird. To instead build a Debug version, run theMeta/ladybird.py script with the value of the BUILD_PRESET environment variable set to Debug, like this:
BUILD_PRESET=Debug ./Meta/ladybird.py runNote that debug symbols are available in both Release and Debug builds.
If you want to run other applications, such as the JS REPL or the WebAssembly REPL, specify an executable with./Meta/ladybird.py run <executable_name>.
To launch an existing binary without configuring, preparing dependencies, or building, use --no-build:
./Meta/ladybird.py run --no-build
./Meta/ladybird.py run --no-build js --evaluate 'console.log(1 + 1)'
./Meta/ladybird.py run --no-build --preset Debug jsPut --no-build before the executable name; arguments after the name are passed to the executable.
The command uses the selected preset's existing build and retains the usual runtime environment,
including sanitizer options. It does not update the binary or change its build configuration.
The User Interfaces
Ladybird will be built with one of the following browser frontends, depending on the platform:
- Android UI - The native UI on Android.
- Qt - The UI used on all other platforms.
Build error messages you may encounter
The section lists out some particular error messages you may run into, and explains how to deal with them.
Unable to find a build program corresponding to "Ninja"
This error message is a red herring. We use vcpkg to manage our third-party dependencies, and this error is logged when
something went wrong building those dependencies. The output in your terminal will vary depending on what exactly went
wrong, but it should look something like:
error: building skia:x64-linux failed with: BUILD_FAILED
Elapsed time to handle skia:x64-linux: 1.6 s
-- Running vcpkg install - failed
CMake Error at Build/vcpkg/scripts/buildsystems/vcpkg.cmake:899 (message):
vcpkg install failed. See logs for more information:
Build/release/vcpkg-manifest-install.log
Call Stack (most recent call first):
/usr/share/cmake-3.30/Modules/CMakeDetermineSystem.cmake:146 (include)
CMakeLists.txt:15 (project)
CMake Error: CMake was unable to find a build program corresponding to "Ninja". CMAKE_MAKE_PROGRAM is not set. You probably need to select a different build tool.
-- Configuring incomplete, errors occurred! See logs for more information:
Build/release/vcpkg-manifest-install.logIf the error is not immediately clear from the terminal output, be sure to check the specified vcpkg-manifest-install.log.
for more information.
Resource files
Ladybird requires resource files from the ladybird/Base/res directory in order to properly load
icons, fonts, and other theming information. These files are copied into the build directory by
special CMake rules. The expected location of resource files can be tweaked by packagers using
the standard CMAKE_INSTALL_DATADIR variable. CMAKE_INSTALL_DATADIR is expected to be a path relative
to CMAKE_INSTALL_PREFIX. If it is not, things will break.
Custom CMake build directory
The script Meta/ladybird.py and the Release preset in CMakePresets.json both define a build directory ofBuild/release. For distribution purposes, or when building multiple configurations, it may be useful to create a custom
CMake build directory.
The install rules in UI/cmake/InstallRules.cmake define which binaries and libraries will be
installed into the configured CMAKE_PREFIX_PATH or path passed to cmake --install.
Note that when using a custom build directory rather than Meta/ladybird.py, the user may need to provide a suitable C++
compiler (see Build Prerequisites) via the CMAKE_C_COMPILER and
CMAKE_CXX_COMPILER cmake options.
cmake --preset Release -B MyBuildDir
# optionally, add -DCMAKE_CXX_COMPILER=<suitable compiler> -DCMAKE_C_COMPILER=<matching c compiler>
cmake --build --preset Release MyBuildDir
ninja -C MyBuildDir run-ladybirdBuilding with limited system memory
The default build mode will run as many build steps in parallel as possible, which includes link steps;
this may be an issue for users with limited system memory (or users building with fat LTO in general).
If you wish to reduce the number of parallel link jobs, you may use the LAGOM_LINK_POOL_SIZE cmake option
to set a maximum limit for the number of parallel link jobs.
cmake --preset Release -B MyBuildDir -DLAGOM_LINK_POOL_SIZE=2Running manually
The Meta/ladybird.py script will execute the run-ladybird and debug-ladybird custom targets.
If you don't want to use the ladybird.py script to run the application, you can run the following commands:
To automatically run in gdb:
ninja -C Build/release debug-ladybirdTo run without ninja rule on non-macOS systems:
./Build/release/bin/LadybirdTo run without ninja rule on macOS:
open -W --stdout $(tty) --stderr $(tty) ./Build/release/bin/Ladybird.app
# Or to launch with arguments:
open -W --stdout $(tty) --stderr $(tty) ./Build/release/bin/Ladybird.app --args https://ladybird.devDebugging with CLion
Ladybird should be built with debug symbols first. This can be done by adding -DCMAKE_BUILD_TYPE=Debug to the cmake command line,
or selecting the Build Type Debug in the CLion CMake profile.
After running Ladybird as suggested above with ./Meta/ladybird.py run ladybird, you can now in CLion use Run -> Attach to Process to connect. If debugging layout or rendering issues, filter the listing that opens for WebContent and attach to that.
Now breakpoints, stepping and variable inspection will work.
Debugging with Xcode or Instruments on macOS
If all you want to do is use Instruments, then an Xcode project is not required.
Simply run the ladybird.py script as normal with a debug-style build. The build automatically signs the app bundle
with the entitlements from Meta/DebugEntitlements.plist, which includes get-task-allow for debugger and Instruments
attachment.
Now you can open the Instruments app and point it to the Ladybird app bundle.
Building the project with Xcode is not supported. The Xcode project generated by CMake does not properly execute custom
targets, and does not handle all target names in the project.
Documentation BuildProfilingInstructions (`Documentation/BuildProfilingInstructions.md`)
Build profiling instructions
There are three ways you can get information about compile times:
- Just using
time ninja installinstead ofninja installto time it overall. - Reading ninja's log files to get a per-file compile time.
- Enabling GCC or Clang flags to get a more detailed per-file breakdown of times for compiler passes within a file.
Ninja log files
Ninja produces a handy log file that includes a per-cpp-file compilation time. This is useful for discovering which files take the most time, and so are good targets for speeding up the overall build time. A python3 script named ninjatracing converts the ninja log file into a JSON format that's readable by several profile viewers.
Prerequisites
ninjatracing is available on GitHub
It requires python3, available at python. You can either create the symlink yourself, or just modify the ninjatracing file to say python3 instead of python.
You also need to make sure ninjatracing is marked as executable, and available from your PATH (or somewhere convenient where you can manually call it from).
Usage
First, we need to clean the build (and ccache if present) to make sure every file gets compiled and gives a meaningful time reading.
ninja clean
ccache --clearThen, execute ninja as normal:
ninjaThe log will be written to .ninja_log in the current directory by default. To convert it to the more useful JSON file, call ninjatracing from the directory you called ninja from:
ninjatracing .ninja_log > trace.jsonYou can then drag-and-drop the file onto Speedscope or any other compatible flamegraph visualizer.
GCC/Clang
Adding the -ftime-report flag to GCC will cause it to output a breakdown for each file it compiles, which looks like this:
GCC -ftime-report output/* Detailed source-code truncated for AI context efficiency. */Depending on whether you understand the internals of the compiler, this may or may not be helpful to you! Generally, this is not recommended.
Clang also supports -ftime-report, but I have not tested the output for it.
To add the flag, edit the CMakeLists.txt in the ladybird root directory, and add add_compile_options(-ftime-report) to the list of compile options that start around line 220.
Additionally, you can add -ftime-report-details too, which again I have not tested.
Documentation CSSGeneratedFiles (`Documentation/CSSGeneratedFiles.md`)
CSS Generated Files
We generate a significant amount of CSS-related code, taking in one or more .json files inLibraries/LibWeb/CSS and producing C++ code from them, located inBuild//Libraries/LibWeb/CSS/.
It's likely that you'll need to work with these if you add or modify a CSS property or its values.
The generators are found in Meta/Generators.
They are run automatically as part of the build, and most of the time you can ignore them.
Properties.json
Each CSS property has an entry here, which describes what values it accepts, whether it's inherited, and similar data.
This generates PropertyID.h, PropertyID.cpp, GeneratedCSSStyleProperties.h, GeneratedCSSStyleProperties.cpp and GeneratedCSSStyleProperties.idl.
Most of this data is found in the information box for that property in the relevant CSS spec.
The file is organized as a single JSON object, with keys being property names, and the values being the data for that property.
Each property will have some set of these fields on it:
(Note that required fields are not required on properties with legacy-alias-for or logical-alias-for set.)
| Field | Required | Default | Description | Generated functions |
|---|---|---|---|---|
affects-layout |
No | true |
Boolean. Whether changing this property will invalidate the element's layout. | bool property_affects_layout(PropertyID) |
affects-stacking-context |
No | false |
Boolean. Whether this property can cause a new stacking context for the element. | bool property_affects_stacking_context(PropertyID) |
animation-type |
Yes | String. How the property should be animated. Defined by the spec. See below. | AnimationType animation_type_from_longhand_property(PropertyID) |
|
inherited |
Yes | Boolean. Whether the property is inherited by its child elements. Only applicable to longhand properties. | bool is_inherited_property(PropertyID) |
|
initial |
Yes | String. The property's initial value if it is not specified. | NonnullRefPtr<StyleValue const> property_initial_value(PropertyID) |
|
legacy-alias-for |
No | Nothing | String. The name of a property this is an alias for. See below. | |
logical-alias-for |
No | Nothing | An object. See below. | bool property_is_logical_alias(PropertyID);``PropertyID map_logical_alias_to_physical_property(PropertyID, LogicalAliasMappingContext const&) |
longhands |
No | [] |
Array of strings. If this is a shorthand, these are the property names that it expands out into. | Vector longhands_for_shorthand(PropertyID)``Vector expanded_longhands_for_shorthand(PropertyID)``Vector shorthands_for_longhand(PropertyID) |
max-values |
No | 1 |
Integer. How many values can be parsed for this property. eg, margin can have up to 4 values. |
|
multiplicity |
No | "single" |
String. Category for whether this property is a single value or a list of values. See below. | bool property_is_single_valued(PropertyID)``bool property_is_list_valued(PropertyID) |
percentages-resolve-to |
No | Nothing | String. What type percentages get resolved to. eg, for width percentages are resolved to length values. |
Optional<ValueType> property_resolves_percentages_relative_to(PropertyID) |
positional-value-list-shorthand |
No | false |
Boolean. Whether this property is a "positional value list shorthand". See below. | bool property_is_positional_value_list_shorthand(PropertyID) |
quirks |
No | [] |
Array of strings. Some properties have special behavior in "quirks mode", which are listed here. See below. | |
requires-computation |
Yes | String. When a property's value needs to be run through the computation process. See below. | bool property_requires_computation_with_inherited_value(PropertyID)``bool property_requires_computation_with_initial_value(PropertyID)``bool property_requires_computation_with_cascaded_value(PropertyID) |
|
valid-identifiers |
No | [] |
Array of strings. Which keywords the property accepts. See below. | bool property_accepts_keyword(PropertyID, Keyword) |
valid-types |
No | [] |
Array of strings. Which value types the property accepts. See below. | bool property_accepts_type(PropertyID, ValueType) |
needs-layout-for-getcomputedstyle |
No | false |
Boolean. Whether this property requires up-to-date layout before it could be queried by getComputedStyle() | bool property_needs_layout_for_getcomputedstyle(PropertyID) |
`animation-type`
The Web Animations spec defines the valid values here:
| Spec term | JSON value |
|---|---|
| not animatable | none |
| discrete | discrete |
| by computed value | by-computed-value |
| repeatable list | repeatable-list |
| (See prose) | custom |
`legacy-alias-for`
(Not to be confused with logical-alias-for below.)
Legacy name aliases are properties whose spec names have changed,
but the syntax has not, so setting the old one is defined as setting the new one directly.
For example, font-stretch was renamed to font-width, so font-stretch is now a legacy name alias for font-width.
`logical-alias-for`
(Not to be confused with legacy-alias-for above.)
Logical aliases are properties like margin-block-start, which may assign a value to one of several other properties
(margin-top, margin-bottom, margin-left, or margin-right) depending on the element they are applied to.
logical-alias-for should be an object with two fields, both of which are required:
| Field | Description |
|---|---|
group |
String. Name of the logical property group this is associated with. (See LogicalPropertyGroups.json.) |
mapping |
String. How this relates to the group. eg, if it's the block end value, block-end. |
`multiplicity`
The three possible values are "single", "list", and "coordinating-list".
Most properties represent a single "thing", possibly made of multiple parts. For example, border takes a color, style
and thickness, but it's still a single border. Others are a list: background takes multiple layers, separated by
commas; counter-increment doesn't have commas but also is a list of counters that are incremented.
Lists can also be coordinating list properties, in which
case they are marked as coordinating-list instead of list.
`positional-value-list-shorthand`
Some shorthand properties work differently to normal in that mapping of provided values to longhands isn't necessarily
1-to-1 and instead depends on the number of values provided, for example margin, border-width, gap, etc.
These properties have distinct behaviors in how they are parsed and serialized, having them marked allows us to
implement this behavior in a generic way.
`quirks`
The Quirks spec defines these.
| Spec term | JSON value |
|---|---|
| The hashless hex color quirk | hashless-hex-color |
| The unitless length quirk | unitless-length |
`requires-computation`
There are three ways the specified value of a property on an element can be determined:
- Inherited, e.g. it is an inherited property and no value has been specified, or the
inheritkeyword has been
specified - Initial, e.g. it is not an inherited property and no value has been specified, or the
initialkeyword has been
specified - Cascaded, a value has been cascaded for this element based on the styles
We then turn that specified value into a computed value by running the computation (seeStyleComputer::compute_value_of_property).
Depending on the property, we know that for some of the above cases the computed value is guaranteed to be the same as
the specified value, by specifying the cases where it isn't we can limit running the computation process to only when
required.
| Value | Behavior |
|---|---|
always |
Always runs the computation process, regardless of how the specified value was determined |
non-inherited-value |
Only run the computation process if the specified value was determined based on the initial or cascaded value |
cascaded-value |
Only run the computation process if the specified value was determined based on a cascaded value |
never |
Always skip the computation process |
`valid-identifiers`
A list of CSS keyword names, that the property accepts. Consider defining an enum instead and putting its name in thevalid-types array, especially if the spec provides a name to a set of such keywords.
Some properties have legacy value aliases, where one
keyword is parsed as another. These are supported as "foo>bar", to make foo an alias for bar.
`valid-types`
The valid-types array lists the names of CSS value types, as defined in the latest
CSS Values and Units spec, without the <> around them.
For numeric types, we use the bracketed range notation,
for example width can take any non-negative length, so it has "length [0,∞]" in its valid-types array.
For <custom-ident>s, the excluded identifiers are placed within ![], for example "custom-ident ![all,none]".
LogicalPropertyGroups.json
A set of matching CSS properties can be grouped together in what's called a
"logical property group".
For example, all of the margin-* properties are in the margin group.
This data is used to map logical properties (such as margin-block-start) into their physical counterparts (likemargin-top) at runtime, depending on the writing direction. We don't generate any code directly from this file.
Instead, it's used as part of generating the PropertyID code.
The file is a single object where the keys are the names of the logical property groups, and their values are objects
mapping physical dimensions, sides, or corners to the relevant property. Which keys are there depends on the group - for
example the size group has width and height.
Descriptors.json
Descriptors are basically properties, but for at-rules instead of style. The overall structure is a JSON object, with
keys being at-rule names and the values being data about those at-rules. The main part is the data about the descriptors
that the at-rule can have.
The generated code provides:
- An
AtRuleIDenum, mostly used as a parameter for parsing descriptors, as multiple at-rules may have descriptors with
the same name. FlyString to_string(AtRuleID), mostly for debug logging.- A
DescriptorIDenum, listing every descriptor. Optional<DescriptorID> descriptor_id_from_string(AtRuleID, Utf16View)for getting a DescriptorID from a string, if
it exists in that at-rule.Utf16FlyString const& to_string(DescriptorID)for serializing descriptor names.bool at_rule_supports_descriptor(AtRuleID, DescriptorID)to query if the given at-rule allows the descriptor.RefPtr<StyleValue const> descriptor_initial_value(AtRuleID, DescriptorID)for getting a descriptor's initial value.
At-rule fields
Each at-rule object has the following fields. Both are required.
| Field | Description |
|---|---|
spec |
String. URL to the spec that defines this at-rule. |
descriptors |
Object, with keys being descriptor names and values being objects of their properties. See below. |
custom-descriptors |
Object, the configuration for custom descriptors (i.e. --foo). Omission means that custom descriptors are disallowed |
Descriptor fields
Each descriptor object can have the following fields:
| Field | Default | Required | Description |
|---|---|---|---|
allow-arbitrary-substitution-functions |
false |
No | Boolean. Whether this descriptor supports arbitrary substitution functions |
allow-css-wide-keywords |
false |
No | Boolean. Whether PropertyID based parsing should include CSS-wide keywords (e.g. initial, inherit) |
initial |
Nothing | No | String. The descriptor's initial value if none is provided. |
legacy-alias-for |
Nothing | No | String. The name of a different descriptor that this is an alias for. |
syntax |
N/A | Yes | Array of strings. Each string is one option, taken from the spec. |
FIXME or NOTE |
Nothing | No | Strings, for when you want to leave a note. |
Custom descriptor fields
Each custom descriptor object has the following fields
| Field | Default | Required | Description |
|---|---|---|---|
allow-arbitrary-substitution-functions |
false |
No | Boolean. Whether this descriptor supports arbitrary substitution functions |
allow-css-wide-keywords |
false |
No | Boolean. Whether PropertyID based parsing should include CSS-wide keywords (e.g. initial, inherit) |
syntax |
N/A | Yes | Array of strings. Each string is one option. |
FIXME or NOTE |
Nothing | No | Strings, for when you want to leave a note. |
Keywords.json
This is a single JSON array of strings, each of which is a CSS keyword, for example auto, none, medium, or currentcolor.
This generates Keyword.h and Keyword.cpp.
All keyword values used by any property or media-feature need to be defined here.
The generated code provides:
- A
Keywordenum as used byKeywordStyleValue Optional<Keyword> keyword_from_string(StringView)to attempt to convert a string into a KeywordStringView string_from_keyword(Keyword)to convert a Keyword back into a stringbool is_css_wide_keyword(StringView)which returns whether the string is one of the special "CSS-wide keywords"
Enums.json
This is a single JSON object, with enum names as keys and the values being arrays of keyword names.
This generates Enums.h and Enums.cpp.
We often want to define an enum that's a set of a few keywords.Enums.json allows you to generate these enums automatically, along with functions to convert them to and from a Keyword,
or convert them to a string.
These enums also can be used in property definitions in Properties.json by putting their name in the valid-types array.
This helps reduce repetition, for example the border-*-style properties all accept the same set of keywords, so they
are implemented as a line-style enum.
The generated code provides these for each enum, using "foo" as an example:
- A
Fooenum for its values Optional<Foo> keyword_to_foo(Keyword)to convert aKeywordto aFooKeyword to_keyword(Foo)to convert theFooback to aKeywordStringView to_string(Foo)to convert theFoodirectly to a string
PseudoClasses.json
This is a single JSON object, with selector pseudo-class names as keys and the values being objects with fields for the pseudo-class.
This generates PseudoClass.h.
Each entry has the following properties:
| Field | Required | Default | Description |
|---|---|---|---|
argument |
Unless legacy-alias-for is specified |
Nothing | A string containing the grammar for the pseudo-class's function parameters - for identifier-style pseudo-classes it is left blank. The grammar is taken directly from the spec. |
legacy-alias-for |
No | Nothing | Use to specify that this should be treated as a legacy selector alias for the named pseudo-class. |
The generated code provides:
- A
PseudoClassenum listing every pseudo-class name
PseudoElements.json
This is a single JSON object, with pseudo-element names as keys and the values being objects with fields for the pseudo-element.
This generated PseudoElement.h and PseudoElement.cpp.
Each entry has the following properties:
| Field | Required | Default | Description |
|---|---|---|---|
alias-for |
No | Nothing | Use to specify that this should be treated as an alias for the named pseudo-element. |
function-syntax |
No | Nothing | Syntax for the function arguments if this is a function-type pseudo-element. Copied directly from the spec. |
implementation |
Unless type is "function |
Nothing | How this pseudo-element is implemented, either "synthetic" or "element-reference" - see below. |
is-allowed-in-has |
No | false |
Whether this is a :has-allowed pseudo-element. |
is-element-backed |
No | false |
Whether this is an element-backed pseudo-element. |
is-highlight |
No | false |
Whether this is a highlight pseudo-element. |
is-pseudo-root |
No | false |
Whether this is a pseudo-element root. |
is-tree-abiding |
No | false |
Whether this is a tree-abiding pseudo-element. |
property-whitelist |
No | Nothing | Some pseudo-elements only permit certain properties. If so, name them in an array here. Some special values are allowed here for categories of properties - see below. |
spec |
No | Nothing | Link to the spec definition, for reference. Not used in generated code. |
type |
No | "identifier" |
What type of pseudo-element is this. Either "identifier", "function", or "both". |
The generated code provides:
- A
PseudoElementenum listing every pseudo-element name StringView pseudo_element_name(PseudoElement)to convert aPseudoElementback into a stringbool is_tree_abiding_pseudo_element(PseudoElement)returns whether the pseudo-element is tree-abidingbool is_pseudo_element_root(PseudoElement)returns whether the pseudo-element is a pseudo-element rootbool is_highlight_pseudo_element(PseudoElement)returns whether the pseudo-element is a highlight pseudo-element
`property-whitelist`
This is an array of strings. Properties can be named directly ("color"), or categories of properties with a leading #
("#font-properties"), as the specs often says a group is allowed instead of listing the properties exactly.
Any properties we don't support yet can be prefixed with "FIXME:" and will be ignored.
The property groups and the properties that always apply are defined inPseudoElementPropertyGroups.txt and generated into the Rust cascade tables.
The following categories are supported:
#background-properties:backgroundand its longhands#border-properties:border,border-radius, and their longhands#custom-properties: Custom properties, AKA CSS variables#font-properties:font, its longhands, and otherfont-*properties#inline-layout-properties: Properties defined in CSS Inline#inline-typesetting-properties: Properties defined in CSS Text#margin-properties:marginand its longhands#padding-properties:paddingand its longhands#text-decoration-properties: Properties defined in CSS Text Decoration
`implementation`
Pseudo-elements are implemented in two ways, either:
- "synthetic": The originating element is authoritative for the pseudo-element's data (e.g. animations, computed
style, etc) and handles it's behavior (such as generating layout nodes) itself. This includes pseudo-elements such
as "::first-line" and "::before" - "element-reference": The pseudo-element refers to a "real" element elsewhere in the DOM, usually in the originating
element's shadow tree, and is thus not authoritative. This includes, among others, form pseudo-elements such as "::placeholder".
NOTE: "element-reference" is an implementation detail and is distinct from the spec concept of "element-backed"
pseudo-elements, at time of writing all "element-backed" pseudo-elements are "element-reference", but not vice
versa.
MediaFeatures.json
This is a single JSON object, with media-feature names as keys and the values being objects with fields for the media-feature.
This generates MediaFeatureID.h.
A <media-feature> is a value that a media query can inspect.
They are listed in the @media descriptor table in the latest Media Queries spec.
The definitions here are like a simplified version of the Properties.json definitions.
| Field | Description |
|---|---|
type |
String. How the media-feature is evaluated, either discrete or range. |
values |
Array of strings. These are directly taken from the spec, with keywords as they are, and <> around type names. Types may be , , <length>, <ratio>, or <resolution>. |
false-keywords |
Array of strings. These are any keywords that should be considered false when the media feature is evaluated as @media (foo). Generally this will be a single value, such as "none". |
The generated code provides:
- A
MediaFeatureIDenum, listing each media-feature
MathFunctions.json
This is a single JSON object, describing each CSS math function,
with the keys being the function name and the values being objects describing that function's properties.
This generates MathFunctions.h and MathFunctions.cpp.
Each entry has two properties:
| Field | Description |
|---|---|
parameter-validation |
Optional string. Either "same" or "consistent", depending on whether the spec says the input calculations should be the same type or consistent types. Defaults to "same". Ignore this if there is only one parameter. |
parameters |
An array of parameter definition objects, see below. |
Parameter definitions have the following properties:
| Field | Description |
|---|---|
name |
String. Name of the parameter, as given in the spec. |
type |
String. Accepted types for the parameter, as a single string, separated by |. |
required |
Boolean. Whether this parameter is required. |
The generated code provides:
- A
MathFunctionenum listing the math functions - The implementation of the CSS Parser's
parse_math_function()method
TransformFunctions.json
This is a single JSON object, describing each CSS transform function,
with the keys being the function name and the values being objects describing that function's properties.
This generates TransformFunctions.h, and the Rust parser and serializer read it directly.
Each entry currently has a single property, parameters, which is an array of parameter definition objects.
Parameter definitions have the following properties:
| Field | Description |
|---|---|
type |
String. Accepted type for the parameter. |
required |
Boolean. Whether this parameter is required. |
The generated code provides:
- A
TransformFunctionenum listing the transform functions
EnvironmentVariables.json
This is a single JSON object, describing each CSS environment variable,
with the keys being the environment variable names, and the values being objects describing the variable's properties.
This generates EnvironmentVariable.h and EnvironmentVariable.cpp.
Each entry has 3 properties, all taken from the spec:
| Field | Description |
|---|---|
spec |
String. URL to the spec definition for this environment variable. |
type |
String. CSS value type of the variable, eg <length>. |
dimensions |
Integer. Number of dimensions for the variable, or 0 for scalars. |
The generated code provides:
- An
EnvironmentVariableenum listing the environment variables StringView to_string(EnvironmentVariable)to convert theEnvironmentVariableback to a stringValueType environment_variable_type(EnvironmentVariable)to get the variable's value typeu32 environment_variable_dimension_count(EnvironmentVariable)to get its dimension count
Units.json
This is a JSON object with the keys being dimension type names, and the values being objects. Those objects' keys are
unit names, and their values are data about each unit.
It generates Units.h and Units.cpp.
Each unit has the following properties:
| Field | Description |
|---|---|
is-canonical-unit |
Boolean, default false. Each dimension has one canonical unit. |
number-of-canonical-unit |
Number. How many of the canonical units 1 of this is equivalent to. Ignore this for relative units, and the canonical unit itself. |
is-relative-to |
String. Some length units are relative to the font or viewport. Set this to "font" or "viewport" for those. |
The generated code provides:
- A
DimensionTypeenum, listing each type of dimension that has units defined. Optional<DimensionType> dimension_for_unit(StringView)for querying which dimension a unit applies to, if any.- A
FooUnitenum for each dimension "foo", which lists all the units of that dimension. - For each of those...
constexpr FooUnit canonical_foo_unit()which is the canonical unit for that type.Optional<FooUnit> string_to_foo_unit(StringView)for parsing a unit from a string.StringView to_string(FooUnit)for serializing those units.bool units_are_compatible(FooUnit, FooUnit)which returns whether these are compatible - basically whether you can convert from one to the other.double ratio_between_units(FooUnit, FooUnit)to get a multiplier for converting the first unit into the second.
bool is_absolute(LengthUnit),bool is_font_relative(LengthUnit),bool is_viewport_relative(LengthUnit), andbool is_relative(LengthUnit)for checking the category of length units.
Documentation CSSProperties (`Documentation/CSSProperties.md`)
Adding or Modifying a CSS Property
There are several different places you need to make changes in order to add or modify a CSS property.
These are listed below in the order that Ladybird deals with them, starting at parsing and ending with them being used.
Data
The first place you will need to go to is CSS/Properties.json. This file contains the definition for each
property, and is used to generate the PropertyID enum and a selection of functions. You may also need to
modify CSS/Keywords.json, CSS/Enums.json, and CSS/LogicalPropertyGroups.json.
See CSSGeneratedFiles.md for details.
Parsing
For many properties, there is no need to add custom parsing code. Properties that take a single value, or shorthands
that are a list of their longhand properties, will be parsed automatically using the data in Properties.json.
However, there are many CSS properties with more complicated grammar and so they require custom parsing.
Property-parsing code goes in CSS/Parser/PropertyParsing.cpp, and CSS/Parser/Parser.h. First,Parser::parse_css_value() is called, which has a switch for specific properties. Call your method from there. It
should return a RefPtr to a StyleValue or one of its subclasses.
For shorthands, you should normally use ShorthandStyleValue, which automatically expands its longhand values. You
might need to modify ShorthandStyleValue::to_string if your shorthand has special serialization rules. For example,border-radius serializes with a / separating the horizontal and vertical components.
If your property's value can't be represented with an existing type, you might need to add a new style value class.
If you need to do this, pester @AtkinsSJ until he gets around to documenting it. ;^)
Computed style
After parsing and style computation, longhand properties are stored as StyleValue pointers inComputedProperties. Any shorthands have been expanded out, and so we do not need to store them directly.
These longhands then need to be converted to a more usable form. To do this, add a getter to ComputedProperties with
the same name as the property. It should return a type that holds the value in a compact form.
Then, CSS/ComputedValues.h contains three classes that are relevant:
ComputedValuesholds the computed value of each property, in a flat format. Depending on whether the property is
inherited or not, it needs adding to them_inheritedorm_noninheritedstructs, with a corresponding getter.MutableComputedValuesalso needs a setter for the value.InitialValueshas a getter for the default value of the property. This isn't always needed, for example if the
default computed value is an emptyOptionalorVector.
Style is copied from ComputedProperties to ComputedValues in NodeWithStyle::apply_style(). Each property is
copied individually.
Then, read the value of your property with that ComputedValues getter we added. For example, this code reads the
computed values of visibility and opacity:
bool Paintable::is_visible() const
{
auto const& computed_values = this->computed_values();
return computed_values.visibility() == CSS::Visibility::Visible && computed_values.opacity() != 0;
}JavaScript
Some properties have special rules for getting the computed value from JS. For these, you will need to add toCSSStyleProperties::style_value_for_computed_property(). Shorthands that are serialized in an unusual way
also need handling inside CSSShorthandStyleValue::to_string().
Documentation CodePolicy (`Documentation/CodePolicy.md`)
Code Policy
This document describes expectations for code maintained in upstream Ladybird. It is primarily intended for project maintainers and people studying local Ladybird development.
Testing policy
When possible, code changes should include tests when fixing bugs or adding new features.
If changes have relevant Web Platform Tests (WPT) tests - especially if the changes cause Ladybird to pass any WPT tests it had not yet been passing - consider importing those tests into the Ladybird tree, and then committing the imported tests along with the code changes.
Human language policy
In Ladybird, we treat human language as seriously as we do programming language.
The following applies to all user-facing strings, code, comments, and commit messages:
- The official project language is American English with ISO 8601 dates and metric units.
- Use proper spelling, grammar, and punctuation.
- Write in an authoritative and technical tone.
- Avoid contractions, slang, and idioms.
- Avoid humor, sarcasm, and other forms of non-literal language.
- Use gender-neutral pronouns, except when referring to a specific person.
Note that this also applies to debug logging and other internal strings, as they may be exposed to users in the future.
Code policy
Nobody is perfect, and sometimes we mess things up. That said, here are some good do's & don'ts to try and stick to:
Do:
- Write in idiomatic project-style C++23, using the
AKcontainers in all code. - Conform to the project coding style found in CodingStyle.md. Use
clang-formatto automatically format C++ files. - Choose expressive variable, function and class names. Make it as obvious as possible what the code is doing.
- Split your changes into separate, atomic commits (i.e. A commit per feature or fix, where the build, tests and the system are all functioning).
- Make sure your commits are rebased on the master branch.
- Wrap your commit messages at 72 characters.
- The first line of the commit message is the subject line, and must have the format "Category: Brief description of what's being changed". The category should be the name of a library, application, service, utility, etc.
- Examples:
LibMedia,WebContent,CI,AK,RequestServer,js - Don't use a category like "
Libraries" or "Utilities", except for generic changes that affect a large portion of code within these directories. - Don't use specific component names, e.g. C++ class names, as the category either - mention them in the summary instead. E.g.
LibGUI: Brief description of what's being changed in FooWidgetrather thanFooWidget: Brief description of what's being changed - Several categories may be combined with
+, e.g.LibJS+LibWeb+Browser: ...
- Examples:
- Write the commit message subject line in the imperative mood ("Foo: Change the way dates work", not "Foo: Changed the way dates work").
- Write your commit messages in proper English, with care and punctuation.
- Amend existing commits when adding changes after a review, where relevant.
- Mark each review comment as "resolved" after pushing a fix with the requested changes, where relevant.
- Add your personal copyright line to files when making substantive changes. (Optional but encouraged!)
- Check the spelling of your code, comments and commit messages.
- If you have images that go along with your code, run
optipng -strip allon them to optimize and strip away useless metadata - this can reduce file size from multiple kilobytes to a couple hundred bytes.
Don't:
- Introduce changes that are incompatible with the project licence (2-clause BSD.)
- Touch anything outside the stated scope of the change.
- Iterate excessively on your design across multiple commits.
- Use weasel-words like "refactor" or "fix" to avoid explaining what's being changed.
- End commit message subject lines with a period.
- Include commented-out code.
- Write in C. (Instead, take advantage of C++'s amenities, and don't limit yourself to the standard C library.)
- Attempt large architectural changes until you are familiar with the system and have worked on it for a while.
- Engage in excessive "feng shui programming" by moving code around without quantifiable benefit.
- Add jokes or other "funny" things to user-facing parts of the system.
On usage of AI and LLMs
Usage of AI assistance is usually fine, but you are responsible for making sure the quality of the output is up to the standards of the project. Currently, AI-generated output is often too verbose and its quality is subpar compared to carefully written human work.
Do not use an AI or LLM to generate changes, respond to issues, or participate in project discussion without holding the output to the same standards as human-written content.
Do not use AI-generated descriptions or summaries as a substitute for understanding the relevant code, issue, or discussion.
Commit hooks
The repository contains a file called .pre-commit-config.yaml that defines several commit hooks that can be run automatically just before and after creating a new commit. These hooks lint your commit message, and the changes it contains to ensure they will pass the automated CI checks.
The Python and workflow lint scripts use the versions pinned inMeta/Linters/requirements.txt, including when called by commit hooks or CI.
They prepare a virtual environment under Build/linters on first use and reuse
it afterward. Python with venv and pip support is required; the first setup
downloads the pinned tools. Changing the requirements or Python interpreter
selects a fresh environment. Concurrent invocations share a setup lock.
To prepare the environment in advance or run a pinned tool directly:
python3 Meta/Linters/run.py
python3 Meta/Linters/run.py ruff --version
python3 Meta/Linters/run.py ruff check Meta/ladybird.pyOnce prepared, the environment can be reused without downloading the packages
again. Delete Build/linters to clear the cache when no linters are running.
To enable these hooks, first follow the installation instructions available at https://pre-commit.com/#install and then enable one or both of the following hooks:
pre-commit hook - Runs
Meta/lint-ci.shandMeta/lint-ports.pyto ensure changes to the code will pass linting:consolepre-commit installpost-commit hook - Lints the commit message to ensure it will pass the commit linting:
consolepre-commit install --hook-type commit-msg
Git notes
The GitHub project contains git notes for each commit that includes e.g. a link to the pull request from which the commit originated and reviewer information. These are updated automatically, but require an additional step locally to be able to see the notes in git log:
git config --add remote.upstream.fetch '+refs/notes/*:refs/notes/*'[!NOTE]
Theupstreamremote in this command should be replaced with whatever you've named the LadybirdBrowser/ladybird.git
remote in your local clone. Usegit remote -vto find that name.
Now, any time you git fetch, the latest notes will be fetched as well. You will see information like the following when you run git log:
commit c1b0e180ba64d2ea7e815e2c2e93087ae9a26500
Author: Timothy Flynn <[email protected]>
Date: Mon Jul 29 10:18:25 2024 -0400
LibWebView: Insert line numbers before each line in about:srcdoc
The behavior chosen here (fixed-width counters, alignment, etc.) matches
Firefox.
Notes:
Author: https://github.com/trflynn89
Commit: https://github.com/LadybirdBrowser/ladybird/commit/c1b0e180ba6
Pull-request: https://github.com/LadybirdBrowser/ladybird/pull/892
Reviewed-by: https://github.com/AtkinsSJ ✅Documentation CodingStyle (`Documentation/CodingStyle.md`)
Ladybird C++ coding style
For low-level styling (spaces, parentheses, brace placement, etc), all code should follow the format specified in .clang-format in the project root.
Important: Make sure you use the correct version of clang-format!
See lint_clang_format.py for the version enforced in CI.
See AdvancedBuildInstructions.md for instructions on how to get an
up-to-date version if your OS distribution does not ship the correct version.
This document describes the coding style used for C++ code in the Ladybird Browser project. All new code should conform to this style.
We'll definitely be tweaking and amending this over time, so let's consider it a living document. :)
Names
A combination of CamelCase, snake_case, and SCREAMING_CASE:
- Use CamelCase (Capitalize the first letter, including all letters in an acronym) in a class, struct, or namespace name
- Use snake_case (all lowercase, with underscores separating words) for variable and function names
- Use SCREAMING_CASE for constants (both global and static member variables)
Right:
struct Entry;
size_t buffer_size;
class FileDescriptor;
String absolute_path();Wrong:
struct data;
size_t bufferSize;
class Filedescriptor;
String MIME_Type();When implementing spec algorithms and other constructs that a spec explicitly names, prefer closely matching the same names the spec uses, whenever possible.
Given the construct at https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-being-missing which has the literal name “Suffering from being missing” in the spec, for example:
Right:
bool HTMLInputElement::suffering_from_being_missing(); // exactly matches the spec namingWrong:
bool HTMLInputElement::has_missing_constraint(); // arbitrarily differs from spec namingUse full words, except in the rare case where an abbreviation would be more canonical and easier to understand.
Right:
size_t character_size;
size_t length;
short tab_index; // More canonical.Wrong:
size_t char_size;
size_t len;
short tabulation_index; // Goofy.Data members in C++ classes should be private. Static data members should be prefixed by "s_". Other data members should be prefixed by "m_". Global variables should be prefixed by "g_".
Right:
class String {
public:
...
private:
int m_length { 0 };
};Wrong:
class String {
public:
...
int length { 0 };
};Precede setters with the word "set". Use bare words for getters. Setter and getter names should match the names of the variables being set/gotten.
Right:
void set_count(int); // Sets m_count.
int count() const; // Returns m_count.Wrong:
void set_count(int); // Sets m_the_count.
int get_count() const; // Returns m_the_count.Precede getters that return values through out arguments with the word "get".
Right:
void get_filename_and_inode_id(String&, InodeIdentifier&) const;Wrong:
void filename_and_inode_id(String&, InodeIdentifier&) const;Use descriptive verbs in function names.
Right:
bool convert_to_ascii(short*, size_t);Wrong:
bool to_ascii(short*, size_t);When there are two getters for a variable, and one of them automatically makes sure the requested object is instantiated, prefix that getter function with ensure_. As it ensures that an object is created, it should consequently also return a reference, not a pointer.
Right:
Inode* inode();
Inode& ensure_inode();Wrong:
Inode& inode();
Inode* ensure_inode();Leave meaningless variable names out of function declarations. A good rule of thumb is if the parameter type name contains the parameter name (without trailing numbers or pluralization), then the parameter name isn't needed. Usually, there should be a parameter name for bools, strings, and numerical types.
Right:
void set_count(int);
void do_something(Context*);Wrong:
void set_count(int count);
void do_something(Context* context);Prefer enums to bools on function parameters if callers are likely to be passing constants, since named constants are easier to read at the call site. An exception to this rule is a setter function, where the name of the function already makes clear what the boolean is.
Right:
do_something(something, AllowFooBar::Yes);
paint_text_with_shadows(context, ..., text_stroke_width > 0, is_horizontal());
set_resizable(false);Wrong:
do_something(something, false);
set_resizable(NotResizable);Enum members should use InterCaps with an initial capital letter.
Prefer const to #define. Prefer inline functions to macros.
#defined constants should use all uppercase names with words separated by underscores.
Use #pragma once instead of #define and #ifdef for header guards.
Right:
// MyClass.h
#pragma onceWrong:
// MyClass.h
#ifndef MyClass_h
#define MyClass_hOther Punctuation
Constructors for C++ classes should initialize their members using C++ initializer syntax. Each member (and superclass) should be indented on a separate line, with the colon or comma preceding the member on that line. Prefer initialization at member definition whenever possible.
Right:
class MyClass {
...
Document* m_document { nullptr };
int m_my_member { 0 };
};
MyClass::MyClass(Document* document)
: MySuperClass()
, m_document(document)
{
}
MyOtherClass::MyOtherClass()
: MySuperClass()
{
}Wrong:
MyClass::MyClass(Document* document) : MySuperClass()
{
m_myMember = 0;
m_document = document;
}
MyClass::MyClass(Document* document) : MySuperClass()
: m_my_member(0) // This should be in the header.
{
m_document = document;
}
MyOtherClass::MyOtherClass() : MySuperClass() {}Prefer index or range-for over iterators in Vector iterations for terse, easier-to-read code.
Right:
for (auto& child : children)
child->do_child_thing();OK:
for (int i = 0; i < children.size(); ++i)
children[i]->do_child_thing();Wrong:
for (auto it = children.begin(); it != children.end(); ++it)
(*it)->do_child_thing();Pointers and References
Both pointer types and reference types should be written with no space between the type name and the * or &.
An out argument of a function should be passed by reference except rare cases where it is optional in which case it should be passed by pointer.
Right:
void MyClass::get_some_value(OutArgumentType& out_argument) const
{
out_argument = m_value;
}
void MyClass::do_something(OutArgumentType* out_argument) const
{
do_the_thing();
if (out_argument)
*out_argument = m_value;
}Wrong:
void MyClass::get_some_value(OutArgumentType* outArgument) const
{
*out_argument = m_value;
}"using" Statements
In header files in the AK sub-library, however, it is acceptable to use "using" declarations at the end of the file to import one or more names in the AK namespace into the global scope.
Right:
// AK/Vector.h
namespace AK {
} // namespace AK
using AK::Vector;Wrong:
// AK/Vector.h
namespace AK {
} // namespace AK
using namespace AK;Wrong:
// runtime/Object.h
namespace AK {
} // namespace AK
using AK::SomethingOrOther;In C++ implementation files, do not use "using" declarations of any kind to import names in the standard template library. Directly qualify the names at the point they're used instead.
Right:
// File.cpp
std::swap(a, b);
c = std::numeric_limits<int>::max()Wrong:
// File.cpp
using std::swap;
swap(a, b);Wrong:
// File.cpp
using namespace std;
swap(a, b);Types
Omit "int" when using "unsigned" modifier. Do not use "signed" modifier. Use "int" by itself instead.
Right:
unsigned a;
int b;Wrong:
unsigned int a; // Doesn't omit "int".
signed b; // Uses "signed" instead of "int".
signed int c; // Doesn't omit "signed".Classes
For types with methods, prefer class over struct.
- For classes, make public getters and setters, keep members private with
m_prefix. - For structs, let everything be public and skip the
m_prefix.
Right:
struct Thingy {
String name;
int frob_count { 0 };
};
class Doohickey {
public:
String const& name() const { return m_name; }
int frob_count() const { return m_frob_count; }
void jam();
private:
String m_name;
int m_frob_count { 0 };
}Wrong:
struct Thingy {
public:
String m_name;
int frob_count() const { return m_frob_count; }
private:
int m_frob_count { 0 };
}
class Doohickey {
public:
String const& name() const { return this->name; }
void jam();
String name;
int frob_count { 0 };
};Use a constructor to do an implicit conversion when the argument is reasonably thought of as a type conversion and the type conversion is fast. Otherwise, use the explicit keyword or a function returning the type. This only applies to single argument constructors.
Right:
class LargeInt {
public:
LargeInt(int);
...
class Vector {
public:
explicit Vector(int size); // Not a type conversion.
Vector create(Array); // Costly conversion.
...Wrong:
class Task {
public:
Task(ExecutionContext&); // Not a type conversion.
explicit Task(); // No arguments.
explicit Task(ExecutionContext&, Other); // More than one argument.
...Singleton pattern
Use a static member function named "the()" to access the instance of the singleton.
Right:
class UniqueObject {
public:
static UniqueObject& the();
...Wrong:
class UniqueObject {
public:
static UniqueObject& shared();
...Wrong:
class UniqueObject {
...
};
UniqueObject& my_unique_object(); // Free function.Comments
Comments should be written using // and not /* */, except for the copyright notice.
Write comments as proper sentences starting with a capital letter and ending with a period (or other punctuation).
One exception may be end of line comments like this: if (x == y) // false for NaN
Another exception is comments copied from specifications. These should be quoted verbatim, and not modified except for
wrapping or to insert necessary punctuation such as adding ** when a number is raised to a power, as this is often
done using elements like <sup> which do not appear in the copied text.
Please wrap long comments onto multiple lines so that they are easier to read. Generally, 120 characters is a good width
to aim for.
Use FIXME: (without attribution) to denote items that need to be addressed in the future.
TODO: (without attribution) is also permitted.
Right:
draw_jpg(); // FIXME: Make this code handle jpg in addition to the png support.OK:
draw_jpg(); // TODO: Make this code handle jpg in addition to the png support.Wrong:
draw_jpg(); // FIXME(joe): Make this code handle jpg in addition to the png support.Explain why the code does something. The code itself should already say what is happening.
Right:
i++; // Go to the next page.// Let users toggle the advice functionality by clicking on catdog.
catdog_widget.on_click = [&] {
if (advice_timer->is_active())
advice_timer->stop();
else
advice_timer->start();
};Even better:
page_index++;Wrong:
i++; // Increment i.// If the user clicks, toggle the timer state.
catdog_widget.on_click = [&] {
if (advice_timer->is_active())
advice_timer->stop();
else
advice_timer->start();
};Spec notes & questions
Many web specs include notes that are prefixed with NOTE: .... To allow for verbatim copying of these notes into our
code, we retain the NOTE: prefix and use NB: for our own notes.
This only applies to comments as part of code that is directly implementing a spec algorithm or behavior. Comments in
other places do not need a prefix.
Sometimes a draft spec text includes a spec question, which should be included with double square brackets:[[ ... ]].
Right:
// 2. If property is in already serialized, continue with the steps labeled declaration loop.
// NOTE: The prefabulated aluminite will not be suitable for use here. If the listed spec note is so long that we reach
// column 120, we wrap around and indent the lines to match up with the first line.
// NB: We _can_ actually use the aluminite since we unprefabulated it in step 1 for performance reasons.
// 3. For each property in window [[ in what order? ]]:Wrong:
// LB-NOTE: The aluminite might come pre-prefabulated at this point.
// Spec-note: Another example of a custom note prefix that we shouldn't use.
// There is no prefix whatsoever here, making it unclear whether this is a spec step, note or a developer note.
// 3. For each property in window (in what order?):Overriding Virtual Methods
The declaration of a virtual method inside a class must be declared with the virtual keyword. All subclasses of that class must also specify either the override keyword when overriding the virtual method, or the final keyword when overriding the virtual method and requiring that no further subclasses can override it.
Right:
class Person {
public:
virtual String description() { ... };
}
class Student : public Person {
public:
virtual String description() override { ... }; // This is correct because it contains both the "virtual" and "override" keywords to indicate that the method is overridden.
}class Person {
public:
virtual String description() { ... };
}
class Student : public Person {
public:
virtual String description() final { ... }; // This is correct because it contains both the "virtual" and "final" keywords to indicate that the method is overridden and that no subclasses of "Student" can override "description".
}Wrong:
class Person {
public:
virtual String description() { ... };
}
class Student : public Person {
public:
String description() override { ... }; // This is incorrect because it uses only the "override" keyword to indicate that the method is virtual. Instead, it should use both the "virtual" and "override" keywords.
}class Person {
public:
virtual String description() { ... };
}
class Student : public Person {
public:
String description() final { ... }; // This is incorrect because it uses only the "final" keyword to indicate that the method is virtual and final. Instead, it should use both the "virtual" and "final" keywords.
}class Person {
public:
virtual String description() { ... };
}
class Student : public Person {
public:
virtual String description() { ... }; // This is incorrect because it uses only the "virtual" keyword to indicate that the method is overridden.
}Const placement
Use "east const" style where const is written on the right side of the type being qualified. See this article for more information about east const.
Right:
Salt const& m_salt;Wrong:
const Salt& m_salt;Casts
Before you consider a cast, please see if your problem can be solved another way that avoids the visual clutter.
- Integer constants can be specified to have (some) specific sizes with postfixes like
u, l, uletc. The same goes for single-precision floating-point constants withf. - Working with smaller-size integers in arithmetic expressions is hard because of implicit promotion. Generally, it is fine to use
intand other "large" types in local variables, and possibly cast at the end. - If you
const_cast, really consider whether your APIs need to be adjusted in terms of their constness. Does the member function you're writing actually make sense to beconst? - If you do checked casts between base and derived types, also consider your APIs. For example: Does the function being called actually need to receive the more general type or is it fine with the more specialized type?
If you do need to cast: Don't use C-style casts. The C-style cast has complex behavior that is undesired in many instances. Be aware of what sort of type conversion the code is trying to achieve, and use the appropriate (!) C++ cast operator, like static_cast, reinterpret_cast, bit_cast, dynamic_cast etc.
There is a single exception to this rule: marking a function parameter as used with (void)parameter;.
Right:
MyParentClass& object = get_object();
// Verify the type...
MyChildClass& casted = static_cast<MyChildClass&>(object);// AK::Atomic::exchange()
alignas(T) u8 buffer[sizeof(T)];
T* ret = reinterpret_cast<T*>(buffer);// SeekableStream::tell()
// Seek with 0 and SEEK_CUR does not modify anything despite the const_cast,
// so it's safe to do this.
return const_cast<SeekableStream*>(this)->seek(0, SeekMode::FromCurrentPosition);Wrong:
// These should be static_cast.
size_t mask_length = (size_t)((u8)-1) + 1;// This should be reinterpret_cast.
return (u8 const*)string.characters_without_null_termination();Omission of curly braces from statement blocks
Curly braces may only be omitted from if/else/for/while/etc. statement blocks if the body is a single line.
Additionally, if any body of a connected if/else statement requires curly braces according to this rule, all of them do.
Right:
if (condition)
foo();if (condition) {
foo();
bar();
}if (condition) {
foo();
} else if (condition) {
bar();
baz();
} else {
qux();
}for (size_t i = i; condition; ++i) {
if (other_condition)
foo();
}OK:
if (condition) {
foo();
}Wrong:
if (condition)
// There is a comment here.
foo();if (condition)
foo();
else {
bar();
baz();
} else
qux();for (size_t i = i; condition; ++i)
if (other_condition)
foo();Documentation CrashReports (`Documentation/CrashReports.md`)
Crash reports
On macOS and Linux, Ladybird saves local text reports when the browser or a
helper process crashes: WebContent, WebWorker, RequestServer, ImageDecoder,
Compositor and WasmCompiler. Reports are stored in~/Library/Application Support/Ladybird/CrashReports/ on macOS and~/.local/share/Ladybird/CrashReports/ on Linux, or under$XDG_DATA_HOME/Ladybird/CrashReports/ if that variable is set.
The directory is private to the current user, and report files are read-only,
with mode 0400.
Reports awaiting review are always kept; of the reports that have already been
offered, the newest 20 are kept. Filenames start with a UTC date and time,
for example2026-09-06T12-34-56Z-WebContent-a1B2c3.txt, so they sort chronologically.
Hyphens in the time keep filenames compatible with Windows; the random suffix
avoids collisions. Retention includes reports saved with the older filenames.
Nothing is uploaded automatically.
Once the report of a WebContent crash is saved, the crash screen shows a review
of it, with sending it as the main action and Reload page next to it. After
the report is answered, reloading is what the screen offers. Any other crash,
such as one of the Compositor or RequestServer, shows a popover below the menu
button as soon as its report is saved. A newer crash takes the place of a
popover still on screen, and the popover closes when its tab goes out of view.
It offers to review the report on the same crash screen in a new blank tab,
which Close tab closes. A report on its way keeps being sent after the
screen that showed its review is closed or lost. A browser-process report is
recovered on the next launch, which shows the same popover for the newest report
still awaiting review. While no browser window is active, the popover waits
until one is. Browsers driven by WebDriver never ask. Ladybird automatically
offers each report at most once; leaving the crash screen or the popover without
answering keeps the report on the device without offering it again on a later
launch. A report of a crash from more than 14 days ago, or from before October
2, 2026 20:00 UTC, is never offered, but stays in the folder. Reports that have
been offered move into a Seen/ subdirectory, where the newest 20 are kept for
reference. Settings > Advanced > Crash reports > Open folder remains
available even when nothing has crashed. Reload restores the failed page without
adding a crash-screen history entry; Back and Forward continue to use the
original session history. The crash screen is native browser UI, so it does not
depend on a web content process.
The review asks what the user was doing and lets them choose whether to send the
report. Report details lists its main fields, such as the failure, signal,
version and commit, and opens the full report, exactly as it would be attached,
in the system's text viewer. Submissions omit the website URL by default; when
the crashed page had one, the user can explicitly include it and edit it first.
Ladybird does not collect contact information. Network errors, timeouts, rate
limits and server errors are retried a few times, honoring the server'sRetry-After; a report the server rejects is not. A report that changed on disk
after it was reviewed is not sent. A successful submission removes the local
copy. A report that is declined or could not be sent stays on the device, but is
not offered again.
Reports and filenames identify the process type. Build information includes the
full Git commit, tracked-source modification state, C++ compiler identity and
version, macOS SDK version when applicable, CMake build options, and flags from
the helper's compilation command. Include/output paths, string-valued defines
and arbitrary compiler arguments are omitted. The metadata refreshes on
incremental builds and does not require Git at runtime. Source archives without
Git metadata report an unknown revision; local source modifications and-march=native builds still require the corresponding source changes and
build-machine target to reproduce.
Reports also contain the time of the crash, the browser version, platform,
architecture, numeric kernel release, build configuration, process uptime when
available, termination signal or exit code, signal code when available, and a
bounded native stack. Stack frames identify their binaries by Mach-O UUID on
macOS or ELF build ID on Linux and contain object addresses with the load
relocation removed. When a binary is also loaded in the surviving browser, its
nearest available native symbol and the offset from that symbol are included.
Binary IDs identify builds, not users or devices.
Saved crash diagnostics do not collect page URLs, titles, content, JavaScript
stacks, cookies, network requests, console output, stderr, command lines,
environment variables, usernames, hostnames, installation paths, absolute
source paths, general register values, or memory dumps. Native symbol names
containing paths or non-printable characters are omitted. This also applies to
crashes in private windows.
Fatal VERIFY and ASSERT failures include their compile-time expression and
source location. Locations inside the checkout are repository-relative; external
locations include only the filename and line. Assertion text is bounded and does
not include evaluated operands or runtime page data. It is saved before terminal
formatting and backtrace generation, and remains available if those fail.
Architecture
After a WebContent crash, the browser displays a native crash screen and retains
the failed URL, title and committed history entry. The replacement WebContent
process remains dormant until the user chooses a recovery action. The crash
screen provides reload and report review actions directly in the browser
process. LibWebView's CrashReportReview prepares a report for display and
validates the user's choices, and CrashReportSubmission sends it to the report
server; the Qt UI only presents them.
The browser creates an unlinked temporary file before spawning each helper and
passes a descriptor to the child. The child cannot access the report directory.
It snapshots native executable ranges and binary IDs before sandboxing. The
POSIX signal handler writes fixed-size records to the descriptor, starting with
the termination reason. It walks a bounded frame-pointer chain using Mach reads
on macOS and a preopened /proc/self/mem descriptor on Linux. Unreadable memory
ends the walk. Linux also has a bounded stack-scan fallback for code without
frame pointers. The handler does not allocate, format strings, acquire
application locks, or symbolize the stack. Signal termination is preserved so
the operating system can still handle the crash normally.
After process exit, the browser reads a bounded number of records and formats
the report. It never copies arbitrary child-process text into the report. Clean
exits, SIGTERM and SIGKILL do not produce reports. Other abnormal exits still
produce a minimal report when capture was unavailable. Helpers launched by a
test-mode browser do not produce automatic reports.
The browser process has no parent process to finish its report, so Ladybird
recovers its signal-safe crash data on the next launch. Clean exits and exits
without a captured fatal signal do not produce a browser report. The record also
keeps a description of the browser's build, so a report recovered after an
update describes the build that crashed. A record without one says so instead of
describing the launch that recovers it.
The capture implementation and bounded record format live in LibCore, so all
helpers can install the handler before sandboxing without linking browser UI
code. Browser-owned report formatting and storage remain in LibWebView. Windows
capture is not implemented yet; it will need native binary IDs, stack capture,
storage and exit-status handling.
Limitations and symbolication
Only the crashing thread is captured. Stacks can be partial due to corruption,
missing frame pointers, JIT code, or modules loaded after handler
initialization. An alternate signal stack protects main-thread stack overflow;
stack overflow on other threads may only produce a minimal report. Early startup
crashes and other exits that bypass the handler also produce minimal
reports for helpers. Helper reports require the browser to survive long enough
to format them. Browser crashes before the handler is installed, or exits that
bypass its signal handler, may not produce a report. Disk errors can prevent
saving; they are reported to stderr.
Keep the binaries and debug symbols for distributed builds. A binary ID and object
address remain useful even when symbols were stripped from the user's install.
On macOS, use dwarfdump --uuid to match the report's build ID
(ignoring hyphens and case), then atos -arch arm64 -o <object-address> to resolve a frame with the matching binary and dSYM. Usex86_64 for Intel reports. Report addresses are already unslid, so do not add
the user's ASLR slide. The report itself contains no local binary paths.
On Linux, use readelf -n to match the ELF build ID, thenaddr2line -f -C -e <object-address> with matching debug symbols.
Documentation DevTools (`Documentation/DevTools.md`)
Firefox DevTools Integration
Ladybird contains a DevTools server. This document describes how to use the server and the protocol used to communicate
with the DevTools client.
Using DevTools
The DevTools server may be enabled in the "Inspect" menu via the "Enable DevTools" menu item, or with the --devtools
command line flag. A banner will be displayed in Ladybird with the port on which the DevTools server is listening. This
port may be changed by providing a port to the command line flag, e.g. --devtools=6001. To disable DevTools, use the
menu item (which will now be titled "Disable DevTools"), or close the banner.
Once DevTools is enabled, you may use the "Open Client" button on the banner to open a Firefox DevTools client window to
inspect the current tab. Only one such window may be open at a time.
Alternatively, in Firefox, navigate to about:debugging and select the "Setup" tab. In the "Network Location" form,
enter the DevTools server address. In the above example with --devtools=6001, this will be localhost:6001. You will
only have to enter this information once:
This will make the address appear on the left-side of the page. Click "Connect":
Once connected, the listed address will now be clickable itself. Click it to be brought to a page which shows Ladybird's
tab list:
Click the "Inspect" button next to the tab you wish to debug. This will open another tab in Firefox with an inspector
panel, which you may use to view the DOM tree:
DevTools protocol
Firefox's documentation of its DevTools protocol
is quite incomplete, and even incorrect in some places. To aid with development, this section describes the protocol that
Ladybird has implemented. Note that this is only observed behavior - our implementation could certainly be incorrect or
incomplete in places.
The protocol itself is based on "actors".
Each packet contains a JSON object. Messages sent from the DevTools client contain a "to" field indicating the actor
for which the message is intended. Messages sent from the DevTools server contain a "from" field indicating the actor
from which the message originated.
A very important aspect of this protocol is that the client may send multiple requests to an actor at once without
waiting for a reply, to which the actor must then reply in order. Any requests that must be performed asynchronously
(such as fetching the serialized DOM tree from the WebContent process) must block the actor from sending replies for
subsequent requests.
To log communication between the DevTools server and client, enable the DEVTOOLS_DEBUG flag:
cmake -B Build/release -D DEVTOOLS_DEBUG=ONLogged messages beginning with "<<" were sent from the server to the client. Messages beginning with ">>" were sent from
the client to the server.
Session initialization
Once the DevTools client has connected, communication begins with a root actor.
The root actor initiates the session by sending a message to the client describing itself:
<< {"from":"root","applicationType":"browser","traits":{"sources":false,"highlightable":true,"customHighlighters":true,"networkMonitor":false}}The client then sends a connection request, to which the server replies with an empty message:
>> {"type":"connect","frontendVersion":"135.0","to":"root"}
<< {"from":"root"}The client then asks the root actor to describe other top-level actors. We are required to provide a device actor and a
preference actor:
>> {"type":"getRoot","to":"root"}
<< {"from":"root","selected":0,"deviceActor":"server0-device1","preferenceActor":"server0-preference2"}The device actor provides information about the device for which the server is running, such as application version and
host OS. We provide a subset of fields
that Firefox itself provides, as the other fields seem optional, and may not even make sense for Ladybird.
The client will immediately request this information after the getRoot reply:
>> {"type":"getDescription","to":"server0-device1"}
<< {"from":"server0-device1","value":{"apptype":"ladybird","name":"Ladybird","brandName":"Ladybird","version":"1.0","appbuildid":"Version 1.0","platformbuildid":"Version 1.0","platformversion":"135.0","useragent":"Mozilla/5.0 (macOS; AArch64) Ladybird/1.0","os":"macOS","arch":"AArch64"}}The preference actor is used to query and update configuration options that resemble those found in Firefox'sabout:config page. We don't implement anything concrete here. The client will request a few boolean configuration
options after the device request, to which the server will just reply with false:
>> {"type":"getBoolPref","value":"devtools.debugger.prompt-connection","to":"server0-preference2"}
<< {"from":"server0-preference2","value":false}
>> {"type":"getBoolPref","value":"browser.privatebrowsing.autostart","to":"server0-preference2"}
<< {"from":"server0-preference2","value":false}
>> {"type":"getBoolPref","value":"dom.serviceWorkers.enabled","to":"server0-preference2"}
<< {"from":"server0-preference2","value":false}The client then asks for a list of add-ons, workers, and service workers, to which the server replies with an empty list:
>> {"type":"listTabs","to":"root"}
<< {"from":"root","addons":[]}
>> {"type":"listWorkers","to":"root"}
<< {"from":"root","workers":[]}
>> {"type":"listServiceWorkerRegistrations","to":"root"}
<< {"from":"root","registrations":[]}Then client then asks for a list of processes, followed immediately by a request for the zeroth process. This zeroth
process is required, and seems to correspond to the browser's main process. We currently stub out this information:
>> {"type":"listProcesses","to":"root"}
>> {"type":"getProcess","id":0,"to":"root"}
<< {"from":"root","processes":[{"actor":"server0-process3","id":0,"isParent":true,"isWindowlessParent":false,"traits":{"watcher":true,"supportsReloadDescriptor":true}}]}
<< {"from":"root","processDescriptor":{"actor":"server0-process3","id":0,"isParent":true,"isWindowlessParent":false,"traits":{"watcher":true,"supportsReloadDescriptor":true}}}The client then asks for a list of tabs. The server contains a delegate interface
to be implemented by the WebView application. This interface includes a method to form a list open tabs. The server will
reply with this list:
>> {"type":"listTabs","to":"root"}
<< {"from":"root","tabs":[{"actor":"server0-tab4","title":"Ladybird","url":"https://ladybird.org/","browserId":1,"browsingContextID":1,"outerWindowID":1,"traits":{"watcher":true,"supportsReloadDescriptor":true}}]}The client then asks for information about the tabs, including their favicon. The favicon is expected to be a URL, but
when provided, the DevTools client seems to hang. So we use a null value here, which Firefox itself also uses. The
server will reply with the same information provided in the listTabs request:
>> {"type":"getFavicon","to":"server0-tab4"}
<< {"from":"server0-tab4","favicon":null}
>> {"type":"getTab","browserId":1,"to":"root"}
<< {"from":"root","tab":{"actor":"server0-tab4","title":"Ladybird","url":"https://ladybird.org/","browserId":1,"browsingContextID":1,"outerWindowID":1,"traits":{"watcher":true,"supportsReloadDescriptor":true}}}At this point, the session is established and Ladybird is listed on about:debugging.
Inspecting a DOM tree
When the user inspects a tab, the client initiates the inspection with a request for a "watcher". Watchers are the
actors responsible for monitoring a wide variety of components. We currently only implement a "frame" watcher. The
server will create a watcher associated with the inspected tab, and reply with a set of watcher options indicating our
support of frame inspection:
>> {"type":"getWatcher","isServerTargetSwitchingEnabled":true,"isPopupDebuggingEnabled":false,"to":"server0-tab4"}
<< {"from":"server0-tab4","actor":"server0-watcher5","traits":{"shared_worker":false,"service_worker":false,"frame":true,"process":false,"worker":false,"resources":{"Cache":false,"console-message":false,"cookies":false,"css-change":false,"css-message":false,"css-registered-properties":false,"document-event":false,"error-message":false,"extension-storage":false,"indexed-db":false,"jstracer-state":false,"jstracer-trace":false,"last-private-context-exit":false,"local-storage":false,"network-event":false,"network-event-stacktrace":false,"platform-message":false,"reflow":false,"server-sent-event":false,"session-storage":false,"source":false,"stylesheet":false,"thread-state":false,"websocket":false}}}The client then asks the server to watch the inspected tab's frame. The server must reply with multiple messages here.
The first message contains information about the inspected tab, as well as a list of other actors associated with the
watcher. We are required to have an inspector actor, a CSS properties actor, and a thread actor. We also have the optional
console actor. These actors are described below when they are requested by the client.
The second message contains a small set of information about the tab again. The third message is just an empty message,
which seems to indicate and end-of-transmission status:
>> {"type":"watchTargets","targetType":"frame","to":"server0-watcher5"}
<< {"from":"server0-watcher5","type":"target-available-form","target":{"actor":"server0-frame10","title":"xkcd: Scream Cipher","url":"https://xkcd.com/","browsingContextID":1,"outerWindowID":1,"isTopLevelTarget":true,"traits":{"frames":true,"isBrowsingContext":true,"logInPage":false,"navigation":true,"supportsTopLevelTargetFlag":true,"watchpoints":true},"cssPropertiesActor":"server0-css-properties6","consoleActor":"server0-console8","inspectorActor":"server0-inspector7","threadActor":"server0-thread9"}}
<< {"from":"server0-frame9","type":"frameUpdate","frames":[{"id":1,"title":"Ladybird","url":"https://ladybird.org/"}]}
<< {"from":"server0-watcher5"}The client then asks for a couple of configuration actors, and sends some configuration options to those actors. These
actors are a target configuration actor and a thread configuration actor. The target configuration actor informs the
client about features supported by the target tab, such as an "offline mode". We currently stub these options to
indicate all features are not supported. The thread configuration actor is currently entirely a stub.
/* Detailed source-code truncated for AI context efficiency. */The client then asks for a list of frames, to which the server replies with an empty message:
>> {"type":"listFrames","to":"server0-frame9"}
<< {"from":"server0-frame9"}The client then asks for a CSS database. The server must reply with a list of every CSS property that the rendering
engine supports. The delegate interface includes a method for the application to provide this information. LibWebView
will reply with the list of properties generated by Properties.json.
(For brevity, only the font property is included here, as the list of all properties is very large.)
>> {"type":"getCSSDatabase","to":"server0-css-properties6"}
<< {"from":"server0-css-properties6","properties":{"font":{"isInherited":true,"supports":[],"values":[],"subproperties":["font"]}}}The client then asks the inspector actor for a few more actors in a row. The inspector actor is essentially just a
container that serves to hold other actors that actually perform inspection.
First, the client asks for a walker actor. This walker is what will actually own and traverse the DOM tree. It is at
this point that the server fetches the DOM tree from the WebContent process. The delegate interface includes a method
for the application to asynchronously provide this information. The inspector actor's message queue is blocked until we
receive the DOM tree (or encounter an error). The server will reply with information about the document element.
Next, the client asks for a page style actor and a highlighter actor. The page style actor is what will provide layout
and style information about a particular node. The highlighter actor's responsibility varies depending on the type of
highlighter that is requested. Here, the client asks for a viewport resize highlighter, which we currently do not
support.
>> {"type":"getWalker","options":{"showAllAnonymousContent":false},"to":"server0-inspector7"}
>> {"type":"getPageStyle","to":"server0-inspector7"}
>> {"type":"getHighlighterByType","typeName":"ViewportSizeOnResizeHighlighter","to":"server0-inspector7"}
<< {"from":"server0-inspector7","walker":{"actor":"server0-walker14","root":{"actor":"server0-node15","attrs":[],"baseURI":"https://ladybird.org/","causesOverflow":false,"containerType":null,"displayName":"#document","displayType":"block","host":null,"isAfterPseudoElement":false,"isAnonymous":false,"isBeforePseudoElement":false,"isDirectShadowHostChild":null,"isDisplayed":false,"isInHTMLDocument":true,"isMarkerPseudoElement":false,"isNativeAnonymous":false,"isScrollable":false,"isShadowHost":false,"isShadowRoot":false,"isTopLevelDocument":true,"nodeName":"#document","nodeType":9,"nodeValue":null,"numChildren":1,"shadowRootMode":null,"traits":{}}}}
<< {"from":"server0-inspector7","pageStyle":{"actor":"server0-page-style12","traits":{"fontStyleLevel4":true,"fontWeightLevel4":true,"fontStretchLevel4":true,"fontVariations":true}}}
<< {"from":"server0-inspector7","highlighter":{"actor":"server0-highlighter13"}}The client then asks for the frame's parent browsing context, to which the server currently replies with the same
context as the frame itself:
>> {"type":"getParentBrowsingContextID","browsingContextID":1,"to":"server0-watcher5"}
<< {"from":"server0-watcher5","browsingContextID":1}The client then instructs the viewport resize highlighter actor to "show" the inspector actor, to which the server
currently replies with a nack:
>> {"type":"show","node":"server0-inspector7","to":"server0-highlighter13"}
<< {"from":"server0-highlighter13","value":false}Then client then starts to ask for DOM node information. It begins by issuing a query selector request for the element, followed by requests for the children of the `<html>` element, the document element, and the element.
The query selector indicates the actor name of the node from which to start searching, and the name of the requested
node. We do not currently create concrete actor objects for each node; rather, we just assign actor names to each node
in the DOM tree received from the WebContent process. The query selector reply includes a field to indicate that its
parent is not the parent from which the search started. Each serialized DOM node contains information such as its type,
display name, tag name, attributes, etc.
/* Detailed source-code truncated for AI context efficiency. */The client then instructs the server to watch the root node. The server replies with information about the document
element again, followed by an empty message. We do not currently do anything more with this request.
>> {"type":"watchRootNode","to":"server0-walker14"}
<< {"from":"server0-walker14","type":"root-available","node":{"actor":"server0-node15","attrs":[],"baseURI":"https://ladybird.org/","causesOverflow":false,"containerType":null,"displayName":"#document","displayType":"block","host":null,"isAfterPseudoElement":false,"isAnonymous":false,"isBeforePseudoElement":false,"isDirectShadowHostChild":null,"isDisplayed":false,"isInHTMLDocument":true,"isMarkerPseudoElement":false,"isNativeAnonymous":false,"isScrollable":false,"isShadowHost":false,"isShadowRoot":false,"isTopLevelDocument":true,"nodeName":"#document","nodeType":9,"nodeValue":null,"numChildren":1,"shadowRootMode":null,"traits":{}}}
<< {"from":"server0-walker14"}At this point, the DOM tree in the DevTools client is interactable. As the user interacts with the client, the client
will send similar requests as the above to retrieve more DOM node information. The server will log an error for features
we do not yet support.
Inspecting a DOM node
After the DOM tree becomes interactable, the client will inspect a default node on the page (typically the ``
element). The client will immediately ask for the layout of the inspected node and for a layout inspector. The layout
consists of the box model metrics for the node. The layout inspector is an actor that provides e.g. grid and flexbox
information, but we currently just sub this out.
>> {"type":"getLayout","node":"server0-node31","autoMargins":true,"to":"server0-page-style12"}
>> {"type":"getLayoutInspector","to":"server0-walker14"}
<< {"from":"server0-page-style12","autoMargins":{},"width":1514,"height":10236.8125,"border-top-width":"0px","border-right-width":"0px","border-bottom-width":"0px","border-left-width":"0px","margin-top":"0px","margin-right":"0px","margin-bottom":"0px","margin-left":"0px","padding-top":"0px","padding-right":"0px","padding-bottom":"0px","padding-left":"0px","box-sizing":"border-box","display":"block","float":"none","line-height":"1.5","position":"static","z-index":"auto"}
<< {"from":"server0-walker14","actor":{"actor":"server0-layout-inspector568"}}The client then asks for a selector for the inspected node. The server replies with the display name of the node:
>> {"type":"getUniqueSelector","to":"server0-node32"}
<< {"from":"server0-node32","value":"body"}The client then asks for the applied style for the inspected node. This would be for the pane in the inspector tab which
allows the user to toggle and edit styles on a live DOM node. We do not yet have support for this in LibWeb, so the
server replies with an empty list:
>> {"type":"getApplied","node":"server0-node32","inherited":true,"matchedSelectors":true,"to":"server0-page-style12"}
<< {"from":"server0-page-style12","entries":[]}The client then asks the layout inspector for the inspect node's flexbox and grid information. As noted above, we have
just stubbed this for now. The server replies with empty values:
>> {"type":"getCurrentFlexbox","node":"server0-node32","onlyLookAtParents":false,"to":"server0-layout-inspector568"}
>> {"type":"getGrids","rootNode":"server0-node15","to":"server0-layout-inspector568"}
<< {"from":"server0-layout-inspector568","flexbox":null}
<< {"from":"server0-layout-inspector568","grids":[]}The client then asks if the inspected node's box model metrics are editable, and for the node's "offset parent". We
currently don't support such edits, and have stubbed the offset parent. The server again replies with empty values:
>> {"type":"isPositionEditable","node":"server0-node32","to":"server0-page-style12"}
<< {"from":"server0-page-style12","value":false}
>> {"type":"getOffsetParent","node":"server0-node32","to":"server0-walker14"}
<< {"from":"server0-walker14","node":null}If the user switches the inspector to the "Computed" pane, then the client will ask for the inspected node's computed
style. The server collects the computed style from the WebContent process, and replies with that style:
(For brevity, only the color property is included here, as the list of all styles is very large.)
>> {"type":"getComputed","node":"server0-node32","markMatched":true,"onlyMatched":true,"filter":"user","to":"server0-page-style12"}
<< {"from":"server0-page-style12","computed":{"color":{"matched":true,"value":"canvastext"}}}The user may select any other DOM node to inspect. As the mouse hovers over other DOM nodes, the client will ask (once)
if the server supports highlighting DOM nodes. We do support this, and the server will reply:
>> {"type":"supportsHighlighters","to":"server0-inspector7"}
<< {"from":"server0-inspector7","value":true}The client will then ask for box model highlighter. This highlighter indicates when the WebContent process should
render an overlay over a particular node, with information about that node's box model. The server will create this
highlighter type, and reply:
>> {"type":"getHighlighterByType","typeName":"BoxModelHighlighter","to":"server0-inspector7"}
<< {"from":"server0-inspector7","highlighter":{"actor":"server0-highlighter569"}}When the user's mouse enters a DOM node, the client will instruct the server to show the box model overlay for that node:
>> {"type":"show","node":"server0-node16","to":"server0-highlighter569"}
<< {"from":"server0-highlighter569","value":true}When the user's mouse leaves a DOM node, the client will instruct the server to stop showing the box model overlay for
that node:
>> {"type":"hide","to":"server0-highlighter569"}
<< {"from":"server0-highlighter569"}The above highlighter requests are repeated as the user's mouse is moved between nodes. When the user clicks on a DOM
node, that node then becomes the inspected node, and the client will ask for the box model / computed style for that
node.
JavaScript console
During the initialization of the watcher actor, the server advertises to the client that it supports the console. The
client will then ask the server to begin some console-related listeners, which we do not yet support:
>> {"type":"startListeners","listeners":["DocumentEvents"],"to":"server0-console8"}
<< {"from":"server0-console8","error":"unrecognizedPacketType","message":"Unrecognized packet type: 'startListeners'"}
>> {"type":"startListeners","listeners":["PageError"],"to":"server0-console8"}
<< {"from":"server0-console8","error":"unrecognizedPacketType","message":"Unrecognized packet type: 'startListeners'"}When the user enters text into the console, the client will ask the server for autocompletions and eager evaluation of
the input text. We do not yet support either of these features, so the server replies with some stubbed information:
>> {"type":"autocomplete","text":"1+","frameActor":null,"authorizedEvaluations":[],"expressionVars":[],"to":"server0-console8"}
>> {"type":"evaluateJSAsync","text":"1+","eager":true,"to":"server0-console8"}
<< {"from":"server0-console8","matches":[],"matchProp":""}
<< {"from":"server0-console8","resultID":"server0-console8-1"}When the script is finally executed by the user, the client will ask for a non-eager evaluation of the script. At this
point, the server will send the script to the WebContent process for evaluation. Its result is serialized to a JSON
value, which is called a grip. We
currently only support serialization of plain types; objects are serialized to a string for now. Since the request is
async, the server replies immediately with a pending result ID for the script. The actual result associated with that
ID is then sent once the script is complete:
>> {"type":"evaluateJSAsync","text":"1+1","disableBreaks":false,"to":"server0-console8"}
<< {"from":"server0-console8","resultID":"server0-console8-3"}
<< {"from":"server0-console8","type":"evaluationResult","timestamp":1740417141889,"resultID":"server0-console8-3","input":"1+1","result":2,"exception":null,"exceptionMessage":null,"helperResult":null}Anytime console messages are evaluated by the page (console.log, console.warn, etc.), the server will independently
notify the client without any prompt. The same grip serialization used for console results is used here.
<< {"from":"server0-frame10","type":"resources-available-array","array":[["console-message",[{"level":"log","filename":"<eval>","line_number":1,"column_number":1,"time_stamp":1741096175644,"arguments":["hello!"]}]]]}Session termination
When the user disconnects from the DevTools server, the client will send a few cleanup messages. We currently do not
implement the first few that are sent:
>> {"type":"unwatchTargets","targetType":"frame","options":{},"to":"server0-watcher10"}
<< {"from":"server0-watcher10","error":"unrecognizedPacketType","message":"Unrecognized packet type: 'unwatchTargets'"}
>> {"type":"finalize","to":"server0-highlighter18"}
>> {"type":"finalize","to":"server0-highlighter573"}
<< {"from":"server0-highlighter18","error":"unrecognizedPacketType","message":"Unrecognized packet type: 'finalize'"}
<< {"from":"server0-highlighter573","error":"unrecognizedPacketType","message":"Unrecognized packet type: 'finalize'"}We do implement the request to detach the frame, at which point we clear any inspected DOM node information from the
WebContent process:
>> {"type":"detach","to":"server0-frame14"}
<< {"from":"server0-frame14"}Inspecting the protocol with Firefox
When developing a DevTools feature, it's invaluable to see how the DevTools protocol is used by Firefox itself. First,
enable remote debugging in your Firefox profile's about:config page:
devtools.chrome.enabled = true
devtools.debugger.remote-enabled = trueYou may wish to create a new profile for this, rather than modifying your default profile. To create a new profile from
the command line:
firefox -CreateProfile YOUR_PROFILE_NAMEIf you are using your default profile, then close all Firefox windows. Restart Firefox via the command line, specifying
the DevTools server port:
firefox --start-debugger-server 6000Or if you created a new profile, you do not need to close all Firefox windows. Instead, launch a new instance with the--new-instance flag, and specify your new profile's path with the --profile flag:
firefox --start-debugger-server 6000 --new-instance --profile ~/snap/firefox/common/.mozilla/firefox/z20dtqwq.YOUR_PROFILE_NAMEAfter Firefox is running, we can now snoop the specified port. The Servo team has written a nice script using tshark
to listen on this port and pretty-print the JSON packets. You will have to install tshark before using the script.
https://github.com/servo/servo/blob/main/etc/devtools_parser.py
You may run into cases where the script does not log all captured packets. Since this occurs fairly regularly, it is
recommended to run this script in two phases. First, use the script to snoop on the DevTools port and write out a packet
capture. You may need to run this script with sudo.
./etc/devtools_parser.py -s /tmp/devtools.pcap -p 6000(NOTE: It is important to only run this script after Firefox has started. Running the script sooner will not intercept
any DevTools packets.)
Now, use about:debugging to connect to the Firefox DevTools server and inspect a tab. Use the DevTools client to
exercise the feature you wish to implement.
Once complete, use ctrl+c on Servo's snooping script to complete the capture. The script will try to print all packets
at this point, but due to the aforementioned incomplete logging, it is likely that this will not print everything. So we
can use the script again to inspect the now-existing .pcap, and it should be able to print everything this way:
./etc/devtools_parser.py --use /tmp/devtools.pcap -p 6000Known issues
- We occasionally fail to inspect a tab. There isn't any information logged by client or server, other than the client
indicating the session was disconnected. The following is the entire communication log from an instance of this error:
/* Detailed source-code truncated for AI context efficiency. */Documentation EventLoop (`Documentation/EventLoop.md`)
LibCore's `EventLoop` system
This is not about web event loops, which are a separate LibWeb concept.
For handling application tasks concurrently on one thread, LibCore provides the EventLoop system. The event loop is found in many systems, and it can be briefly summarized as an in-process loop, running all the time, which processes incoming events from signals, notifiers, file watchers, timers etc. by running associated callbacks. The event loop relies on the fact that the callbacks will eventually return control to it, so that other events can be processed. In that way, it's like a cooperative multitasking scheduler in userspace.
LibCore's event loop system is mainly used with graphical applications, and LibGUI and IPC are deeply integrated into it. If you are working on a command-line application, chances are you don't have to deal with the event loop, though that's not a universal rule.
How it works
When an event loop runs, it usually sits in exec() repeatedly pump()ing the loop. exec() waits for an event to happen and then runs all the callbacks associated with that event. After that, it goes back to sleep and waits for more events to happen.
On a lower level, this is what happens:
- On
exec(), the event loop enters the event loop stack. Then, the event loop is pumped repeatedly. - Each
pump()first puts the event loop to sleep withwait_for_event(), then handles all events. wait_for_event()uses theselect(2)function to wait until one of the prepared file descriptors becomes writeable or readable, or until a timeout expires. This means that while an event loop has nothing to do, the kernel keeps the thread at sleep. That's also the reason you'll see most GUI applications being listed with a "Selecting" state in SystemMonitor. Afterselect(2)returns, it immediately dispatches signals to all signal handlers. Other new events (expired timers, file notifications) are put into the event queue.- The specific file descriptors are the file notifiers the event loop has registered (this is the original purpose of
select(2)) as well as the wake pipe. This pipe is responsible for two things: Once a POSIX signal is received which the event loop has a handler for (this might happen at any time, on any thread),handle_signal()is entered, which writes the handler number to the pipe. But second, thewake()function just writes 0 (not a valid signal number) to the wake pipe, which is used to trigger an event loop wake from other threads. - The timeout of the
select(2)call might be infinite if there's no time-based wake condition. If there are timers, however, theselect(2)timeout is the minimum of all timer timeouts. If there are available events beforeselect(2)is even called, the timeout is zero, leading to aselect(2)which immediately returns.
- The specific file descriptors are the file notifiers the event loop has registered (this is the original purpose of
- Event handling from the event queue happens by invoking associated callbacks. Remember that the event queue has two primary sources of events:
deferred_invoke()adds an event immediately and signals a wake, while after returning fromselect(2)new events are created based on timers and notifications. - If the event loop's exit was requested (checked while handling events), it returns the pending events to the queue so that the next-lower event loop can handle them. The assumption is that that event loop will resume running shortly. The return value of
exec()has comparable purpose to a process return code, which is why the patternreturn app.exec()is so common in GUI applications (GUI::Application::exec()just runs an event loop under the hood). In any case, even if the event loop's exit doesn't mean program exit, it is removed from the event loop stack.
All of this applies per thread. The event loops share some global state (notifiers, timers, event loop stack etc.) which are thread-local variables. The convenience getter EventLoop::current() relies on this to give you the topmost event loop on the thread you're calling from.
The event loop stack is mainly used for nesting GUI windows. Each window adds another event loop onto the stack, and remaining events are added to the lower event loop's queue when it exits. The purpose behind this system is that GUI::Windows and other systems which provide their own event loop can just create and run this class depending on global variables without interfering with other event loops.
What it can do
An event loop handles several kinds of events:
- POSIX signals can be registered with
EventLoop::register_signal(). This means that the event loop of the calling thread registers the specified POSIX signal and callback with the kernel, and you can be sure that the signal handler will run as a normal event without the weirdness that comes with POSIX signal handlers (such as unspecified thread). - EventLoop::post_event() allows calling code to fire an event targeting a specific Core::EventReceiver the next time the event loop is pumped.
- Similarly, an arbitrary callback can be called on the next event loop iteration with
EventLoop::deferred_invoke(). - Timer events, i.e. events that fire after a certain timeout, possibly repeatedly, can be created with
EventLoop::register_timerandObject::start_timer(). A more user-friendly version is theCore::Timerutility class which does the same thing and allows you to attach any callback to the timer. - For when a "file" becomes readable or writeable, the utility class
Core::Notifierinterfaces with the event loop system to handle exactly that.
Note that all events are registered, and therefore happen, on the event loop of the thread that created the event.
Dos and Don'ts with event loops
- DO NOT store an event loop in a global variable. Because the event loop itself relies on global variable initialization, UBSAN will catch an initialization order fiasco. DO create your main event loop in
mainand pass it to classes which need it. - DO NOT access the current event loop if you don't know on which thread you are running. If there is no event loop on your thread, the program will crash. DO receive the specific event loop you need to talk to as an initializer variable.
- DO NOT
pump()and/orexec()the event loop of another thread. While handling events is fine, going to sleep and waking up relies on thread-local variables. DO signal events to event loops on other threads.
Documentation FAQ (`Documentation/FAQ.md`)
Frequently Asked Questions about Ladybird
What does 'Independent' mean, if you're including third party dependencies?
Independent means:
- We implement the web platform standards ourselves: Ladybird is not a Blink/Chromium shell, not a WebKit port, not a Firefox fork.
- We don't take money from anyone with strings attached
Is [some particular site] usable in Ladybird?
Ladybird is pre-alpha software you have to build from the sources yourself in order to even try. That said, it’s getting better day-by-day at handling some of the most widely-used sites on the web. So, you may find that a given site among the sites you visit daily is already usable in Ladybird. Or you may not. The only way you can really know at this point is to follow the build instructions to build Ladybird and try it yourself.
Windows support when?
There are very few maintainers working on Windows support. As such, maintaining a native Windows port would be
a lot of effort that distracts from building out the web platform standards in a reasonable amount of time.
After we have a solid foundation, we may consider a Windows port, but it's not a priority. In the meantime, Windows
developers can use other tools such as WSL2 to work on Ladybird.
Will Ladybird support `$THING`?
Eventually, probably, if there's a Web Spec for it!
When will you implement `$THING`?
There's no concrete roadmap of individual features yet. You can always raise an issue so we can keep track, if one does
not already exist!
Will you have a built in ad blocker?
Ladybird supports user-configured content blocker lists, and we already have open issues about supporting uBlock via extensions.
Is there a release roadmap?
- 2026: alpha release (daily driver for developers and early adopters) for Linux and macOS.
- 2027: beta release; downloadable app for Linux and macOS.
- 2028: stable release for general use
Well, how do I run this thing then?
Simple, my friend! Just refer to the build instructions.
I did a `git pull` and now the build is broken! What do I do?
If it builds on CI, it should build for you too. You may need to rebuild the toolchain. If that doesn't help, try it with a clean repo.
If you can't figure out what to do, ask in the #build-problems channel on Discord.
Where did Ladybird come from?
For full details, see the Ladybird: A new cross-platform browser project announcement from 12 September 2022.
Here’s a short timeline:
2019 June: Work on what eventually became Ladybird started as LibHTML — the beginnings of an HTML viewer for SerenityOS — with a commit titled “LibHTML: Start working on a simple HTML library”, and with this commit description:
I'd like to have rich text, and we might as well use HTML for that. :^)
LibHTML eventually became LibWeb — which in turn eventually grew into being the core part of the browser engine and browser to which, on 4 July 2022, the name Ladybird was given.
2022 July: Renamed Ladybird by Andreas in “Let's make a Linux GUI for the SerenityOS browser” live-coding video.
2022 Sept: Spun off from SerenityOS to separate project: “A new cross-platform browser project” announcement.
2024 June: “I'm forking Ladybird and stepping down as SerenityOS BDFL” announcement from Andreas.
2024 July: Ladybird Browser Initiative launched by Andreas and GitHub co-founder defunkt (Chris Wanstrath).
What makes Ladybird/[Ladybird Browser Initiative](https://ladybird.org/) different?
- Fully independent: Written from scratch, using no code from any other browser engine.
- Singular focus: Doing only one single thing: building a new browser engine and browser.
- No monetization: Will never take funding from default search deals or any other forms of user monetization, ever.
Are there video/audio announcements and interviews about the start of the Ladybird Browser Initiative?
- Ladybird Browser Initiative announcement video from defunkt explaining the project raison dʼêtre + goals (July 2024).
- Why we need Ladybird: Changelog podcast interview with Andreas and defunkt (August 2024); transcript; chapters.
- Eron Wolf announcement grant of $200K from FUTO to the project (August 2024).
- Eron Wolf interview with Andreas (August 2024).
Can you describe some of the project goals and its culture?
- Eventually give everybody the choice of a whole new browser they can use for their daily browsing.
- Prove it is in fact possible to build a completely new browser, by implementing from the WHATWG/W3C/etc. specs.
- Have a lot of real fun together actually doing it.
- Prove that developing an engine doesn’t take hundreds of engineers — and not anything close to even just a hundred.
- Browser engineering: Further help de-mystify it and make it a standard thing to learn (hat tip: https://browser.engineering/).
- Using project Discord server for communication discord.gg/nvfjVJ4Svh.
- Using one GitHub repo for issues, CI, and test automation.
What are some of the project coding conventions? And do you have any activity metrics?
- Implement web-platform features exactly according to the actual steps in spec algorithms.
- Abundant code comments with verbatim spec text copy/pasted in — showing exactly what’s being implemented.
- Additional
“AD-HOC:”comment convention to mark code that doesn’t map to any spec requirements. - Class/file names tend to closely match actual current spec terms; e.g.,
Navigable.h,Transferable.h. - “critically reading standards and reporting what is wrong”
- Project activity relative rankings: https://git-pulse.github.io/snapshots/?project=LadybirdBrowser_ladybird
Do you have some general details about the code and basic architecture?
- Some use of third-party libraries (e.g., Harfbuzz, Skia, simdutf, libcurl).
- Performance optimizing is not yet a super-high priority (but performance-boosting changes are regularly getting made).
- Code size:
- Roughly same size (number of lines of code) as Servo.
- About 1/15th as many lines of C++ code as WebKit.
- About 1/20th as many lines as C++ code Gecko.
- About 1/50th as many lines as C++ code Chromium.
- Level of standards support: wpt.fyi/results?product=ladybird has current test results for all WPT tests.
- LadybirdBrowser/ladybird#features:
- UI process, ImageDecoder process, RequestServer process, WebContent processes.
- LibWeb: core web-rendering engine (HTML, CSS, Events, DOM, APIs).
- LibJS: JavaScript engine written from scratch (currently JIT-less).
- LibWasm: WebAssembly implementation written from scratch.
- AK: Ladybird standard library/abstractions: asserts, smart pointers, strings, numbers (e.g., fast_float impl.), more…
What about funding?
- Funded entirely through donations and sponsorships.
- https://donorbox.org/ladybird – donations of any amount: $10, $50, $100, etc.
- Sponsorship opportunities: Platinum $100,000 • Gold $50,000 • Silver $10,000 • Bronze $5,000 • Copper $1,000.
- Ladybird Browser Initiative announced/seeded with 1 million dollar donation from defunkt and his family.
Do you have some places where I can watch videos related to the project?
- Ladybird YouTube channel: monthly Ladybird project updates from Andreas.
- Andreas’ YouTube channel: 1000+ videos from 6+ years; incl. “car talk” + OS/browser “hacking” (live-coding) videos.
Is there some related background to help me understand what a browser _engine_ is and why it’s important?
- Understanding the role of browser engines (UK Competition and Markets Authority Mobile ecosystems market study).
Documentation GettingStartedContributing (`Documentation/GettingStartedContributing.md`)
Getting involved with Ladybird
Welcome to the Ladybird web browser project!
This document is a beginner-friendly guide to getting familiar with Ladybird and helping the project through testing, bug reports, reductions, standards discussion, design discussion, security reports, and technical feedback.
Getting familiar with the project
Ladybird is a large project making use of many homegrown and third-party libraries, written primarily in C++.
It is recommended you read the README and FAQs in case they already answer any questions you have:
The Discord server is the preferred way to get in contact with the maintainers and community.
Getting familiar with browser engineering in general
If you’ve never worked on browser-engine code before, and you’re not sure where to begin — one great place to get started is by reading the book Web Browser Engineering. It explains how browser engines in general work, and how they’re built — by walking you through real code for actually building all the parts of a basic but complete browser engine (networking code, HTML parsing, layout engine, JavaScript handling, and more), in a couple thousand lines of Python.
Building the code
Ladybird must be built from source during this pre-alpha stage of development, and currently natively supports Linux and macOS; running it on Windows requires WSL.
Carefully follow the steps outlined in the build instructions. If you are facing issues, consult the troubleshooting guide and the #build-problems channel on Discord.
Finding bugs and other issues
Here are some of the ways you can find an issue in Ladybird:
- By checking the issue tracker.
- Manually, by using the browser as you normally would.
- By finding failing WPT tests on WPT.fyi. You do not need to submit issue reports for individual tests.
- By finding WPT tests on WPT.fyi that are timing out in Ladybird. For a real-world walk-through of doing that from start to finish with an actual timing-out-in-Ladybird test case, see the “Fixing a WPT timeout in Window.postMessage()” “browser hacking” video.
- By using a profiling tool such as Callgrind to find code that can be improved.
- By looking for
TODOandFIXMEcomments in the codebase.
If you’re not necessarily fluent in C++, beginning by troubleshooting WPT tests may be the very best way to get started helping the project — especially if you do already have some proficiency with frontend JavaScript code.
That’s because without even knowing any C++ at all, you can still — by working just with the JavaScript code in the WPT test source — get a long way toward isolating the cause of a particular WPT test failure or timeout. That alone can be a very big help to maintainers and other people investigating the related C++ code.
Submitting an issue
If you have found an issue that is not already in the issue tracker, you may submit it. Do not submit general questions about the project, please use the Discord server instead.
Read the project participation guidelines, in particular the Issue policy and Human language policy. It is recommended you reduce the website to the most minimal amount of HTML/CSS/JS which still results in the error (if applicable), and provide the result expected from other browsers vs Ladybird. Read the detailed issue-reporting guidelines for the exact steps to do so.
Learning the codebase
Getting familiar with C++
Ladybird requires at least a basic knowledge of C++. Consult a tutorial website like Learn C++ and online references if you need help.
Getting familiar with the codebase
Ladybird makes use of the included AK library instead of the C++ STL, and employs a specific coding style based around it. Unfortunately most AK and internal library facilities are not documented; you may need to look for the header files, and examples of usage in existing code.
Developer documentation:
Documentation LibWebFromLoadingToPainting (`Documentation/LibWebFromLoadingToPainting.md`)
LibWeb: From loading to painting
NOTE: This document is a work in progress!
FIXME: Add more information about everything.
FIXME: Add lots of links to relevant specs, etc.
Resource loading
We make an IPC call to RequestServer, asking it to start loading the URL we've been asked to load.
HTML parsing
The job of the HTML parser is to take an input stream, possibly determine its encoding, and then parse it into a DOM tree. For historical reasons, this is unusually complicated.
LibWeb implements the HTML tokenization and parsing algorithms as laid out in the HTML specification. While the spec is detailed, it's also highly idiosyncratic. The tokenizer and parser reach into each other and update the other's state in certain situations.
Due to the document.write() API, the parser also allows programmatic injection of new input into the parser from JavaScript. The document being parsed may run JavaScript, and this JavaScript may indirectly interact with the parser by feeding it override inputs.
CSS parsing
- CSS parser
- Builds the CSSOM
- Values with variables can't be resolved until cascade, kept as "unresolved" values
JS parsing & execution
- JS parser
- Builds JavaScript AST
- Interpreter runs AST
- Garbage collector (basic stop-the-world mark&sweep)
Style computation
This step is concerned with finding the set of CSS values that apply to a given DOM element.
Selector matching
A CSS selector is represented by the CSS::Selector class.
Given the following selector:
#foo .bar.baz > imgWe get the following C++ object tree:
* CSS::Selector
* CSS::CompoundSelector (combinator: ImmediateChild)
* CSS::SimpleSelector (type: TagName, value: img)
* CSS::CompoundSelector (combinator: Descendant)
* CSS::SimpleSelector (type: Class, value: bar)
* CSS::SimpleSelector (type: Class, value: baz)
* CSS::CompoundSelector (combinator: None)
* CSS::SimpleSelector (type: ID)Selectors are evaluated right-to-left, and we're looking for the first opportunity to reject it.
Optimization notes
We try to minimize the number of selectors that have to be evaluated for each DOM element.
The main optimization is a cache in StyleComputer that divides style rules into buckets based on what their rightmost complex selector must match. For example, a selector that can only match an element with the class "foo" will only ever be evaluated against elements that currently have the class "foo".
Cascading to the final values
The C in CSS is for "cascading" and "the cascade" refers to the process where all the CSS declarations that should apply to a DOM element are evaluated in order. The order is determined by a number of factors, such as selector specificity, rule order within the style sheet. There's also per-property consideration of the !important annotation, which allows authors to override the normal cascade order.
- To compute the "computed style" for an element...
- Perform the CSS cascade to determine final property values
- Interpolate variables
- Absolutize, etc
We separate CSS rules by their cascade origin. The two origins we're concerned with are "user-agent" and "author". If we supported custom user style sheets (we don't), they'd need a separate cascade origin as well.
The cascade origin determines the processing order for rules. The "user-agent" style is the least important, so it gets processed first. Then author style is added on top of that.
Note: the user-agent style is a built-in CSS style sheet that lives in the LibWeb source code here.
The end product of style computation is a fully populated StyleProperties object. It has a StyleValue for each CSS::PropertyID. In spec parlance, these are the computed values. (Note that these are not the same as you get from getComputedStyle(), that API returns the resolved values.)
Resolving CSS custom properties ("variables")
Custom properties are resolved during the cascade. This is the earliest possible time where this can be done, as the value of a given variable depends on the result of the cascade.
Building the layout tree
To prepare for layout, we combine the DOM with the results of style computation to create a new tree: the layout tree.
This is essentially the "box tree" in the CSS specifications, with some important differences that allow us to make better use of the C++ type system.
There isn't a 1:1 mapping between DOM nodes and layout nodes. Elements with display:none style don't generate a layout node at all.
A number of tree fix-ups are also performed:
- To maintain the invariant that block containers only have all block-level children or all inline-level children, inline-level boxes with block-level siblings are wrapped in anonymous wrapper boxes.
- If an individual table component box is found outside the context of a full table, a set of anonymous table boxes are generated to compensate and ensure that there's always a fully-formed table. (FIXME: This is currently buggy..)
- For list item boxes (
display: list-item), a box representing the marker is inserted if needed.
Layout
Layout starts at the ICB (initial containing block), which is the layout node that corresponds to the DOM document node.
CSS says that the ICB should be the size of the viewport, so the very first thing layout does is assign these dimensions.
Layout works through a mechanism call formatting contexts. CSS defines a number of different formatting contexts:
- Block Formatting Context (BFC)
- Inline Formatting Context (IFC)
- Table Formatting Context (TFC)
- Flex Formatting Context (FFC)
In LibWeb, to simplify the layout model, we also define our own SVGFormattingContext for embedded SVG content. This isn't part of the SVG specification, but simplifies the implementation.
Layout is fundamentally recursive. We start at the ICB, which always creates a BFC.
Block-level layout
BFC works by laying out its children, one by one in tree order. If its children are block level, they are laid out along the block axis of the BFC's root box. Block-axis margins between adjacent boxes are collapsed.
Floating boxes (CSS float:left or float:right) are interesting. First, we compute where they would have ended up if they weren't floating. Then we push them towards the left or right edge as requested. If they collide with the edge of another floating box before getting to the edge, they are stacked next to that box, forming a sort of "float line". BFC keeps track of floating boxes on both sides.
Inline-level layout
If its children are inline level, BFC creates an IFC to handle them. Unlike other formatting contexts, IFC can't work alone; it always works together with its parent BFC.
The job of IFC is to generate line boxes. Line boxes are essentially a sequence of inline content fragments, laid out along the inline axis. Line boxes are stored in the IFC's containing block box.
There are three main classes involved in inline-level layout:
- InlineFormattingContext (IFC)
- LineBuilder
- InlineLevelIterator
IFC drives the high-level loop by creating a LineBuilder and an InlineLevelIterator. It then traverses the inline-level content within the IFC containing block, one item at a time, by calling InlineLevelIterator::next().
The items produced by InlineLevelIterator are then passed along to LineBuilder, which adds them to the line box we're currently building.
When a line box is filled up, we insert a break and begin a new box after it.
We always keep track of how much space is available on the current line. When starting a new line, this is reset by computing the width of the IFC's containing block and then subtracting the size occupied by floating boxes on both sides. We get this information by querying the parent BFC about floating boxes intersecting the Y coordinate of the new line.
The result of performing a layout is a LayoutState object. This object contains the CSS "used values" (final metrics, including line boxes) for all box that were in scope of the layout. The LayoutState can either be committed (via commit()) or simply discarded. This allows us to perform non-destructive (or "immutable") layouts if we're only interested in measuring something.
Paintable and the paint tree
When layout is finished, we take all the final metrics for each box and generate a new tree: the paint tree. The paint tree hangs off of the layout tree, and you can reach the corresponding paintable for a given layout node via Layout::Node::paintable(). There's also a convenience accessor in the DOM: DOM::Node::paintable().
Unlike Layout::Node (which is basically a box tree node with associated style), Paintable is an object with fully finalized metrics. It has two jobs, painting and hit testing.
Note that not every layout node will have a paintable. If a layout node cannot possibly be visible, it may not require a paintable.
Every paintable has a corresponding layout node, and painting code will reach into the layout node for some information, to avoid having it in multiple places.
Stacking contexts
There's one final thing we have to do before we can paint: we have to create stacking contexts.
Stacking contexts are a 3-dimensional model of layers (stacking contexts) that places content along a Z axis. This is what the CSS z-index property is for.
The set of rules for what becomes a stacking context are somewhat intricate, but the important thing is that we create a new tree: the stacking context tree.
The stacking context tree is rooted at the ICB, and can have zero or more descendants. Each descendant stacking context has a corresponding layout that it's attached to.
Painting
Painting follows the order specified in the CSS2 appendix E. The rules are quite involved, but two main things to know about:
- Painting is driven through stacking contexts, which are painted back-to-front (stacking context tree order)
- Painting is performed in phases. For each stacking context, we paint in order: backgrounds & borders, floats, backgrounds & borders for inline and replaced content, foreground (text), focus outlines and overlays.
Documentation LibWebPatterns (`Documentation/LibWebPatterns.md`)
LibWeb Code Style & Patterns
This document aims to describe agreed upon code style and patterns used across LibWeb.
Directory Structure
Generally we use a subdirectory, and thus C++ namespace, for each individual spec. For example XHR
(xhr.spec.whatwg.org):
- Code lives in
LibWeb/XHR/ - Uses the C++ namespace
Web::XHR
If necessary, code can also be grouped into sub-subdirectories (for example HTML/Scripting/).
Sometimes a spec document affects several areas of the web platform. An example of this is CSSOM,
which of course contains features belonging in LibWeb/CSS/ / Web::CSS, but at the same time
implements additions to Window from the HTML spec. Use best judgement in those cases.
Error Handling
The following error types are commonly used in LibWeb, each having a different purpose:
`AK::ErrorOr`
This error type is generally only used to propagate OOM errors from AK and other general libraries.
It should not be used to propagate any other kinds of errors, even though this is supported and used
in other parts of the system. For LibWeb, use any of the error types below.
This should be propagated as far as possible before being turned into a JS error object (via theTRY_OR_THROW_OOM macro). This avoids a situation where almost everything returns a genericWebIDL::ExceptionOr<T>.
`Web::WebIDL::ExceptionOr`
This is the most common and at the same time most broad error type in LibWeb. Internally it stores a
variant of supported errors:
SimpleExceptionGC::Ref<DOMException>JS::Completion(fromJS::ThrowCompletionOr<T>, assumed to be ofType::Throw)
Use this error type for anything that needs to interact with the JS bindings, which will generally
know how to turn any of the internally supported errors into JS objects.
`Web::WebIDL::SimpleException`
This is a thin wrapper around various built-in errors from ECMAScript:
EvalErrorRangeErrorReferenceErrorTypeErrorURIError
Instead of constructing one of these directly, create a SimpleException with the appropriate type
and message instead whenever required by a web spec. These will be converted into actual JS objects
in the bindings layer.
Note Relevant WebIDL documentation: https://webidl.spec.whatwg.org/#dfn-simple-exception
`Web::WebIDL::DOMException`
This is an error type from the WebIDL spec specifically for web AOs where none of the JS built-in
error types are sufficient. Like SimpleException, use when indicated by a web spec.
Note Relevant WebIDL documentation: https://webidl.spec.whatwg.org/#idl-DOMException
`JS::ThrowCompletionOr`
This is an error type from LibJS, which uses "completions" to propagate errors and other types of AO
results. Don't use this in LibWeb unless absolutely necessary, e.g. when overriding a JS::Object
virtual method that returns this type.
At the call site, these should be wrapped in a ExceptionOr<T> as soon as possible for further
propagation.
Note Relevant ECMAScript documentation:
https://tc39.es/ecma262/#sec-completion-record-specification-type
Comments
As in LibJS, all functions that represent an operation or JS function from a web specification
must have:
A spec link, above the function definition:
cpp// https://fetch.spec.whatwg.org/#concept-fetch WebIDL::ExceptionOr<GC::Ref<Infrastructure::FetchController>> fetch(JS::Realm& realm, Infrastructure::Request& request, Infrastructure::FetchAlgorithms const& algorithms, UseParallelQueue use_parallel_queue) { // ... }Comments for each individual step of the operation:
cpp// 1. Assert: request’s mode is "navigate" or processEarlyHintsResponse is null. VERIFY(request.mode() == Infrastructure::Request::Mode::Navigate || !algorithms.process_early_hints_response().has_value()); // 2. Let taskDestination be null. GC::Ptr<JS::Object> task_destination; // ...- If a step cannot be implemented at the time, prepend it with a
FIXME - Add a blank line between code and the next comment
- If a step cannot be implemented at the time, prepend it with a
Optimizations (e.g. fast paths) should be marked as such with an
// OPTIMIZATION:comment
explaining the reasoningWhen adding non-standard code for a feature that is otherwise well-specified, it should be
marked as such. This does not universally apply as certain areas (layout and painting, for
instance) are only broadly spec'dIf the spec has additional prose before or after its algorithm steps, that doesn't need to be
copied into the code
JS Interfaces
IDL
Try to copy IDL verbatim, only making changes where necessary (IDL parser shortcomings, non-standard
extended attributes).
This includes not reordering functions, changing parameter names, etc.
The major difference to the spec is that we use four spaces for indentation, like for any other code
(not two).
C++ Naming
Try to stick with the interface's exact name as much as possible for the class and file names (noObject suffixes like in LibJS, for example). When there's a name clash, try to introduce a nested
namespace, e.g. Fetch::Request vs Fetch::Infrastructure::Request.
File placement
The .cpp, .h, and .idl files for a given interface should all be in the same directory, unless
the implementation is hand-written when it cannot be generated from IDL. In those cases, no IDL file
is present and code should be placed in Bindings/.
Documentation MediaPipelineDesign (`Documentation/MediaPipelineDesign.md`)
Media Pipeline
Overview
LibMedia turns demuxed media data into decoded audio and video, then feeds it to sinks that are
driven by playback time. The pipeline is pull-based: downstream nodes ask upstream nodes whether
data is available, then pull data when the reported status allows it.
The current pipeline is made of three categories of nodes:
- Producers materialize raw audio/video data.
- Sinks consume that raw audio/video data.
- Processors are both producers and sinks, pulling data from an input producer to transform it
before a downstream sink makes use of it.
Audio and video use separate producer interfaces because the data they carry is different, but their
control flow is intentionally similar.
Producer Interface
Producers expose three core operations:
peek()reports the current status and, when data is available, exposes the head of the
producer's output without removing it.consume()removes the head that a priorpeek()exposed.set_wake_handler(...)is used by the downstream node to wake itself when a previously-observed
status would result in it going to sleep.
Downstream nodes peek() when they need new data, then either read the head and consume() it or
go to sleep. Peeking without consuming lets a node decide whether to take the head, rather than
being forced to accept data it may have no room for.
PipelineStatus
PipelineStatus is the shared status language between nodes:
Pending: Data is not available yet, but may become available later.HaveData: A peek exposes data.Blocked: Data is not available yet, i.e. we have run out of buffered data from over the network.Suspended: The upstream node has released its decoding resources and will not produce data until
it receives a seek.EndOfStream: We have reached the end of the input data.Error: The upstream node encountered an error and cannot continue without a seek.
HaveData carries data. All other statuses require handling by downstream code.
Pending, Blocked, Suspended, EndOfStream and Error are waiting statuses. A downstream node
that normally polls for upstream input seeing these should sleep until its wake handler fires.
EndOfStream and Error are terminal statuses. They resolve seeks even though they are still
"waiting" statuses for helper APIs, because no more data is required to decide the seek has
completed.
Wake Handlers
Wake handlers do not carry state. A wake means the downstream node should peek() again.
Nodes should dispatch a wake when the downstream node may be waiting, and the status has changed to
one that resolves a seek: HaveData, EndOfStream, or Error.
Some nodes (i.e. AudioMixer) may also wake when moving away from a previously dispatchedEndOfStream, so stale EOS state can be recovered after a seek or after new input becomes
available.
Seeking
Seeking is initiated downstream and forwarded upstream. For downstream nodes, a seek completes when
the upstream node reports a status that resolves the new position. Usually that means HaveData,
but EndOfStream or Error can also complete the wait.
Sinks report pipeline status changes to their users. A user such as the playback manager can
interpret a seek-resolving status as seek completion.
If a node forwards a seek upstream, it can clear or invalidate its cached data, since the upstream
node is required to emit data at or before the target timestamp.
However, if a node uses its cached data to resolve seeks, it must also resolve seeks past the last
frame in that cached data if its last known status is EndOfStream. This ensures that a seek past
EOS will not invalidate in-flight frames by bumping a seek generation.
Decoded Data Producers
Decoded data producers run demux and decode video/audio data into queues in order to absorb variance
in processing for each individual coded frame.
When a downstream consume() removes an item from the queue, the producer wakes its decoder thread
so it can refill the queue. When the queue is full and no downstream activity occurs for the idle
timeout, the producer auto-suspends: it drops its decoder, its queue, and any frame pool buffers it
can free, then wakes the consumer with a Suspended status.
peek() and consume() defer the idle timeout, but they never resume a suspended producer. When a
downstream node needs new data, it must explicitly seek() the suspended node to resume it, so that
the pipeline may insert pre-roll data at any stage where it is needed (i.e. for audio stretching).
Playback Manager
PlaybackManager creates, modifies and drives the end-to-end playback pipeline. When media data is
received, it will spawn a thread to determine what tracks are present, and connect the necessary
nodes for playback to begin.
Behavior is defined through state handlers derived from PlaybackStateHandler, with each overriding
methods for various functions and signals to determine when to transition to another state. Upon
entering or exiting, a state handler will drive changes to the pipeline, like pausing/resuming or
seeking.
The media time is derived from the audio sink when audio is used, otherwise GenericTimeProvider
calculates time from the monotonic clock.
Audio Pipeline
The usual audio path is:
DecodedAudioProducer
-> AudioMixer
-> AudioTimeStretchProcessor
-> AudioPlaybackSink
-> PlaybackStreamAudioPlaybackSink owns a small queue of AudioBlocks pulled from upstream, which it fills on a
separate thread to avoid blocking the PlaybackStream data callback. It starts pulling input when
its start() method is called, so that all inputs can be connected first. When its input reachesEndOfStream, it continues producing silence to allow the time to advance in case there is video
data still available.
AudioMixer sequences/mixes audio from multiple inputs into a single output. It emits silence for
gaps, and pauses output when any input indicates data is not ready yet. When all inputs are atEndOfStream, the output becomes EndOfStream as well.
AudioTimeStretchProcessor stretches audio without affecting pitch.
Video Pipeline
The usual video path is:
DecodedVideoProducer -> DisplayingVideoSinkDisplayingVideoSink keeps the current frame and the next frame. It advances when playback time
passes the current frame's display interval.
Video seeks are comparatively expensive, so the sink can satisfy some seeks locally when the
requested timestamp is already covered by the current or next frame interval. This avoids
unnecessary upstream seeks during scrubbing. The same optimization is less important for audio
because it decodes exponentially faster.
Threading
The media pipeline crosses several threads:
- The main thread coordinates playback state and serializes cross-thread communication through its
event loop - Decoded data producers manage decoding threads to amortize the cost to decode each frame
- The audio sink runs a processing thread to amortize the cost of mixing/stretching each audio block
- The playback stream data callback is invoked from an (often system-managed) thread
Data should only be consumed via peek() and consume() from a single thread, to allow producers
to use lock-free structures internally. Control methods like seek() should similarly only initiate
from a single thread, or be otherwise serialized.
Avoid synchronous callback dispatch while holding locks unless the callee is known not to reenter
the same pipeline. Deferred dispatch is often used to keep lock ordering simple.
Wake handlers must be removed before disconnecting or destroying a node. An upstream node must never
invoke a handler after downstream has cleared it.
Documentation Omnibox (`Documentation/Omnibox.md`)
Omnibox
Ladybird's omnibox combines URL entry, search, history, bookmarks, adaptive learning, and remote
search suggestions. The implementation is used by the UI frontend through LibWebView.
The central rule is that ordering, automatic selection, and inline completion are separate
decisions. A result may be useful enough to display near the top without being safe to select or
complete automatically.
Code structure
The implementation is divided into a few focused components:
Omniboxowns an editing session. It tracks the user's query, displayed text, selected row,
completion state, popup state, and active query generation.Autocompletecoordinates local and remote suggestion delivery.AutocompleteServiceruns local database work on a serial worker thread.AutocompleteRankerretrieves and scores history, bookmark, and adaptive candidates.AutocompleteMuxermerges duplicate destinations, applies diversity rules, and constructs the
final result list.HistoryStore,BookmarkStore, and the omnibox engagement tables provide local evidence.UI/Qt/Autocompleterenders the shared suggestion model.
OmniboxSuggestionProvider is the boundary between the editing state machine and suggestion
generation. The production provider wraps Autocomplete; tests use a scripted provider so they can
deliver results in any order without relying on timing.
Query lifecycle
Every edit starts a new query generation identified by an incrementing AutocompleteQueryID.
Local retrieval begins immediately. Remote search suggestions are debounced for 100 milliseconds.
The local worker owns a separate SQLite connection. There is at most one running local query and
one pending query per client. A newer pending query replaces an older one. When possible, a newer
query interrupts stale SQLite work rather than waiting for it to finish.
Results are delivered incrementally:
- Local results are ranked and published.
- A remote response, if enabled and successful, is merged into the same query generation.
- The combined list is published again.
Remote suggestions from the preceding query may remain visible while a compatible continuation is
in flight. Only suggestions that still have the new input as a prefix are retained. A successful
response replaces these provisional rows.
The UI thread discards results when their query ID is no longer active. Ending an editing session,
dismissing the popup, or starting another session cancels provider work and invalidates the active
generation. Stale results can therefore neither reopen the popup nor become an Enter action.
Suggestion model
AutocompleteSuggestion contains:
- Its source: literal URL, history, bookmark, adaptive engagement, or search.
- Destination text and optional title, subtitle, and favicon.
- The input used for presentation highlighting.
- A match class and separate relevance components.
- Whether it is the verbatim action.
- Whether it may be automatically selected.
- Whether it may be used for inline completion.
The score components are retained after ranking so duplicate destinations can combine independent
history, bookmark, and adaptive evidence without losing their provenance.
Destination identity
URL destinations are compared semantically. Scheme differences remain meaningful, while equivalent
serializations of the same URL are merged. www. and bare hosts share a diversity family but are
not otherwise treated as the same destination.
Search destinations are identified by Unicode case-folded text with whitespace normalized. This
allows an exact remote suggestion to merge with the verbatim search action.
When history, bookmark, and adaptive candidates identify the same URL, their score components and
eligibility flags are combined. Bookmark presentation is preferred so its title and favicon remain
visible.
Matching
URL matching accepts the forms users commonly type by ignoring an http:// or https:// prefix,
and optionally www., when comparing a query with a candidate. Matching is case-insensitive.
Match classes, from strongest to weakest, include:
- Exact URL.
- URL prefix.
- Exact history or bookmark title.
- Title word prefix.
- URL substring.
- Title substring.
- Exact or prefix adaptive association.
Title matching uses Unicode case folding. URL completion uses ASCII case-insensitive comparison
because serialized non-ASCII URL components are already encoded.
History titles participate after three input code points. Exact history titles outrank title-prefix
matches, allowing a page named Wikimedia Commons to rank ahead of pages whose titles merely end
in Wikimedia Commons. They remain ineligible for automatic selection and inline completion.
Bookmark titles and folders normally participate after three input code points. An exact bookmark
title participates at any non-empty length, so a bookmark named GH is retrieved by GH, gh, orGh. This exception does not turn partial two-character title prefixes into candidates.
An exact title match remains ineligible for automatic selection and inline completion. It is a
strong retrieval signal, not permission to replace the user's search action or editor text.
Ranking
Ranking is deterministic and uses bounded integer components. Match quality is the largest signal;
history quality, bookmark evidence, and adaptive engagement then refine candidates within the same
general match quality.
History
History ranking distinguishes direct navigation intent from passive visits. Visit and direct-visit
scores decay over time and are bounded before being added to a candidate score. Reloading or
repeatedly encountering a page cannot produce unbounded relevance.
For host-like input, history also creates aggregate origin candidates. Evidence comes from a capped
number of distinct pages, which prevents one frequently reloaded deep page from making its origin
look intentional. The origin may borrow a favicon from a matching page.
Deep history pages are suppressed for one- and two-character host queries unless the input contains
a URL path. This keeps short result lists from filling with incidental pages.
Bookmarks
A bookmark contributes a fixed relevance bonus in addition to match quality. URL-prefix bookmark
matches are strong navigation evidence. Bookmark title and folder matches are useful for retrieval
but are not automatically selected or inline-completed.
Deep bookmarks remain eligible for a two-character URL prefix. Exact short title matches also remain
eligible, but do not become automatic actions.
Adaptive engagements
The omnibox records committed input-to-destination associations. Search and URL destinations are
stored separately. Explicit row choices and automatic commitments have separate counts.
Exact associations are stronger than prefix associations. Short adaptive prefixes require repeated
explicit use before they can become automatic:
- A one-character deep destination requires three explicit uses.
- A two-character deep destination requires two explicit uses.
An exact input-to-URL association of at least two characters becomes automatic after one explicit
choice, including search-like input containing whitespace. This allows the omnibox to learn a
deliberate navigation without allowing passive title matches to replace the search action.
Adaptive scores decay with time. Prefix evidence is scaled by the square root of the typed-prefix
length divided by the learned-input length, so very short prefixes receive less credit.
Merely displaying, automatically highlighting, or hovering a suggestion does not record an
engagement. A private session never records engagements.
Result composition
The muxer first merges candidates with the same destination and sorts them by relevance. It then
constructs a bounded result list while preserving the verbatim action and limiting repetition.
The current limits are soft:
- At most four local navigation rows before deferred local rows.
- At most three remote search rows before deferred remote rows.
- At most two rows per origin for ordinary host queries.
- At most one row per origin and five total rows for one- and two-character host queries.
Deferred source rows may fill unused space after the first pass. This prevents a source cap from
leaving the popup unnecessarily sparse.
For short queries, candidates from the same origin are collapsed to one representative. Proven
adaptive destinations are preferred, followed by matching deep bookmarks, followed by origin rows.
An exact bookmark-title match is treated as a matching deep bookmark for this decision, preventing
an origin row from hiding it.
The highest-ranked candidate eligible for automatic selection is moved to the first row. A
higher-scoring but ineligible title or substring match may remain visible below it.
Automatic selection
Automatic selection determines what Enter does when the user has not deliberately chosen another
row. It does not imply inline completion.
A candidate may be selected automatically when it is supported by strong navigational evidence,
for example:
- A sufficiently long URL prefix backed by direct visits.
- A bookmark URL prefix of at least two characters.
- An adaptive association whose evidence meets the short-prefix thresholds.
Title-only, folder, substring, previous-search, and remote suggestion matches are not automatic URL
actions. The verbatim URL-or-search action remains available in every non-empty result list.
Automatic defaults use hysteresis during incremental refreshes. A challenger replaces the current
default only when it exceeds the incumbent by both 10 percent and 100 relevance points. This avoids
visible selection churn as local and remote results arrive.
Inline completion
Inline completion is intentionally stricter than automatic selection. A candidate must:
- Be eligible for automatic selection.
- Be a URL-prefix match.
- Extend the user's input at the end of the editor.
- Produce a non-empty suffix after optional scheme and
www.handling. - Not contain a query string unless an exact adaptive association permits it.
Title, folder, substring, search, and literal URL candidates never inline-complete. The typed prefix
is preserved exactly; only the borrowed suffix is selected.
An existing completion is retained across forward typing while it remains valid and is within 150
relevance points of the best inline candidate. A materially better non-inline default clears it.
Backspace suppresses completion for the resulting input. Typing a different input lifts the
suppression. Moving the cursor away from the end removes the completion and prevents late results
from applying another one. Right Arrow and End accept a completion, adopt the displayed text as the
new query, and requery it.
Editing and activation
Omnibox keeps the user's query separate from borrowed display text. Display text has one of three
provenances:
- User text.
- Inline completion.
- Keyboard row preview.
Arrow keys explicitly select rows. A selected row may preview its destination in the editor while
the original query remains available for restoration. Pointer hover only changes row appearance; it
does not select, preview, or train the result.
Escape closes the popup and restores the query. Clicking outside does the same. If the user edits or
rejects a completion, Enter commits the text currently owned by the user rather than silently
choosing an unrelated automatic row.
An explicitly selected row remains selected across a refresh of the same generation when a result
with the same destination still exists. If it disappears, its preview is removed. Automatic
selection is recalculated separately.
Enter never waits for suggestions. If the displayed rows belong to an older query generation, the
current input is immediately committed through normal URL-or-search resolution.
Persistence and deletion
History stores visit counts, direct-visit counts, last-use timestamps, and decayed scores. Redirect
chains distinguish direct navigation from visits produced by redirects so redirect targets do not
inherit typed intent.
Engagement rows store normalized input, destination kind, destination identity, explicit and
automatic use counts, and the last-use time. Database migrations add these fields to existing
profiles. The database layer supports floating-point values, statement interruption, and a busy
timeout because autocomplete reads concurrently with history writes.
Removing a history row also removes its engagement associations unless the destination remains
bookmarked. Forgetting a site and clearing all history remove the corresponding associations.
Private browsing
Private autocomplete reads normal-profile history, bookmarks, and adaptive associations so local
suggestions remain useful. It does not write history or engagement data.
Remote suggestions use the private RequestServer connection. No history, bookmark, or engagement
data is sent to the search provider; the request contains only the query required by the configured
autocomplete engine.
Presentation
Both native frontends render the same ordered suggestion list without source headers. Search rows
use a search icon. Local navigation rows use a favicon when available and otherwise a globe.
Bookmarks receive a star badge.
Matched portions of titles and URLs use shared match ranges from LibWebView, ensuring the UI
highlights the same text. The match uses both weight and brightness so it remains visible in
dark and light themes.
The popup displays at most six rows before scrolling. Rows with a title use two lines; URL-only and
search rows use one. Selection and pointer hover have separate visual treatments.
Settings
Remote search suggestions are controlled by the autocomplete-engine setting. Disabling suggestions
clears the configured engine; the setting is persisted with the rest of the browser settings and is
restored on the next launch.
The search engine used for the verbatim action is independent of whether remote suggestions are
enabled.
Tests
The implementation has three main deterministic test layers:
TestAutocompleteRankerverifies matching, relevance components, eligibility, decay, and short
query behavior.TestAutocompleteMuxerverifies destination merging, diversity, source limits, verbatim
preservation, and representative selection.TestOmniboxdrives the editing state machine with a scripted provider, covering stale delivery,
completion acceptance and rejection, selection preservation, activation, and engagement recording.
TestAutocomplete exercises the asynchronous integration with the application, service worker,
bookmark updates, private-profile reads, and remote failure handling. History-store tests cover
persisted ranking state, migrations, and deletion behavior.
Tests control result delivery or inject a clock rather than depending on wall-clock timing.
Documentation Patterns (`Documentation/Patterns.md`)
Coding Patterns
Introduction
Over time numerous reoccurring patterns have emerged from or were adopted by
the Ladybird code base. This document aims to track and describe them, so they
can be propagated further and the code base can be kept consistent.
`TRY(...)` Error Handling
The TRY(..) macro is used for error propagation in the Ladybird code base.
The goal being to reduce the amount of boiler plate error code required to
properly handle and propagate errors throughout the code base.
Any code surrounded by TRY(..) will attempt to be executed, and any error
will immediately be returned from the function. If no error occurs then the
result of the contents of the TRY will be the result of the macro's execution.
Examples:
Example from LibGfx:
#include <AK/Try.h>
... snip ...
ErrorOr<NonnullRefPtr<Bitmap>> Bitmap::create_shareable(BitmapFormat format, IntSize size, int scale_factor)
{
if (size_would_overflow(format, size, scale_factor))
return Error::from_string_literal("Gfx::Bitmap::create_shareable size overflow");
auto const pitch = minimum_pitch(size.width() * scale_factor, format);
auto const data_size = size_in_bytes(pitch, size.height() * scale_factor);
auto buffer = TRY(Core::AnonymousBuffer::create_with_size(round_up_to_power_of_two(data_size, PAGE_SIZE)));
auto bitmap = TRY(Bitmap::create_with_anonymous_buffer(format, buffer, size, scale_factor, {}));
return bitmap;
}Note: Our TRY(...) macro functions similarly to the ? operator in rust.
`MUST(...)` Error Handling
The MUST(...) macro is similar to TRY(...) except the macro enforces that
the code run inside the macro must succeed, otherwise we assert.
Note that MUST(...) should not be used as a replacement for TRY(...) in cases where error propagation is not (currently) possible.
Instead, the release_value_but_fixme_should_propagate_errors() method of ErrorOr<> should be used to retrieve the value
and to mark the location for future improvement. MUST(...) is reserved for cases where we determine through other circumstances that it
should not be possible for the code inside the macro to fail or if a failure is serious enough that the program needs to crash.
Example:
#include <AK/Vector.h>
... snip ...
ErrorOr<void> insert_one_to_onehundred(Vector<int>& vector)
{
TRY(vector.try_ensure_capacity(vector.size() + 100));
for (int i = 1; i <= 100; i++) {
// We previously made sure that we allocated enough space, so the append operation shouldn't ever fail.
MUST(vector.try_append(i));
}
return {};
}Fallible Constructors
The usual C++ constructors are incompatible with Ladybird's method of handling errors,
as potential errors are passed using the ErrorOr return type. As a replacement, classes
that require fallible operations during their construction define a static function that
is fallible instead.
This fallible function (which should usually be named create) will handle any errors while
preparing arguments for the internal constructor and run any required fallible operations after
the object has been initialized. The resulting object is then returned as ErrorOr<T> orErrorOr<NonnullOwnPtr<T>>.
Example:
class Decompressor {
public:
static ErrorOr<NonnullOwnPtr<Decompressor>> create(NonnullOwnPtr<Core::Stream::Stream> stream)
{
auto buffer = TRY(CircularBuffer::create_empty(32 * KiB));
auto decompressor = TRY(adopt_nonnull_own_or_enomem(new (nothrow) Decompressor(move(stream), move(buffer))));
TRY(decompressor->initialize_settings_from_header());
return decompressor;
}
... snip ...
private:
Decompressor(NonnullOwnPtr<Core::Stream::Stream> stream, CircularBuffer buffer)
: m_stream(move(stream))
, m_buffer(move(buffer))
{
}
CircularBuffer m_buffer;
NonnullOwnPtr<Core::Stream::Stream> m_stream;
}The `ladybird_main(..)` program entry point
Ladybird has moved to a pattern where executables do not expose a normal C
main function. A ladybird_main(..) is exposed instead. The main reasoning
is that the Main::Arguments struct can provide arguments in a more idiomatic
way that fits with the Ladybird's internal API surface area. The ErrorOr likewise
allows the program to propagate errors seamlessly with the TRY(...) macro,
avoiding a significant amount of clunky C style error handling.
These executables are then linked with the LibMain library, which will link in
the normal C int main(int, char**) function which will call into the programsladybird_main(..) on program startup.
The creation of the pattern was documented in the following video:
OS hacking: A better main() for SerenityOS C++ programs
Examples:
A function main(..) would normally look something like:
int main(int argc, char** argv)
{
return 0;
}Instead, ladybird_main(..) is defined like this:
#include <LibMain/Main.h>
ErrorOr<int> ladybird_main(Main::Arguments arguments)
{
return 0;
}Intrusive Lists
Intrusive lists are used in some specific cases
in Ladybird. A data structure is said to be
"intrusive" when each element holds the metadata that tracks the
element's membership in the data structure. In the case of a list, this
means that every element in an intrusive linked list has a node embedded
inside it. The main advantage of intrusive
data structures is you don't need to worry about handling out of memory (OOM)
on insertion into the data structure. This means error handling code is
much simpler than say, using a Vector in environments that need to be durable
to OOM.
The common pattern for declaring an intrusive list is to add the storage
for the intrusive list node as a private member. A public type alias is
then used to expose the list type to anyone who might need to create it.
Static Assertions of the size of a type
It's a universal pattern to use static_assert to validate the size of a
type matches the author's expectations. Unfortunately when these assertions
fail they don't give you the values that actually caused the failure. This
forces one to go investigate by printing out the size, or checking it in a
debugger, etc.
For this reason AK::AssertSize was added. It exploits the fact that the
compiler will emit template argument values for compiler errors to provide
debugging information. Instead of getting no information you'll get the actual
type sizes in your compiler error output.
Example Usage:
#include <AK/StdLibExtras.h>
struct Empty { };
static_assert(AssertSize<Empty, 1>());String View Literals
AK::StringView support for operator""sv which is a special string literal operator that was added as of
C++17 to enable std::string_view literals.
[[nodiscard]] ALWAYS_INLINE constexpr AK::StringView operator""sv(const char* cstring, size_t length)
{
return AK::StringView(cstring, length);
}This allows AK::StringView to be constructed from string literals with no runtime
cost to find the string length, and the data the AK::StringView points to will
reside in the data section of the binary.
Example Usage:
#include <AK/String.h>
#include <AK/StringView.h>
#include <LibTest/TestCase.h>
TEST_CASE(string_view_literal_operator)
{
StringView literal_view = "foo"sv;
String test_string = "foo";
EXPECT_EQ(literal_view.length(), test_string.length());
EXPECT_EQ(literal_view, test_string);
}Source Location
C++20 added std::source_location, which lets you capture the
callers FILE / LINE / FUNCTION etc as a default
argument to functions.
See: https://en.cppreference.com/w/cpp/utility/source_location
AK::SourceLocation is the implementation of this feature in
Ladybird's AK. It's become the idiomatic way to capture the location
when adding extra debugging instrumentation, without resorting to
littering the code with preprocessor macros.
To use it, you can add the AK::SourceLocation as a default argument
to any function, using AK::SourceLocation::current() to initialize the
default argument.
Example Usage:
#include <AK/SourceLocation.h>
#include <AK/StringView.h>
static StringView example_fn(const SourceLocation& loc = SourceLocation::current())
{
return loc.function_name();
}
int main(int, char**)
{
return example_fn().length();
}If you only want to only capture AK::SourceLocation data with a certain debug macro enabled, avoid
adding #ifdef's to all functions which have the AK::SourceLocation argument. Since SourceLocation
is just a simple struct, you can just declare an empty class which can be optimized away by the
compiler, and alias both to the same name.
Example Usage:
#if LOCK_DEBUG
# include <AK/SourceLocation.h>
#endif
#if LOCK_DEBUG
using LockLocation = SourceLocation;
#else
struct LockLocation {
static constexpr LockLocation current() { return {}; }
private:
constexpr LockLocation() = default;
};
#endif`type[]` vs. `Array` vs. `Vector` vs. `FixedArray`
There are four "contiguous list" / array-like types, including C-style arrays themselves. They share a lot of their API, but their use cases are all slightly different, mostly relating to how they allocate their data.
Note that Span<type> differs from all of these types in that it provides a view on data owned by somebody else. The four types mentioned above all own their data, but they can provide Span's which view all or part of their data. For APIs that aren't specific to the kind of list and don't need to handle resizing in any way, Span is a good choice.
- C-style arrays are generally discouraged (and this also holds for pointer+size-style arrays when passing them around). They are only used for the implementation of other collections or in specific circumstances.
Arrayis a thin wrapper around C-style arrays similar tostd::array, where the template arguments include the size of the array. It allocates its data inline, just as arrays do, and never does any dynamic allocations.Vectoris similar tostd::vectorand represents a dynamic resizable array. For most basic use cases of lists, this is the go-to collection. It has an optional inline capacity (the second template argument) which will allocate inline as the name suggests, but this is not always used. If the contents outgrow the inline capacity, Vector will automatically switch to the standard out-of-line storage. This is allocated on the heap, and the space is automatically resized and moved when more (or less) space is needed.FixedArrayis essentially a runtime-sizedArray. It can't resize likeVector, but it's ideal for circumstances where the size is not known at compile time but doesn't need to change once the collection is initialized.FixedArrayguarantees to not allocate or deallocate except for its constructor and destructor.
Documentation Porting (`Documentation/Porting.md`)
Porting to new Operating System Environments
This document outlines the steps required to port Ladybird to a new OS environment, and expectations for such ports to
be accepted into the mainline repository.
Types of Ports
There are two types of ports that can be made to Ladybird:
- UI port: We define the "browser frontend" as the UI layer. This includes the browser window, tabs, address bar, etc.
- Platform port: This includes the underlying platform-specific code that interacts with the OS. This includes things like
file I/O, networking, and process management.
UI Ports
There are currently two supported UI ports:
- Android UI - The native UI on Android.
- Qt - The UI used on all other platforms.
Platform Ports
There are currently two supported platform ports:
- GNU/Linux: The Linux platform port that may work on other POSIX platforms
- macOS: The macOS platform port
Many other POSIX desktop platforms are known to work, or have worked in the past, but are not officially supported.
Among others, these include Alpine Linux, FreeBSD, OpenBSD, and Haiku. Support for these systems is community-driven,
meaning there is no regular CI to ensure that each master branch commit maintains their functionality.
Contributions to restore or improve compatibility are welcome!
There is currently one in progress platform port:
- Android: The Android platform port, using the Android SDK/NDK directly.
Porting Steps
UI ports
UI ports mostly concern themselves with the UI layer. This means the main Ladybird process, using LibWebView.
To create a new Ladybird UI, you will need to implement a new WebView::ViewImplementation subclass.
ViewImplementation is the main interface between the UI process and WebContent processes. It is expected that each tab
of the browser will have its own WebContent process. This is all managed by the WebView layer.
Each UI port must also subclass WebView::Application to add any UI-specific command-line flags.
TODO: Explain any more details that are necessary
Platform ports
Platform ports concern themselves with the underlying OS-specific code. In Ladybird, this code is largely contained in
the AK and LibCore libraries.
AK is the standard template library for Ladybird. The first step of a new platform port is a new platform define inAK/Platform.h. This define will be used to conditionally compile platform-specific code.
In AK, the most likely class to need platform-specific code is AK::StackInfo.
LibCore is an abstraction over POSIX. It contains classes to wrap lower level OS functionality into APIs that are
comfortable for Ladybird developers to use. The most likely place to need adjustment is Core::System, followed byCore::Process and Core::Socket.
Ladybird makes heavy use of IPC, and the IPC layer is in LibIPC. This layer is mostly platform-agnostic, but there are
some platform-specific details in LibCore that may need to be adjusted. The IPC system is based on Unix domain sockets,
so any platform that supports Unix domain sockets should be able to use the IPC system out of the box.
Special Consideration
Windows
Over the years excitement about a native Windows port has waxed and waned. The main issue is that Ladybird is built on
top of LibCore, which is a POSIX abstraction. Windows is not POSIX, and so a Windows port requires a significant amount
of effort to implement.
To limit the scope of the work, we have decided that a Windows port will need the following properties:
- Target x86_64-windows-msvc and arm64-windows-msvc only. We are not interested in accepting patches to make MinGW or MSYS2 work.
- The port must use
clang-cl.exeas the compiler. We are not interested in accepting patches to make MSVC work. - Platform
#ifdefs must stay within AK and LibCore, with a few exceptions in LibWebView. - Prefer separate cpp files with the same interface to
#ifdefs within a single file. - Avoid
#ifdefs in headers, as much as possible.
Documentation ProcessArchitecture (`Documentation/ProcessArchitecture.md`)
Ladybird Browser process architecture
NOTE: This document is partly aspirational, in that the state of the code does not yet fully reflect what's described here. Implementation is underway.
The Ladybird web browser uses a multi-process architecture to improve stability and security in the face of arbitrary (and possibly hostile) web content.
Process overview
Every instance of the Browser application can have one or more tabs open. Each tab has a unique WebContent service process spawned on its behalf.
Two important aspects of web browsing are further separated from the WebContent process: network requests and image decoding, segregated to the RequestServer and ImageDecoder processes respectively.
All processes are aggressively sandboxed using the pledge() and unveil() mechanisms. Furthermore, all processes except Browser run as an unprivileged user, separate from the primary logged-in desktop user.
Process: WebContent
This process hosts the main HTML/CSS engine (LibWeb.) It also runs JavaScript (LibJS.) It gets input events from Browser and paints the web content into shared bitmaps. It can only communicate with the outside world via RequestServer.
Process: RequestServer
This process can use networking protocols (like HTTP or HTTPS) to request files from the outside world. Each WebContent process gets its own RequestServer to do uploading or downloading on its behalf.
For DNS lookups, RequestServer asks for help from the system's global LookupServer service, which handles all outgoing DNS requests.
Process: ImageDecoder
This process can decode images (PNG, JPEG, BMP, ICO, PBM, etc.) into bitmaps. Each image is decoded in a fresh ImageDecoder process. These are strongly sandboxed and can't do much except receive encoded bitmap data and return a bitmap to WebContent if decoding is successful.
How processes are spawned
To get a fresh WebContent process, anyone with the suitable file system permissions can spawn one by connecting to
the socket at /tmp/session/%sid/portal/webcontent, with %sid being the current login session id. This socket is managed
by SystemServer and will spawn a new instance of WebContent for every connection.
The same basic concept applies to RequestServer and ImageDecoder as well, except that those services are spawned
by WebContent as needed, not by Browser.
Class overview
In the GUI application process, a OutOfProcessWebView widget is placed somewhere in a window, and it takes care of spawning all of the helper processes, etc.
Internally, the OutOfProcessWebView has a WebContentClient object that implements the client side of the WebContent IPC protocol.
The WebContentClient speaks to a WebContent::ConnectionFromClient in the WebContent process. Internally, the WebContent::ConnectionFromClient has a WebContent::PageHost which hosts the LibWeb engine's main Web::Page object.
Inside LibWeb, a Web::Page has a main Web::Frame, which may have subframes corresponding to <frame> or `` HTML elements. Each Web::Frame has a Web::Document, which is the root node of the DOM tree.
Describing the LibWeb object model in detail is outside the scope of this document.
Documentation RequestServerWireLogging (`Documentation/RequestServerWireLogging.md`)
RequestServer wire-activity logging
RequestServer emits up to four dbgln lines per HTTP fetch that actually
hit the network, so you can correlate "dead air" in a profile with the
download (or stall) responsible for it.
Cache hits do not appear here — only requests that produced traffic.
That is the point: if the log is silent during a profile gap, the gap is
not a download.
The lines
For every fetched request you get a wire: line. Successful fetches that
delivered any body also get wire+: (decoded view), wire++: (encoded
view, when curl provided wire-level progress), and wire^: (pre-network
time spent inside RequestServer plus drain delay). When more than one
request completes in the same curl multi tick, a single wire-batch:
line is emitted explaining why their wire: timestamps cluster.
Several diagnostic lines fire only when something interesting happened
and don't belong to any single request: wire-stall: flags
event-loop blocks and slow synchronous curl calls, wire-burst: flags
request floods from a client, wire-pipe-pressure: flags WebContent
back-pressure on the response pipe, LibDNS wire-dns: flags slow DNS
lookups (especially the blocking getaddrinfo fall-back), and UI wire-cookie: flags slow cookie-jar lookups on the UI side. See the
"Process-wide diagnostic lines" section below.
`wire:` — one-line summary
wire: KIND METHOD URL HTTP-VERSION -> STATUS
| wire X.X KiB sent X.X KiB
| total N ms = queue N + dns N + tcp N + tls N + req N + wait N + body N
| wire X.X KiB/s avg, X.X KiB/s during bodyFields:
- KIND — what happened:
DOWNLOAD— full body fetched from origin.REVAL-304— conditional GET hit the cache; server returned 304, we
served the cached body.REVAL-FULL— conditional GET that the server invalidated (returned
new content), so we replaced the cache entry.FAIL— curl failed before completion. The line collapses to... -> error: <curl message> (after N ms, wire X.X KiB).[bg]suffix on the kind marks a background revalidation triggered
bystale-while-revalidate.
- wire X.X KiB — bytes received on the wire (encoded; gzip /
brotli / etc. as transferred). Comes fromCURLINFO_SIZE_DOWNLOAD_T.
Read this carefully — see the wire-vs-decoded section below. - sent X.X KiB — request body bytes uploaded.
- total N ms — request lifetime, broken into libcurl phases. All
phase numbers are wall-time deltas:queue— time waiting in libcurl's pre-start queue (HTTP/2 and
HTTP/3 stream multiplexing usually puts you here briefly).dns— name resolution.tcp— TCP handshake (or QUIC connect on HTTP/3).tls— TLS handshake. Zero on plaintext, and zero on a reused
connection — see "Reused-connection timing" below.req— time between handshake completing and being ready to send
the request bytes.wait— server processing / time-to-first-byte. A highwaitwith
everything else low means the origin took its time before sending
the first response byte.body— time from first response byte to last response byte.
- wire X.X KiB/s avg —
CURLINFO_SPEED_DOWNLOAD_T, averaged over
the entire request including TTFB. Useful as a sanity check, not as a
throughput number. - wire X.X KiB/s during body — wire bytes divided by
bodytime.
This is the throughput you actually got while bytes were flowing.
`wire+:` — decoded-side per-chunk stats
wire+: decoded chunks=N bytes=X.X KiB span=N ms thru=X.X KiB/s
| gap avg=N ms max=N ms stalls(>100ms)=N
| worst gap began at +N ms after X.X KiB decoded
| chunk bytes avg=N min=N max=NSampled inside our WRITEFUNCTION callback, so all numbers here are
post-decompression — i.e. the bytes our consumer actually receives.
- chunks=N — number of times curl handed us a buffer of decoded
bytes. - bytes=X.X KiB — total decoded body size (sum of all chunks).
- span=N ms — wall time between the first decoded chunk and the
last. - thru=X.X KiB/s —
bytes / span, throughput as the consumer
experienced it. - gap avg / max — inter-chunk spacing.
maxis the longest pause
between two consecutive decoded chunks. - stalls(>100ms)=N — count of inter-chunk gaps longer than 100 ms.
Useful for "was this one big pause, or several smaller ones?". - worst gap began at +N ms after X.X KiB decoded — locates the
worst gap in the timeline. If the answer is+5 ms after 0.0 KiB,
the server flushed once and then thought; if it is+800 ms after 90% of bytes, the tail dragged. - chunk bytes avg / min / max — distribution of decoded chunk
sizes.
`wire++:` — wire-side per-progress-tick stats
wire++: wire bytes=X.X KiB span=N ms thru=X.X KiB/s
| wire max gap=N ms stalls(>100ms)=N
| worst wire gap began at +N ms after X.X KiB on the wire
| compression=YxSampled inside CURLOPT_XFERINFOFUNCTION, which curl invokes both as
bytes hit the socket and as a heartbeat during silence. All numbers
here are wire bytes (encoded, before decompression).
- wire bytes / span / thru — encoded-side equivalents of the
decoded line.thruis the real network throughput while bytes were
flowing. - wire max gap / stalls / worst gap began at — same idea as the
decoded line, but reflecting actual silence on the network. This is
what you want when the question is "is the server pausing or is curl
buffering?". - compression=Yx —
decoded_bytes / wire_bytes. Common HTML pages
land at 5–10×; static assets that are already compressed (.jpg,
.mp3, .woff2) land at 1×.
`wire^:` — pre-network time spent inside RequestServer
wire^: internal pre-curl=N ms = cache+init N + our-dns N + cookie N + curl-setup N | drain delay N msWall-time accounting for everything that happens before libcurl owns the
request, plus the gap between curl marking it done and us logging.
- internal pre-curl —
curl_added_at - created_at. Total time this
request spent inside our state machine beforecurl_multi_add_handle.
When this is a meaningful fraction oftotalon thewire:line, the
delay is ours, not the network's. - cache+init — Init / ReadCache / WaitForCache time. Big numbers
here mean cache contention or a slow disk-cache lookup. - our-dns — time inside our DNS resolver. Distinct from libcurl's
dnsfield on thewire:line, which is 0 because we
pre-resolve and pass the address viaCURLOPT_RESOLVE. If thewire:line saysdns 0and you trust the network is fine but
things still feel slow, look here. - cookie — round-trip IPC to the UI process to retrieve cookies for
the URL. Skipped (-) for credential-less requests. - curl-setup — gap from the last completed pre-network step to
curl_multi_add_handle. Should be very small; if it isn't, something
inhandle_fetch_stategot expensive. - drain delay —
complete_observed_at - last_wire_byte_at. How
long between the last byte landing andcheck_active_requests
noticing the completion. Usually sub-ms — non-zero means an event-loop
delay between curl seeing the end and us draining its message queue.
Fields can show - when the relevant phase didn't run (e.g.our-dns - for connect-only requests, cookie - when credentials
were disabled).
`wire-batch:` — multiple completions per drain pass
RequestServer wire-batch: drained N completions in one curl multi tickEmitted once per check_active_requests call when more than one request
completed since the previous drain. When you see this followed by Nwire: lines all sharing the same timestamp prefix, the clustering is
cosmetic: we drained the curl multi handle's done-queue in a tight
loop, so the dbgln calls happen microseconds apart. The actual
network completion times can be reconstructed from the total /body numbers on each request, walked back from the drain timestamp.
If you want true per-request completion times, take the drain
timestamp and subtract each request's drain delay from wire^:.
Process-wide diagnostic lines
These do not belong to any one request. They surface conditions that
affect the whole RequestServer process or its peers (WebContent, the UI
process). When investigating a stall, look at these first — a singlewire-stall: line often explains a dozen confusing wire: lines that
follow.
`wire-stall:` — event-loop block detector
RequestServer wire-stall: N ms event-loop gap before 'LABEL' (previous handler: 'PREV')
RequestServer wire-stall: curl call 'LABEL' took N ms (synchronous in event loop)Two flavours:
Event-loop gap. Every notifier/handler we control samples
MonotonicTime::now()on entry. If more than 100 ms passed since the
previous sample, this fires, naming both the handler that just woke up
and the previous handler that was the last thing to run. IfPREVis
the same handler each time, that handler is doing too much work
synchronously. IfLABELiscurl-socket-readythe kernel has been
trying to deliver bytes to us for that long.Exception: when
LABELiscurl-timer-firedand the timer fired
within ±50 ms of the time libcurl asked us to wake it, the gap is
by design (libcurl's internal heartbeat — typically 250 ms intervals)
and the log line is suppressed. A real stall — the timer fires much
later than its scheduled time — still reports.Synchronous curl call.
curl_multi_socket_actionshould be very
fast — it just hands a socket event to libcurl. If a single call
exceeds 50 ms, libcurl is doing real work synchronously (TLS handshake
on a fresh connection, certificate validation, callback dispatch into
ourWRITEFUNCTION). That work blocks every other request.
Labels currently emitted: curl-timer-fired, curl-socket-ready,check-active-requests, ipc-start-request, ipc-start-revalidation,ipc-retrieved-cookie, plus the curl wrappersmulti_socket_action(timeout) and multi_socket_action(socket).
`wire-burst:` — request flood from a client
RequestServer wire-burst: client N sent M requests in <100 msPer-client start_request arrival counter. When a single client lands
more than 5 start_request IPCs in a 100 ms window, the count is logged
the first time the next request arrives outside that window. Useful for
correlating a wire-stall: with "this is the burst that hit us".
`wire-pipe-pressure:` — WebContent back-pressure on the response pipe
RequestServer wire-pipe-pressure: GET URL pipe full, buffering=N bytes
RequestServer wire-pipe-pressure: GET URL unblocked after N ms (peak buffered=N bytes); WebContent likely behindFires when m_client_request_pipe->write returns EAGAIN /EWOULDBLOCK. RequestServer is producing response bytes faster than
WebContent's main thread can drain them — almost always because
WebContent is busy parsing HTML, executing JavaScript, or doing layout.
The "unblocked" line fires when the pipe drains and we resume writing,
but only if the back-pressure window lasted more than 50 ms. The
per-request totals are also surfaced in the wire^: line aspipe back-pressure events=N total=N ms peak-buffered=N bytes. This is
the "WebContent is behind" diagnosis — it doesn't mean RequestServer is
slow.
`LibDNS wire-dns:` — DNS lookup synchronous portion (and worker completions)
LibDNS wire-dns: lookup(NAME) path=PATH sync=N ms
LibDNS wire-dns: lookup(NAME) path=system-resolver-bg total=N ms = A(queue N + work N) | AAAA(queue N + work N) (off event loop)The first form is emitted by DNS::Resolver::lookup whenever its
synchronous portion takes more than 5 ms. The synchronous portion is
what holds the event loop — anything past trivial here is interesting.path classifies how the lookup was satisfied:
cache-hit— answered entirely from our in-memory cache.literal-ipv4/literal-ipv6—namewas an IP address literal.system-resolver-bg— dispatched two parallelgetaddrinfocalls
to ThreadPool workers, one forAF_INET(A records) and one forAF_INET6(AAAA records). The split avoids buggy stub resolvers
(notably systemd-resolved under load) that drop the AAAA half of a
coupled query and stall both. The promise resolves as soon as one
side returns records, with a 50 ms grace window for the other side
(Happy Eyeballs v2's "Resolution Delay", RFC 8305) so curl can prefer
IPv6 when both are available.The synchronous portion is just the dispatch (sub-ms). When both
workers have completed, a single line fires from the originating
event loop with both halves of the breakdown:A(queue N + work N)— IPv4 worker timing.queue— wall time waiting in the ThreadPool work queue.work— wall time insidegetaddrinfofor AF_INET.
AAAA(queue N + work N)— same for the IPv6 worker.total— wall-clock from dispatch to both workers reporting
completion. May be much longer than what the user actually
waited on if the slower side completed after the promise had
already resolved.
Either side reporting
-for its fields would mean its results
haven't been merged yet (only matters if you're reading partial state
in a debugger; the log only fires after both completed). A large
asymmetry between A and AAAAwork(e.g., A=20 ms, AAAA=10000 ms)
is a textbook stub-resolver-drops-AAAA pattern, but it no longer
matters for the user's wait time — we resolved the promise off the
fast side.system-resolver-join-pending— a worker is already runninggetaddrinfofor this name; we attached to its promise instead of
spawning a second worker.async-query— sent a query over our DNS socket.join-pending— joined an in-flight async DNS query for the same name.repeat-timeout/no-conn-dnssec-rejected— error paths.
A consistent ~30 ms floor on cache-hit would indicate the cache is
not being short-circuited and we are paying lookup overhead per request.
A system-resolver-bg completed in N ms (worker) line with a large N
no longer freezes the event loop — it just means that one cold lookup
took a long time. Other requests should keep flowing during it.
`UI wire-cookie:` — cookie-jar lookup time on the UI side
UI wire-cookie: get_cookie(URL) took N ms (N bytes returned)Emitted by the UI process when CookieJar::get_cookie takes more than
5 ms. Pair this with the cookie field in wire^::
- High
wire^:cookie + matchingUI wire-cookie:⇒ the jar itself is
slow. - High
wire^:cookie + noUI wire-cookie:⇒ the UI process IPC
was scheduled late (UI process busy with something else), but the
jar lookup itself was fast.
Wire vs decoded — the foot-gun
wire X.X KiB on the first line and the byte counts on the wire+:
line measure different things:
- wire bytes = encoded body on the network. This is what dictates
network time. - decoded bytes = bytes our consumer (the HTML parser, image
decoder, etc.) sees. This is what dictates CPU work after download.
Comparing them gives you the compression ratio. Don't divide one by the
other's time and call it "throughput" — that's how you accidentally
report a network at 1.3 MB/s when the wire is doing 130 KB/s.
Reused-connection timing
libcurl's phase markers (namelookup, connect, appconnect) report
0 on a reused connection because no DNS / TCP / TLS work happened
again. The logger clamps each marker to the previous one's value so a
reused connection shows dns 0 + tcp 0 + tls 0 and folds the queue
time into queue only. If you see a req value larger than zero on a
reused HTTP/2 / HTTP/3 connection, that is the time spent acquiring a
stream slot and writing the request, not redundant queue counting.
Patterns and what they mean
| Symptom | Likely cause |
|---|---|
One large wire max gap mid-transfer, low stall count, fast bytes either side (wire++:) |
Server-side streaming / SSR with early flush. Origin sent the head, paused to generate the rest, then dumped it. Network is fine. |
Many small stalls, low average throughput, no big gap (wire++:) |
Bandwidth- or congestion-limited link, or HTTP/3 / TLS stack pacing. |
Long single wait before any body, then everything fast (wire:) |
Slow origin (server took its time computing the response). Not a network issue. |
wire thru close to your link bandwidth, no stalls (wire++:) |
You're saturating the pipe. Nothing to fix. |
High tls on first request, tls 0 afterwards (wire:) |
Cold connection setup. Expected on first hit to a host. |
Large our-dns with dns 0 on wire: (wire^:) |
Our DNS resolver is the bottleneck — libcurl reports 0 because we pre-resolved. Look at the resolver, not the network. |
Large cookie (wire^:) |
Cookie IPC round-trip to the UI process is slow — the UI process is busy or contended. |
Non-zero drain delay (wire^:) |
We noticed completion later than curl did — event-loop delay between curl's done-message landing and check_active_requests running. |
wire-batch: followed by N wire: lines with the same timestamp prefix |
Cosmetic clustering. The network completion times were spread out; only the logging happened in a tight loop. Trust the per-request phase numbers. |
If wire+ shows stalls but wire++ doesn't, the network was steady and
curl's decompressor was batching — not interesting. If wire++ shows
the stall too, it really happened on the wire and the gap text tells
you when in the transfer it landed.
For a worked example of using these lines to diagnose a 1.5 s "slow
download" that turned out to be a 1062 ms server pause inside a
streaming-SSR response, see the discussion that originally added this
logging.
Where the code lives
Most of this is implemented in Services/RequestServer/Request.cpp:
log_network_activityemits thewire:line.record_chunkpopulates the decoded-sideWireStats;log_chunk_stats
emits thewire+:line.on_xferinfo(registered viaCURLOPT_XFERINFOFUNCTION) populates the
wire-side fields;log_chunk_statsalso emits thewire++:line.mark_lifecycle_eventrecords pre-network timestamps from the state
handlers (handle_dns_lookup_state,handle_retrieve_cookie_state,notify_retrieved_http_cookie,handle_fetch_state,handle_connect_state);log_chunk_statsalso emits thewire^:line.- The per-request
WireStatsentry is created in theRequest
constructors (socreated_atis the true creation time) and removed
in~Request.
Process-wide diagnostic lines:
wire-batch:,wire-stall:,wire-burst:are all inServices/RequestServer/ConnectionFromClient.cpp. The stall and burst
detectors live at file scope asnote_event_tick,time_curl_call,
andburst_state_by_client; they are invoked from each event-handler
entry point.wire-pipe-pressure:lives inRequest::write_queued_bytes_without_blocking
inServices/RequestServer/Request.cpp. It also feeds the back-pressure
totals appended towire^:.LibDNS wire-dns:lives inLibraries/LibDNS/Resolver.h, inResolver::lookup— aScopeGuardaround the function body times the
synchronous portion and classifies the resolution path.UI wire-cookie:lives inLibraries/LibWebView/Application.cpp, in
theon_retrieve_http_cookiecallback set up bylaunch_request_server.
All of the above lines are gated by the REQUESTSERVER_WIRE_DEBUG
debug macro (defined in Meta/CMake/all_the_debug_macros.cmake,
defaults to ON). To silence the whole subsystem at compile time,
set it to OFF and rebuild. Each individual line additionally
self-suppresses under its own threshold (100 ms event-loop gap,
50 ms curl call, 50 ms back-pressure window, 5 ms DNS sync, 5 ms
cookie jar) so even with the macro on, the log only fires when
something is interesting. Adjust the per-line thresholds in the
source if you want them tighter or louder.
Documentation ScreenReaderSupport (`Documentation/ScreenReaderSupport.md`)
Screen-reader support
Ladybird exposes its internal accessibility tree to the Orca and VoiceOver screen readers — and to other platform assistive technologies (AT) — by serializing tree data from the WebContent process and presenting it through platform-specific wrapper objects in the UI process.
On macOS, we use LadybirdAccessibilityElement, an Objective-C wrapper that implements the NSAccessibility protocol. It bypasses Qt’s QMacAccessibilityElement Cocoa bridge almost entirely: an NSView overlay hosts the wrapper objects, and a few very small, targeted swizzles on Qt’s Cocoa classes redirect Qt’s bridge to that overlay.
On Linux, we use Qt’s built-in AT-SPI2 bridge (QSpiAccessibleBridge) — by way of AccessibilityInterface (a QAccessibleInterface wrapper), along with a custom Orca script.
[!NOTE]
Qt’s built-in AT-SPI2 bridge implements direct communication over the Linux AT-SPI2 D-Bus accessibility protocol — rather than, say, making calls using the ATK C API, which is what Firefox and Chromium use for AT-SPI2 D-Bus communication.
The core underlying “infrastructure” code for IPC serialization, tree management, and WebContent-side tree building is shared across all platforms and ports/UIs.
Key technologies
This section explains key technologies referenced throughout the rest of the document.
AT-SPI2
AT-SPI2 is the Linux desktop-wide accessibility-bus protocol: a D-Bus protocol through which applications publish an object tree. Platform assistive technologies (AT) — screen readers, screen magnifiers, accessibility-tree inspectors — connect to the same bus and query that object tree.
The protocol defines interfaces on each accessible object: Accessible, Text, Component, Action, Hypertext, Hyperlink, Document, Table, Value, Selection, and a few others. Each interface is a D-Bus interface with methods like GetText(start, end) or GetCaretOffset().
There is no AT-SPI2 spec; instead, the protocol is defined by its reference implementation, at-spi2-core. The canonical surface for the AT-SPI2 protocol is that at-spi2-core reference implementation’s set of D-Bus interface XML files: Accessible.xml, Text.xml, Component.xml, Hypertext.xml, Hyperlink.xml, Document.xml, Cache.xml, and so on).
The client-side C library for AT-SPI2 is libatspi, which wraps the raw D-Bus calls in a GObject API; pyatspi is its Python binding, and Orca uses both. A libatspi API reference documents the same interfaces from the client side, and the ATK API reference complements that — because AT-SPI2 interfaces mirror their ATK counterparts almost one-to-one, so the ATK docs effectively describe the semantics of the matching AT-SPI2 methods.
ATK
ATK is a C/GObject API that actually predates AT-SPI2 and that’s historically been the shape of how GTK apps expose or “serve” their accessibility trees to clients. An ATK object is a GObject subclass which implements interfaces like AtkText, AtkComponent, etc. There’s a separate libatk-bridge-2.0 library that takes an ATK tree at runtime and republishes it over the AT-SPI2 D-Bus to clients. Chromium, Firefox, and GTK3 apps (though not GTK4 apps) all implement ATK objects to serve/expose their accessibility trees, and rely on libatk-bridge-2.0 to handle the D-Bus-side client communication.
Qt AT-SPI2 bridge
Qt’s AT-SPI2 bridge (QSpiAccessibleBridge, compiled into QtGui.so) plays the same role that libatk-bridge-2.0 plays for ATK-based apps: It takes a tree of QAccessibleInterface objects (Qt’s platform-agnostic accessibility API) and talks AT-SPI2 D-Bus directly — without going through ATK or libatk-bridge-2.0.
The Qt AT-SPI2 bridge activates automatically when Qt detects a screen reader on the session bus, and there’s no supported way to disable it — and that constraint drives many of the design decisions recorded in other parts of this document.
Orca and VoiceOver
The GNOME project’s Orca utility is the standard screen reader on Linux. It’s an AT-SPI2 client written in Python, uses pyatspi/libatspi to handle the AT-SPI2 communication with applications, and is what ultimately announces the accessibility tree to users. Orca has an extension system: Per-application Python modules/scripts in ~/.local/share/orca/orca-scripts/ that subclass orca.scripts.default.Script (or web.Script for browsers) to customize behavior for a specific application.
Apple’s VoiceOver utility is the standard screen reader on macOS. It talks to applications via the NSAccessibility protocol (the macOS equivalent to AT-SPI2).
The view of web content that Orca and VoiceOver “see” is a snapshot of the accessibility tree — roles, names, bounds, states, and text — exposed by the browser engine to the browser’s UI process. The browser UI process transforms the accessibility tree into platform-specific AT protocol traffic, and Orca and VoiceOver receive that traffic and stitch the tree back together from it, and then speak the contents of the tree to users.
On Linux, the Qt AT-SPI2 bridge is a component in our Qt-based browser UI process which turns the Qt-side accessibility tree (QAccessibleInterface wrappers that get built around accessibility data from the browser engine) into AT-SPI2 D-Bus traffic.
On macOS, there is no separate bridge component: LadybirdAccessibilityElement (NSObject wrappers in the UI process, built around accessibility data from the browser engine) implements the NSAccessibility protocol directly — and VoiceOver uses the NSAccessibility protocol to query those wrappers across process boundaries.
Architecture
/* Detailed source-code truncated for AI context efficiency. */That mostly follows the Chromium/Firefox model rather than the WebKit model:
- No dependency on private Apple APIs (WebKit uses the private
NSAccessibilityRemoteUIElementAPI) - The accessibility wrapper objects live in the UI process
- The platform-agnostic parts (
AccessibilityNodeDataIPC data format, IPC endpoints, WebContent-side serialization,AccessibilityTreeManagerUI-process tree manager) are shared between the two platforms
On Linux specifically, the Qt AT-SPI2 bridge and the custom Orca script work as a pair: the bridge publishes our tree over AT-SPI2 D-Bus in whatever shape we can give it through QAccessibleInterface, and the Orca script closes the gaps that remain on the Orca side (through Python subclass overrides and a handful of runtime monkey patches).
IPC data format: AccessibilityNodeData
AccessibilityNodeData (in Libraries/LibWebCommon/WebView/, which both WebContent and the UI link) is the serialization format. Each node carries:
id/parent_id/child_ids— tree structure via flat ID referencesrole— ARIA role string (button,heading,banner, etc.)name/description/value— accessible name, description, valuebounds— bounding rect in CSS pixels (viewport-relative)is_focused/is_disabled/is_editable/is_multi_line/is_read_only/is_required/is_invalid/is_multi_selectable/is_pressed/is_visited— individual state bitsheading_level— forh1—h6and explicitaria-levellive—aria-livevalue (assertive,polite, or empty)- Table geometry:
column_span,row_span,cell_row_index,cell_column_index,table_row_count,table_column_count,column_header_ids,row_header_ids,table_caption_id,table_summary keybinding,checked_state,expanded_state- Text formatting:
font_family,font_size,font_weight,font_style,color,background_color,text_decoration - Caret/selection:
caret_offset,selection_start,selection_end - Text layout:
character_offsets(per-character viewport positions),line_break_character_offsets,line_heights— used by macOS text-marker navigation
The IPC payload is a flat Vector<AccessibilityNodeData> with parent/child ID references, rather than a nested tree. That simplifies IPC encoding and lets the AccessibilityTreeManager build its own lookup tables.
IPC endpoints
| Endpoint | Direction | Purpose |
|---|---|---|
request_accessibility_tree(page_id) |
UI —> WebContent | Request full tree snapshot |
perform_accessibility_action(page_id, node_id, action) |
UI —> WebContent | AT-triggered action: press, click, focus, scroll_into_view |
inspect_accessibility_tree(page_id) |
UI —> WebContent | Debug JSON dump for DevTools |
did_get_accessibility_tree(page_id, nodes) |
WebContent —> UI | Full tree response (also used for live updates) |
did_accessibility_focus_change(page_id, node_id) |
WebContent —> UI | Focus changed |
did_inspect_accessibility_tree(page_id, json) |
WebContent —> UI | Debug JSON response |
See the Linux: QAccessibleInterface with custom Orca scripts section below for what perform_accessibility_action’s scroll_into_view value does — and why it’s distinct from focus.
WebContent-side serialization
AccessibilityTreeNode::serialize_tree_as_node_data() walks the existing GC-managed accessibility tree (the same tree used for DevTools JSON inspection) and produces the flat vector. For each node:
- Role from
element—>role_or_default()viaARIA::role_name() - Name/description from the existing ACCNAME 1.2 implementation
- Bounds from
element—>get_bounding_client_rect() - Heading level from
aria_level()(works for bothh1—h6androle="heading" aria-level="N") - Live value from explicit
aria-live, or implicit from role (alert —> assertive, status/log/marquee/timer —> polite) - Table geometry from the grid layout
- Text formatting from computed style
update_layout() must be called before building the tree — because exclude_from_accessibility_tree() checks layout_node(), which asserts layout is up-to-date.
Text children of head (e.g., title text) are filtered out during serialization, since they’re not visible content. Text leaf nodes inherit their parent element’s bounding rect — since text DOM nodes don’t have their own layout box.
UI-process tree manager: AccessibilityTreeManager
AccessibilityTreeManager (in Libraries/LibWebView/) is a platform-agnostic C++ class that caches the tree in the UI process. It provides:
node(id)— lookup by node IDroot()— document root nodehit_test(point)— recursive hit testing (reverse child order, deepest node wins)set_focused_node(node_id)— updates focus trackingtext_leaves_in_order()— flat DFS-ordered list of text leaf node IDs for text-marker navigationupdate_tree(nodes)— replaces the tree, witharia-livechange detection: compares old and new node names, fireson_live_region_changedcallback when content changes inside a live region
macOS: NSAccessibility wrapper
LadybirdAccessibilityElement (in UI/Qt/) is an NSObject subclass implementing the NSAccessibility “informal” protocol. Each instance wraps one node by holding a node ID and a pointer to the AccessibilityTreeManager. Elements are cached in a dictionary keyed by node ID on the overlay view that hosts them (see Linux: QAccessibleInterface with custom Orca scripts below).
Why NSObject, not NSAccessibilityElement?
Chromium uses NSAccessibilityElement as its base class. We tried that but encountered two problems:
NSAccessibilityElement’s synthesized read-write properties (internal ivar storage) competed with our getter overrides — the accessibility system read the stored nil values rather than calling our methods.accessibilityParameterizedAttributeNamesoverrides were never called —NSAccessibilityElementswallowed them.
NSObject with the “informal” protocol gives us full control. Tradeoff: we have to override certain “modern” property API methods that NSAccessibilityElement would provide automatically. In particular, we found that missing overrides for accessibilityWindow and accessibilityTopLevelUIElement caused VoiceOver to treat nested elements as orphaned (not part of any window) — breaking VoiceOver forward navigation into nested groups.
Likewise accessibilityShouldUseUniqueId, which NSAccessibilityElement answers YES and NSObject answers NO. Without a unique id, AppKit identifies an element to an assistive technology by its path of child indices from the window: Every request resolves that path again, asking each ancestor for its AXChildren on the way down — enough work on a big page for VoiceOver to report “Ladybird not responding”. And a handle VoiceOver holds then names a position, not an element. So LadybirdAccessibilityElement answers YES, as WebKit’s wrappers do, and gives the id up (NSAccessibilityUnregisterUniqueIdForUIElement) when its node leaves the tree or it’s deallocated — AppKit’s table of ids doesn’t retain the elements.
Dual API support: “informal” and “modern”
The macOS accessibility system queries objects via two code paths:
- Older “informal” protocol —
accessibilityAttributeValue:,accessibilityAttributeNames,accessibilityIsIgnored - “Modern” property API —
accessibilityRole,accessibilityTitle,accessibilityChildren, etc.
Both paths must return consistent data. The “informal” protocol is the source of truth, with “modern” property overrides that delegate to accessibilityAttributeValue:. All relevant modern overrides must be present; on NSObject, the defaults return nil, and VoiceOver may treat nil-returning elements as invalid.
Role mapping
ARIA roles map to NSAccessibility roles via aria_role_to_ns_role(). Landmark roles (banner, navigation, main, etc.) map to NSAccessibilityGroupRole, with AXLandmark* subrole strings. NSAccessibilityRoleDescription() doesn’t recognize the AXLandmark* subrole strings (it returns group for all of them) — so custom role description strings are provided, matching what Safari, Chrome, and Firefox all do.
The document role maps to @"AXWebArea" with role description @“HTML content”. That was an undocumented private WebKit string adopted by all browser engines until Apple made it a public constant (NSAccessibilityWebAreaRole) in macOS 26. Without AXWebArea, VoiceOver treats the entire page as a generic group and summarizes it — rather than offering web-specific navigation (rotor, search predicates, text markers).
Tree exclusion
Per the ARIA spec’s tree-exclusion rules, elements in the following cases are excluded from the accessibility tree during build_accessibility_tree(), along with all their descendants:
- Elements with no layout node (
display:none, HTMLhiddenattribute) - Elements with
visibility:hiddenorvisibility:collapse(those have layout nodes but aren’t visually perceptible) - Elements with
aria-hidden=true, including descendants (checked viaElement::is_aria_hidden(), which walks up the ancestor chain)
Elements with role=none or role=presentation are also excluded, but their children are promoted to the parent (per the spec: “their descendants and text content are generally included”). If such an element has global ARIA attributes (like aria-label), the presentational role is overridden and the element isn’t excluded.
Ignored-element handling
Elements with certain roles are “ignored” — transparent to VoiceOver. Their children are promoted to the nearest non-ignored ancestor. The is_ignored_role() static function centralizes this check:
genericrole with no name (HTMLdiv,span,body)paragraphrole with no name
That check is used in four places (all calling the same function):
accessibilityIsIgnored- parent walking in
accessibilityParent collectUnignoredChildren- the search-predicate DFS
Parent-child consistency is maintained: if element C appears in parent P’s accessibilityChildren, then C’s accessibilityParent returns P.
VoiceOver navigation via search predicates
VoiceOver navigates web content (VO+Right/Left) via the AXUIElementsForSearchPredicate parameterized attribute, not via basic tree traversal. Without search predicates, it only does sibling navigation within groups.
AXUIElementsForSearchPredicate must be advertised by accessibilityParameterizedAttributeNames on every element. VoiceOver checks that list on the current element before calling it. If a nested group element doesn’t advertise search predicates, VoiceOver falls back to sibling-only navigation within that group.
The search-predicate implementation does a pre-order depth-first traversal. Two important behaviors:
Leaf-like roles are navigation terminals. Links, buttons, headings, menu items, tabs, radio buttons, checkboxes, and images don’t have their children included in the navigation order. Their accessible name already carries the text content. Without that, for e.g., a link with the text “Planning”, VoiceOver would visit both, “link Planning” and “text leaf Planning” — as separate stops.
Container descendants are skipped. When navigating forward past a container element (e.g., VoiceOver exits a
navand the parent’s search predicate receives thenavas the start element), all descendants of the start element are skipped. In pre-order DFS, descendants form a contiguous block after their ancestor — so if we don’t skip that block, VoiceOver reads it out twice.
shouldGroupAccessibilityChildren returns NO on all elements — to prevent VoiceOver from grouping children by screen position rather than document order.
Actions
accessibilityPerformAction: handles NSAccessibilityPressAction by sending a perform_accessibility_action IPC with action press. On the WebContent side, HTMLElement::click() is called on the target node, looked up by Node::from_unique_id(UniqueNodeID). accessibilitySetValue:forAttribute: handles the focused attribute by sending action focus, which calls HTML::run_focusing_steps() on the target element.
Table navigation
Table-specific attributes are returned conditionally based on role:
- Table elements:
AXRowCount,AXColumnCount,AXRows,AXVisibleRows,AXColumns,AXVisibleColumns,AXHeader(computed from the tree structure) - Row elements:
AXIndex(zero-based row index spanning rowgroups) - Cell elements:
AXRowIndexRange,AXColumnIndexRange(position and span)
Coordinate conversion
Element bounds arrive as CSS pixel rectangles relative to the viewport. On macOS, CSS pixels equal points (the NSView coordinate unit), so no device-pixel-ratio scaling is needed:
NSRect window_rect = [self convertRect:viewRect toView:nil];
return [self.window convertRectToScreen:window_rect];Text markers
Text markers enable cursor-level text navigation. Each marker encodes a node ID (i64) and a character offset (i32), in a 12-byte AXTextMarkerRef. Text-marker attributes are exposed only on the document root element (AXWebArea role) — not on structural containers. We found that exposing them on every element caused VoiceOver to treat containers as text content.
On the AXWebArea itself, three text-marker-related attributes work together to give VoiceOver a starting cursor position: AXStartTextMarker (the document beginning), AXEndTextMarker (the document end), and AXSelectedTextMarkerRange — a zero-width range at the first text leaf. Without AXSelectedTextMarkerRange, on page load, VoiceOver fires its load-complete handler but has no anchor for its cursor — which makes it fail to move into the web-content area.
`AXWebArea` attributes for VoiceOver focus-on-load
Beyond text markers, the document AXWebArea element exposes a small set of attributes that VoiceOver uses to recognize it as the active reading target after page load:
AXFocused: returnsYESexactly when theAXWebAreais the page’s focused UI element — that is, when nothing in the page has the focus. VoiceOver expects the element the application reports as focused to say so itself. When it doesn’t, VoiceOver setsAXFocusedon it, and then again after every tree update — announcing “web content” each time.AXURL: the page URL. VoiceOver compares this against its own page-identity tracking — to decide whether the load is a new page worth re-reading.AXVisibleCharacterRange: a non-empty range — so that VoiceOver doesn’t treat theAXWebAreaas skippable empty content.
Together with AXSelectedTextMarkerRange and the accessibilityFocusedUIElement behavior described below, those attributes match what Safari and Chrome expose on their respective AXWebArea elements after page load. Without them, VoiceOver receives AXLoadComplete — but never moves its cursor into the web content.
Live-region announcements
When AccessibilityTreeManager::update_tree() detects that a node’s name changed, and that node is inside a live region (has a non-empty, non-“off” live value in its ancestor chain), it fires the on_live_region_changed callback. post_accessibility_announcement() (in WebContentViewAccessibility.mm) posts NSAccessibilityAnnouncementRequestedNotification on the main window with NSAccessibilityPriorityHigh for “assertive” or NSAccessibilityPriorityMedium for “polite”.
Live DOM-mutation updates
Once the UI has requested the tree, WebContent pushes a rebuilt tree via did_get_accessibility_tree whenever the DOM changes, and after every accessibility action (press, focus) — since an action can change what the tree reports without mutating the DOM. PageClient::schedule_accessibility_tree_update() debounces the rebuilds with a 200ms timer.
Until that first request, WebContent reports nothing: Page::accessibility_interested() gates the tree pushes and the focus-change reports alike, and only a tree request sets it. And the UIs request the tree only once an assistive technology is present — so users without one pay for neither, just as in Gecko, WebKit, and Blink, none of which report a focus change before an assistive technology turns accessibility on.
WebContentView integration
WebContentView (UI/Qt/WebContentView.cpp) owns the AccessibilityTreeManager, and WebContentViewAccessibility.mm hosts the wrapper objects: its WebContentAccessibilityView overlay acts as the scroll-area container, with a single child, the document root LadybirdAccessibilityElement. The wiring:
- Load finish (a navigation listener) →
request_accessibility_tree(), but only while an assistive technology is present, which Qt reports asQAccessible::isActive(): on macOS,-[QNSView activateQtAccessibility]turns that on at the first accessibility query into any Qt window. Without one, the view never requests the tree — so WebContent never sends it tree updates or focus changes (see Live DOM-mutation updates above). - An assistive technology that turns up later →
accessibilityActiveChanged()(the view is aQAccessible::ActivationObserver) →request_accessibility_tree(), if the view hasn’t requested the tree yet. That tree doesn’t take the first responder. on_accessibility_tree_received→ update manager; thenupdate_accessibility_tree()drops the cached elements of the nodes that left the tree (invalidating them first) and keeps the rest, so an assistive technology’s handles survive a DOM mutation. For the first tree after each load only, it makes the widget’s NSView the window’s first responder, so AppKit resolvesaccessibilityFocusedUIElementthrough it (and thus into theAXWebArea); posts@"AXLoadComplete"(matching what Chromium and Firefox do); and callsNSAccessibilityHandleFocusChanged()— so AppKit refreshes its cached focused-UI-element. Any other tree posts no notification at all, and no tree postsNSAccessibilityLayoutChangedNotification: VoiceOver answers that one by moving its cursor back to theAXWebArea, announcing “frame” — and by settingAXFocusedthere, which takes the focus from whatever element in the page had it.on_accessibility_focus_changed→ update manager, postNSAccessibilityFocusedUIElementChangedNotificationon the node’s element (post_accessibility_focus_changed())on_live_region_changed→ postNSAccessibilityAnnouncementRequestedNotification(post_accessibility_announcement())
accessibilityFocusedUIElement returns the focused DOM element when one is focused (e.g., an `` the user clicked into) — and otherwise returns the AXWebArea (document root). That matches what Safari and Chrome also do. Returning a leaf (heading, link, etc.) instead causes VoiceOver to interpret the focus as “user already navigated to this element” — which makes it unexpectedly skip its read-from-document-beginning behavior.
macOS: the overlay and the swizzles
A WebContentAccessibilityView (NSView overlay) conforms to LadybirdAccessibilityViewProtocol, the protocol through which LadybirdAccessibilityElement reaches its host view, and provides the scroll-area container, element cache, coordinate conversion, and search predicate delegation.
The overlay is added as a subview of the QWidget’s underlying NSView (obtained via QWidget::winId()). It overrides isFlipped to return YES so that Cocoa’s coordinate conversion matches CSS’s top-left origin.
The swizzles on Qt’s Cocoa classes
VoiceOver in a Qt app navigates through QMacAccessibilityElement objects (created by Qt’s cocoa bridge) — not through NSViews. Our overlay is an NSView, so VoiceOver cannot discover it through the view hierarchy alone. Three very small, targeted swizzles on QMacAccessibilityElement connect Qt’s bridge to the overlay:
accessibilityRole: Returns@"AXWebArea"for elements that back aWebContentView.accessibilityChildren: Returns the overlay (which contains the rootLadybirdAccessibilityElement) as the sole child.accessibilityFocusedUIElement: Delegates to the overlay’saccessibilityFocusedUIElement.
A fourth, on QNSView, covers the application’s focused element: AppKit resolves that through the key window’s first responder, which for a Qt window is its QNSView, and Qt answers with the element of the widget that has the keyboard focus, never looking inside it. The override on QNSView’s accessibilityFocusedUIElement (added to that class alone, falling through to NSView’s) answers, for a web view, with what its overlay would: the focused DOM element, or the AXWebArea — the answer Chrome’s RenderWidgetHostViewCocoa, also the first responder, gives from its accessibilityFocusedUIElement.
All other accessibility semantics — role mapping, landmark subroles, role descriptions, search predicates, ignored-element handling, hit testing, actions, text markers — are handled by LadybirdAccessibilityElement.
A minimal WebContentViewAccessible (QAccessibleWidget with Grouping role, no children) is registered via QAccessible::installFactory() so Qt’s bridge creates a QMacAccessibilityElement for the swizzles to act on.
Why swizzles are needed (and alternatives that were tried)
Several swizzle-free approaches were tested:
- Overlay only (no swizzles, no
makeFirstResponder): VoiceOver never discovers the overlay. It stays in the browser UI. makeFirstResponderon the overlay: VoiceOver discovers the overlay and announces “web content”, but enters an infinite re-entry loop (repeating “web content” endlessly). The loop appears to be caused by VoiceOver’s web-content-mode entry logic conflicting with the overlay being the first responder.makeFirstResponderwithoutNSAccessibilityHandleFocusChanged: VoiceOver never discovers the overlay.object_setClassper-instance subclass injection on the QWidget’s NSView: Crashes inQCocoaWindow::safeAreaMargins()because the class change breaks Qt’s internal NSView state.- Swizzling QNSView (the QWidget’s NSView class) instead of
QMacAccessibilityElement: Breaks accessibility for the entire application because QNSView is shared by all widgets.
The three swizzles on QMacAccessibilityElement are the minimum that works for navigation. They are targeted (only affect elements backing a WebContentView), stable (operate within Qt’s existing bridge mechanism), and very small and simple (each is ~5 lines that delegate to the overlay).
Advantages of the overlay approach
An earlier implementation used a QAccessibleInterface subclass (770 lines) that reimplemented all the role mapping, ignored-element handling, text content, actions, and tree navigation that LadybirdAccessibilityElement already provided (for the AppKit port, which Ladybird had at the time). 7 runtime swizzles on QMacAccessibilityElement patched Qt’s bridge to add AXWebArea, landmark subroles, role descriptions, search predicates, focused-element handling, and ListItem ignore behavior.
The overlay approach replaced all of that:
| Earlier approach | Current overlay approach | |
|---|---|---|
| macOS Qt a11y code | AccessibilityInterface (770 lines) + 7 swizzles (464 lines) = 1,234 lines |
Overlay + four swizzles (~520 lines) |
| Shared code | AccessibilityTreeManager only |
AccessibilityTreeManager + LadybirdAccessibilityElement (all semantics) |
Swizzles on QMacAccessibilityElement |
7 (role, subrole, roleDescription, parameterizedAttributeNames, attributeValue:forParameter:, focusedUIElement, isIgnored) | 3 (role, children, focusedUIElement), plus 1 on QNSView (focusedUIElement) |
| What swizzles do | Reimplement web-content semantics inside the swizzle functions | Delegate to the overlay (which delegates to LadybirdAccessibilityElement) |
| Qt-specific role mapping | Full reimplementation in AccessibilityInterface::map_role() |
None |
| Qt-specific search predicates | DFS traversal reimplemented in swizzled accessibilityAttributeValue:forParameter: |
None |
| Element cache | QHash with manual pruning |
NSMutableDictionary on the overlay |
| Adding a new toolkit | Would require another full reimplementation | Reuse overlay pattern with new toolkit’s bridge |
The key insight: the earlier approach treated the Qt port as a separate accessibility implementation that happened to share AccessibilityTreeManager. The overlay approach treats LadybirdAccessibilityElement as the single source of truth for macOS accessibility semantics, with the overlay acting as a thin adapter between Qt’s bridge and the shared elements.
Linux: QAccessibleInterface with custom Orca scripts
On Linux, we use QAccessibleInterface (UI/Qt/AccessibilityInterface.cpp) to expose the accessibility tree through Qt’s built-in AT-SPI2 bridge (QSpiAccessibleBridge). A custom Orca screen-reader script at ~/.local/share/orca/orca-scripts/Ladybird/ extends Orca’s web.Script to close gaps in Qt’s bridge and tune browse-mode behavior for web content.
The implementation is split between C++ (Qt/LibWeb) and Python (Orca):
- C++ side decides what the AT-SPI2 tree looks like (which nodes are exposed, what their text is, what children they have, which states and interfaces they advertise).
- Python side (the Orca script) sits inside Orca’s process and adjusts Orca’s own behavior where the default behavior is wrong for our tree shape or Qt’s bridge’s quirks.
What works:
- Say All (auto-reading through an entire document)
- Flat review (manually reading word-by-word/line-by-line)
- Object navigation through the accessibility tree
- Structural navigation (
Hkey for headings,Kfor links, etc.) - Table-cell navigation
- Focus ring shown during various forms of navigation
- Actions (press, focus, scroll-into-view) on all interactive elements
- Live-region announcements
Tree-exposure rules (C++ side, at a glance)
The shape of the AT-SPI2 tree that Orca sees is determined by four overlapping decisions in UI/Qt/AccessibilityInterface.cpp:
is_ignored_role(role, name). Returns true only forgenericelements with no accessible name:div,span, unnamedbody, etc. Ignored elements are not exposed; their children “bubble up” viacollect_unignored_children()to the nearest non-ignored ancestor. Paragraphs are not ignored — even when they have no name: Orca’s sentence-extension logic during Say All relies on seeingROLE_PARAGRAPHin the tree to stop walking across paragraph boundaries. Without that role present, Orca would merge content across paragraphs up to the nearest heading.is_leaf_like_container_role(role). Returns true for roles that Orca reads as whole units:link,button,heading,img,image,menuitem,tab,checkbox,radio,listitem. For those,build_hypertext()flattens the full descendant text into a single string — so Orca gets readable content in one query, rather than aSee [U+FFFC] for details-style string that, to expand, would require the AT-SPI2 Hypertext interface (but which the Qt AT-SPI2 bridge doesn’t support).interface_cast(QAccessible::TextInterface). Decides which roles advertise a Text interface over AT-SPI2. Text leaves always do. Leaf-like containers do (their flattened text is their content).paragraphis explicitly included — Orca’sis_text_block_element()heuristic requiresAXObject.supports_text(obj)to return true for sentence-boundary logic to fire.listis explicitly excluded — a list’s text over AT-SPI2 would be[U+FFFC][U+FFFC][U+FFFC]…(one marker perlistitem), and without a Hypertext interface, Orca can’t expand those markers, and skips the entire list during Say All. Leaving the list without a Text interface makes Orca descend into listitem children naturally instead.state().focusable. Set to true only for roles that are genuinely keyboard-focusable (button,link, form controls, menu items, tabs, tree items, switches), plus any node that is actually editable or currently focused. Reportingfocusable=truefor every element (some simpler behavior we tried) was found to break Orca’sis_text_block_element()heuristic: that heuristic returns false for any focusable object, so paragraphs/headings/list items that claimed to be focusable were excluded from text-block treatment — collapsing sentence boundaries and leading to an unexpected “resume at the top of the document every time” Say-All bug.
Two helpers serve different callers:
collect_unignored_children(node)– returns all non-ignored children, recursing through ignored intermediates. Used bybuild_hypertext()andfind_unignored_parent()(that is, the internal tree view).collect_exposed_children(node)– returns whatchild()andchildCount()advertise to AT-SPI2, and matches similar handling in Firefox.
Text content: hypertext with flattening
build_hypertext() in AccessibilityInterface.cpp produces the text that QAccessibleTextInterface::text(start, end) returns for a node:
- Text leaves: just the leaf’s own text.
- Leaf-like containers (listitem, heading, link, button, image, menuitem, tab, checkbox, radio):
flatten_descendant_text()recursively concatenates every descendant’s text. The node presents one continuous string, with no U+FFFC markers. - General containers (paragraph, section, table cell, etc.): Returns what Orca expects for a paragraph like
Alpha <a>aaa</a> more.. What Orca sees for that isAlpha [U+FFFC] more.— and Orca’sfind_next_caret_in_orderis supposed to expand the [U+FFFC] marker querying the AT-SPI2 Hypertext interface. But because Qt’s bridge doesn’t implement that, if we returned the default expansion None, that’d cause Say All to skip the link entirely and continue with the text after the U+FFFC.
Focus, and the “accessibility focus target” pseudo-state
Two kinds of “focus” exist in the Linux flow:
- DOM focus – the element the keyboard user has focused (via
Tab,click, or JS). Painted as:focus-visibleby the existing CSS machinery. Orca browse-mode navigation must not move this; the user expectsEnterto activate whatever they focused with the keyboard, regardless of where Orca is reading. - Accessibility focus target – the element Orca is currently speaking/reviewing. The Linux flow paints a focus ring on this target so the screen reader’s current position is observable visually. DOM focus is unchanged.
The “accessibility focus target” behavior is implemented by a new Document::set_accessibility_focus_target(Element*) member on Libraries/LibWeb/DOM/Document.h, and a matching pseudo-class match in Libraries/LibWeb/CSS/SelectorEngine.cpp: :focus-visible now also matches when document.accessibility_focus_target() == &target.element(). The existing focus-ring UA stylesheet rule is reused — no new pseudo-class, no new outline style. Style invalidation mirrors Document::set_target_element(), so the ring repaints on both the old and new targets.
The target is set by a new scroll_into_view accessibility action on the WebContent side:
perform_accessibility_action(page_id, node_id, "scroll_into_view")
—> Element::scroll_into_view(Nearest/Nearest)
—> Document::set_accessibility_focus_target(element)Nothing about DOM focus changes. The ring appears because the selector now matches. When Orca moves on, the action fires again on the new target and the ring moves with it.
The route that ends up calling that action is:
Orca structural nav (H/K/I/L) or Say All iteration
—> AXEventSynthesizer.scroll_to_center(obj) [Orca side]
—> AXText.scroll_substring_to_location(obj) [added by our monkey patch]
—> Atspi.Text.ScrollSubstringTo (D-Bus)
—> AccessibilityInterface::scrollToSubstring [Qt side]
—> perform_accessibility_action(..., "scroll_into_view")The monkey patch is necessary because Orca’s stock scroll_to_center uses ScrollSubstringToPoint — which Qt’s bridge doesn’t route to QAccessibleTextInterface::scrollToSubstring. Qt does route ScrollSubstringTo (offset-based) — so we patch Orca to also call that (see the Orca-side monkey patches section).
The Orca script
Embedded as Qt resources and auto-installed on Ladybird startup to ~/.local/share/orca/orca-scripts/Ladybird/ — and only overwritten when the embedded content differs from what’s on disk) — three files:
__init__.py: re-exportsScriptso Orca’s script loader can find it.script.py:Script(web.Script, _QtToolkitScript). Overridesget_toolkit_name()(returns"Ladybird"),get_utilities()(returns ourUtilities),get_app_key_bindings()(delegates toweb.Script),activate(), andon_focused_changed().The
activate()override sets the structural navigator’s mode toNavigationMode.DOCUMENTon script activation. Without that,H/K/Lstay unregistered until the user tabs into a focusable element inside the page — and pressing them on a freshly loaded page would echo the keystroke rather than structurally navigate.The
on_focused_changedoverride causes browse-mode commands work from the very first document-root Focus event, while also ensuring that typing into the address bar and tabbing through chrome widgets don’t break.script_utilities.py—Utilities(web.Utilities). Class-level overrides:active_document(),get_caret_context(), nd_find_first_content_child(). Also installs several runtime monkey patches at instance-construction time.
Orca-side monkey patches
Each patch closes a specific gap. All are installed from Utilities.__init__, use module-level “patched already?” flags so they only take effect once per Orca session, and wrap try/except around both install and run paths so a patch that fails never takes Orca down with it.
Collection fallback (
_install_collection_fallback_patch).Fixes Qt 6.10’s
Collection::GetMatchesreturning empty even when matches exist. Forward-compatible: on Qt 6.11+ the original returns real results and the fallback branch is never taken.Scroll bridge (
_install_scroll_patch).Wraps
orca.ax_event_synthesizer.AXEventSynthesizer.scroll_to_center. Makes the focus ring get painted during structural navigation and Say All.Say All redirect (
_install_sayall_patch).Wraps
orca.say_all_presenter.SayAllPresenter.say_all. Makes Say All pick up where it last left off.Flat-review zone dedup (
_install_flat_review_dedup_patch).Wraps
orca.ax_utilities.AXUtilities._get_on_screen_objects. Prevents Orca for repeating link text in list items.Flat-review document scope (
_install_flat_review_document_scope_patch).Wraps
orca.flat_review.Context._get_showing_zones. Makes Orca’s flat review continue past the bottom of the current viewport.Flat-review auto-scroll (
_install_flat_review_scroll_patch).Wraps the cursor-advance methods on
orca.flat_review.Context. Makes the viewport follow the flat-review cursor the same way it follows Say All.Hypertext fallback (
_install_hypertext_fallback_patch).Wraps
orca.ax_hypertext.AXHypertext.find_child_at_offset,get_link_start_offset,get_link_end_offset, andget_character_offset_in_parent. Makes Say All read link text as expected.
How the Orca script integrates with Orca
Orca historically loaded per-application scripts from ~/.local/share/orca/orca-scripts/: the orca_bin.py script inserted that directory into sys.path, and for each application, script_manager tried importlib.import_module("orca-scripts.<AppName>") — and, if found, instantiated its Script class.
That mechanism was removed in Orca 49.7 - on the grounds that customization should go through orca-customizations.py instead.
To deal with that Orca change, we still ship our script in the same layout (~/.local/share/orca/orca-scripts/Ladybird/) — but re-register it with Orca via a bootstrap block that Ladybird writes into ~/.local/share/orca/orca-customizations.py on first launch.
That bootstrap adds our orca-scripts/ directory back to sys.path — then monkey-patches ScriptManager._new_named_script so that any app whose name matches a subdirectory in orca-scripts/ loads that user-installed script in preference to a built-in one.
The managed region is delimited by # --- Ladybird-bootstrap-begin ---/# --- Ladybird-bootstrap-end --- marker lines — so anything the user has in the rest of orca-customizations.py is preserved across Ladybird launches.
After that loading occurs, the Script class in our script.py subclasses one of Orca’s built-in script base classes (default.Script, web.Script, or a toolkit script).
We use that mechanism in both “sanctioned” and “unsanctioned” ways:
Sanctioned subclass overrides of
Script(foron_focused_changed, etc.) andUtilities(foractive_document,get_caret_context,_find_first_content_child). Those are documented/supported stable extension points. They’ll keep working as long as Orca preserves its documented script API.Unsanctioned monkey patches of Orca-internal classes (
AXUtilitiesCollection,AXEventSynthesizer,SayAllPresenter,AXUtilities). The fact that we can do those is a consequence of Python’s dynamic nature and Orca’s script loader giving us the same address space as Orca itself. It’s not a documented/supported API Orca intentionally exposes.
The two classes of change have different maintenance costs. Subclass overrides move with the script. Monkey patches depend on the names, signatures, and call sites of internal Orca classes — none of which the Orca project makes stability guarantees about.
If Orca renames AXUtilitiesCollection, restructures SayAllPresenter, or moves scroll_to_center out of AXEventSynthesizer, our corresponding patch will silently stop applying — and the feature it was working around will silently regress.
Every new Orca release needs the patches re-verified. See Candidates for upstreaming into Orca itself below for which of our patches we believe Orca might accept upstream (and thereby let us delete on our side).
Qt AT-SPI2 bridge limitations
The accessibility quality we can deliver for Orca users is bounded by QSpiAccessibleBridge’s coverage of AT-SPI2. Some gaps are worked around on the Orca side by our script; some can only be fixed by patching Qt itself; some are addressed upstream in Qt 6.11 compared to Qt 6.10; and a few remain as architectural properties of the Orca-plus-Qt combination even after patches.
User-visible features vs. what our script recovers
✅ — Our script enables the feature to work as expected.
❌ — Our script cannot recover the feature from the Orca side.
| Feature | Details | |
|---|---|---|
Structural navigation (H/K/I/L/B/F/T/R/—) |
✅ | _install_collection_fallback_patch wraps find_all_with_role and find_all_with_role_and_all_states with a manual DFS fallback when Collection::GetMatches returns empty (Qt 6.10 and earlier) |
active_document() for Say All and structural nav |
✅ | Utilities.active_document() override tries super() first, then cached, then tree-search by role with is_showing disambiguation |
| Say All starting in document content on first page load | ✅ | _install_sayall_patch redirects SayAllPresenter.say_all to the document’s first content child when locus is on chrome |
Say All resumption after Esc |
✅ | Orca keeps locus inside the document during iteration; our tree-exposure rules let Orca’s sentence-extension logic break at paragraph boundaries |
get_caret_context() returning document content instead of window title |
✅ | Utilities.get_caret_context() override |
Focus ring during structural navigation (H/K/I/L) |
✅ | _install_scroll_patch + the scroll_into_view accessibility action + the :focus-visible extension described above |
| Flat review not re-reading embedded link text | ✅ | With text-leaf children hidden from AT-SPI2 for every container (matching Firefox’s EmbeddedObjCollector), text chunks and inline link zones interleave cleanly in the paragraph’s own zone. |
| Flat review reaching content below the viewport | ✅ | _install_flat_review_document_scope_patch expands _get_showing_zones so Orca+L keeps going past the last on-screen zone |
| Flat-review cursor auto-scrolling the page | ✅ | _install_flat_review_scroll_patch wraps flat_review.Context.go_next_*/go_previous_* methods — to match the scroll path Say All uses |
| Flat review reading wrapped text as visual lines | ✅ | Per-line characterRect plus overrides of text*Offset for LineBoundary let Orca cluster each paragraph’s chunks and link zones onto their actual visual lines, rather than flattening into one logical line |
Browse-mode navigation (H/K/L) active from page load |
✅ | Script.activate() + Script.on_focused_changed set NavigationMode.DOCUMENT — so H/K/L work without you needing to manually navigate into a focusable in-page element first |
| Chrome widgets (address bar, tabs) interacting cleanly with structural nav | ✅ | Script.on_focused_changed override suspends structural nav for editable chrome, so tabbing through back/forward/tab-strip works |
AtkHypertext / AtkHyperlink features — Say All walking into links, link-offset-aware navigation |
✅ | Qt’s bridge exposes no Hypertext/ Hyperlink interfaces. _install_hypertext_fallback_patch (re)computes offsets so that Say All can handle links correctly |
| Landmark type distinction | N/A | No patch needed: xml-roles object attribute comes from our QAccessibleAttributesInterface (C++ side), which works on any Qt 6.8+ |
| Heading-level announcement | N/A | Same: level object attribute |
| Orca’s “this is web content” detection | N/A | Same: tag object attribute (Orca’s is_web_element() checks for it) |
AtkDocument features — document-level locale, MIME metadata |
❌ | Same reason; recoverable via the Qt appendix patch |
Qt-bridge bugs, by version
| Qt bridge limitation | Qt 6.10 | Qt 6.11 | Addressed by our Qt patches (appendix)? |
|---|---|---|---|
Collection::GetMatches returns empty |
Broken | Fixed | N/A on 6.11; on 6.10, covered by the Collection fallback patch |
QSpiDBusCache::GetItems() returns an empty array |
Broken | Broken | Yes — Qt appendix patch 1 |
RELATION_EMBEDS not exposed from window to WebDocument child |
Missing | Missing | Yes — Qt appendix patch 2 |
AtkDocument interface not exposed on document root |
Missing | Missing | Yes — Qt appendix patch 3 (Document portion) |
AtkHypertext / AtkHyperlink interfaces not exposed on text containers |
Missing | Missing | Worked around from the Orca side via _install_hypertext_fallback_patch (enough for Say All link reading and sentence extension). Qt appendix patch 3 is a deeper fix, but it’s blocked by a libatspi wrapping bug for full Orca utility |
ScrollSubstringToPoint not routed to QAccessibleTextInterface::scrollToSubstring |
Broken | Broken | Worked around from the Orca side via _install_scroll_patch; a Qt fix would also be welcome |
The only accessibility-related improvement in the Qt 6.11 cycle that materially affects Orca is the Collection::GetMatches fix above: 6.11 added a QSpiMatchRuleMatcher class (src/gui/accessible/linux/qspimatchrulematcher.cpp) that makes Collection::GetMatches return real results. On 6.10 the Collection interface was registered but the method returned an empty array — which is why the Orca script carries _install_collection_fallback_patch: without it, structural navigation is otherwise entirely dead on 6.10.
The AT-SPI2 client-side cache and why `GetItems()` matters
AT-SPI2 defines a Cache D-Bus interface with a GetItems() method. It has two sides:
Server side (in the application’s process): the AT-SPI2 bridge walks the app’s accessibility tree and returns a bulk snapshot — every object’s D-Bus path, parent, children, supported interfaces, name, role, description, state.
Client side (in Orca’s process):
libatspicallsGetItems()when it first encounters an application, stores the response in a local hash table, and serves subsequent queries for role/name/state/children from that table — with no D-Bus round-trips.AddAccessibleandRemoveAccessiblesignals keep the table in sync as the tree changes.
Firefox and Chromium use libatk-bridge-2.0 as their AT-SPI2 bridge. Its GetItems() returns a fully-populated array — so Orca’s client-side cache works as intended. Qt’s QSpiDBusCache::GetItems() returns an empty array. libatspi has no cached data; every query is a D-Bus round-trip — including for static metadata like role and name.
And that “every query is a D-Bus round-trip” problem is the root cause of the multi-second delay we encounter on first Say All: Orca’s web script builds each line of content by querying role, name, text, states, and attributes on each element — and the Qt architecture, every one of those queries crosses the process boundary. Qt appendix patch 1 addresses that by implementing a real GetItems() response.
[!NOTE]
Bothlibatk-bridge-2.0and Qt’sQSpiAccessibleBridgerun inside the application’s process — they are both “in-process” bridges relative to the application. The performance difference is not about where the bridge runs; instead it’s about whether the bridge properly implementsGetItems(), and thereby populateslibatspi’s client-side cache in Orca’s process.
Limitations that remain even with patches
First Say All has a visible startup pause. Even with
GetItemspopulated (Qt patch 1), text and geometry queries (GetText,GetCharacterExtents,GetRangeExtents) are still per-element D-Bus calls, by design; they are too expensive to pre-serialize into the cache reply. Combined with Orca’s own Python and GLib event-loop overhead andspeech-dispatcherstartup, some user-visible delay on the very first Say All will remain. Firefox and Chromium avoid this because their ATK bridge pre-serializes more aggressively.Stale AT-SPI2 proxy references after a tab switch. Orca’s Python-side caches hold
Atspi.Accessibleproxies keyed by D-Bus object path. When Ladybird switches tabs, ourhideEventde-registers the inactive tab’sQAccessibleInterfacewrappers (so the paths become invalid), andscrollToSubstringrefuses to operate on non-visible views — but Orca’s Python proxies still point at the old paths.Say All on the second tab reads correct content (because
_patched_say_alllooks up the current tab’s document viaactive_document()) — butscroll_into_viewcalls on stale proxies can fail silently, so the focus ring may not appear for the first item after a tab switch. Fixing that would require changes to how Orca manages its proxy cache across tree transitions — not reachable from a Qt-bridge or application-side fix.
Orca-side patch lifecycle when Qt 6.11 is the baseline
What should happen to each Orca-side patch once Ladybird’s minimum Qt is 6.11 or later? Short answer: keep all of them. Longer answer:
_install_collection_fallback_patch: Keep until Ladybird drops support for Qt < 6.11. On 6.11+,Collection::GetMatchesreturns real results; the fallback branch is never taken. The wrapper itself stays in the call chain but is dormant. Removing earlier would silently break structural navigation for users still on an older system Qt._install_scroll_patch: Keep until Qt addsScrollSubstringToPointhandling. Qt 6.11’satspiadaptor.cppstill only handlesScrollSubstringTo, notScrollSubstringToPoint(verified directly in the 6.11.0 source). Without the extrascroll_substring_to_locationcall, the focus ring during structural nav would stop working on Qt 6.11 too._install_sayall_patch: Keep indefinitely. This patches Orca-side behavior (starting Say All from locus even when locus is on chrome). It is not a Qt-bridge bug; Qt versioning is irrelevant. It stops being needed only if Orca itself changes how it picks the Say All start element._install_flat_review_dedup_patch: Keep indefinitely. This patches Orca-side behavior (flat review zone-per-object without dedup). Qt version is irrelevant._install_flat_review_document_scope_patch: Keep indefinitely. Orca’s default_get_showing_zonesuses the window rect, which is correct for native toolkit widgets but wrong for web content that scrolls. A Qt-version change won’t affect this. It could become unnecessary if Orca upstream gives web scripts a way to declare a larger review rect._install_flat_review_scroll_patch: Keep indefinitely. Orca’s default flat review doesn’t auto-scroll; Say All does. Closing that gap is Orca-side behavior, not a Qt bug. Could become unnecessary if Orca upstream adopts the same scroll-on-advance behavior for flat review._install_hypertext_fallback_patch: Keep until Qt exposesAtkHypertext/AtkHyperlinkThe fallback is what lets Say All read link text in place. Qt appendix patch 3 addresses the underlying Qt-bridge gap, but alibatspiwrapping bug currently blocks full Orca utility even with that patch. So long as Orca relies onAXHypertextfor hypertext offsets, the fallback stays useful.
The subclass overrides on Utilities and Script (active_document, get_caret_context, _find_first_content_child, activate, on_focused_changed) are normal Python OO method overrides on our extension classes, not runtime patches — they stay as long as the extension does.
Candidates for upstreaming into Orca itself
Which of the monkey patches would plausibly be accepted upstream by the Orca project, and thereby let us delete the corresponding wrapper on our side? Sorted by priority:
scroll_to_centeralso callsScrollSubstringTo. Our_install_scroll_patchadds ascroll_substring_to_locationcall after the existingScrollSubstringToPointattempt. The added call is harmless on toolkits that already handleScrollSubstringToPoint— and recovers focus-ring behavior on toolkits (Qt) that don’t. No plausible downside. One-line upstream fix. Upstream source:orca/ax_event_synthesizer.py::AXEventSynthesizer.scroll_to_center.Flat-review auto-scroll. Our
_install_flat_review_scroll_patchwrapsflat_review.Context.go_next_*/go_previous_*to scroll the current zone into view after each advance — matching Say All’s existing behavior. Orca-side concern; benefits every toolkit with a scroll-substring action on text interfaces (Firefox and Chromium included). Upstream source:orca/flat_review.py::Contextadvance methods, ororca/flat_review_presenter.pyequivalents if upstream prefers the Presenter layer.Say All redirect when
objis None. Our_install_sayall_patchmakesSayAllPresenter.say_allfind the active document’s first content child and start from there when called withobj=Noneand locus is outside document content. Benefits any web context where the user invokes Say All without first clicking into the page; happens in Firefox and Chromium too — just less visibly because their AT-SPI2 trees come up faster. Upstream source:orca/say_all_presenter.py::SayAllPresenter.say_all.Flat review zone dedup. Our
_install_flat_review_dedup_patchfilters zones whose text is contained in an ancestor zone’s text (scoped to leaf-like container ancestors). Orca-side behavior; benefits every browser engine whose leaf-like containers flatten descendant text — Firefox and Chromium have the same architectural setup. Upstream source:orca/ax_utilities.py::AXUtilities._get_on_screen_objects.Flat-review document scope. Our
_install_flat_review_document_scope_patchexpands_get_showing_zones’ bounding box to cover the web document, not just the viewport. Shape of fix depends on Orca’s intent. Upstream source:orca/flat_review.py::Context._get_showing_zones.Hypertext-offset fallback. Our
_install_hypertext_fallback_patchcomputes offsets from the parent’s text and exposed children when the underlying AT-SPI2 call returns -1/None. Orca could adopt the same fallback unconditionally. Would help any engine on a Qt-bridge-style toolkit — not just Ladybird. Upstream source:orca/ax_hypertext.py::AXHypertext.Collection fallback to manual tree search.
_install_collection_fallback_patchwrapsfind_all_with_roleandfind_all_with_role_and_all_states. As Qt 6.11 spreads, the affected case will shrink — but other AT-SPI2 implementations (custom toolkits, embedded environments, older Qt that distros may carry for years) would still benefit. Possible pushback: Orca maintainers may take the position that broken-Collection is a toolkit bug Orca shouldn’t paper over. Upstream source:orca/ax_utilities_collection.py.active_document()fallback to tree search. Same shape of argument and same possible pushback as #7. Tree search is slower than the EMBEDS relation, so the override must be conditional (only fall back if EMBEDS returned nothing). Upstream source:orca/scripts/web/script_utilities.py::Utilities.active_document.
Probably not upstreamable:
Script.on_focused_changed()chrome-widget handling and un-suspend on document focus. The un-suspend half is structurally similar to whatweb.Scriptalready does — but depends on Ladybird-specific Focus events fired on the document root. The chrome-widget half depends on a clear chrome-vs-document distinction, which is browser-specific and entangled withweb.Script’s existing focus state machine. Upstream would need a redesign against that state machine.Script.activate()settingNavigationMode.DOCUMENT. Needed here only because Ladybird posts a document-root Focus event thatweb.Scriptshort-circuits on. If upstream stopped short-circuiting (or Ladybird stopped relying on the document-root event), the override would be moot.Utilities.get_caret_context()fallback. Possibly upstreamable, but the rationale is entangled withactive_documentand_find_first_content_child; untangling for upstream submission would be non-trivial.
The _install_*_patch functions themselves (the runtime mechanism that mutates Orca’s classes via setattr) are scaffolding that exists only because the underlying fix isn’t upstream yet. Once any of the changes land in Orca itself, the corresponding wrapper becomes a no-op and can be removed.
Alternative: the private-bridge prototype
Before the current Qt-bridge-plus-Orca-script design was settled on, we tried prototyping a different path: Implement a full ATK bridge ourselves, run it on a private D-Bus instance, and have our Orca script consult that private bus whenever Qt’s built-in bridge let Orca down.
The prototype looked like an attractive way to essentially make us match how every other major Linux browser works — modulo the fact that the other engines don’t need to implement their own private bridges to achieve it. The goal was to work around the three serious gaps in Qt’s bridge on Qt 6.10 that we had run into and that actively harm user experience of web content for Orca users: Collection::GetMatches returns empty, Cache::GetItems returns empty, and AtkHypertext/AtkHyperlink/AtkDocument aren’t exposed at all. The private bridge seemed like a possible way to close all three gaps at once — because we could use libatk-bridge-2.0 to populate the cache and route all the interfaces correctly.
What the prototype was constituted from
On the Ladybird side, the private-bridge prototype added the following major new chunks of code:
PrivateAccessibilityBus(~280 lines of C++ code). Spawned an isolateddbus-daemonfor our Orca script to pick up.AtkBridge(~400 lines of C++ code).dlopenedlibatk-bridge-2.0.so.0at runtime (matching Firefox’s approach inaccessible/atk/Platform.cpp), forked anat-spi2-registrydchild onto the private bus, and ran a dedicated pthread that pumped the bridge’s privateGMainContext.LadybirdAtkObject(~1900 lines of C++ code). Per-node GObject/ATK wrapper with dynamic GType registration per interface combination (the pattern Chromium uses inAXPlatformNodeAuraLinux). It implemented the full ATK surface Qt doesn’t expose — 11 interfaces:AtkObject,AtkText,AtkHypertext,AtkHyperlink, etc.OrcaScripts(additional ~675 lines of Python code). Connected to the private bus, used it as a fast oracle, and mapped results back to Qt-bridgeAtspi.Accessibleobjects. Orca otherwise still navigated Qt-bridge objects normally; the private bus was only ever used for operations the Qt bridge did poorly.
Total additional footprint: ~3500 lines of new C++, Python, and build glue, plus two new runtime dependencies (at-spi2-core development headers and libatk-bridge-2.0 at load time).
Why we abandoned the prototype
Four things came up in practice that made the cost-benefit calculation flip:
The bridge needed a registryd on the private bus, not just a daemon — and the process-lifecycle code we had to implement as as result of that ended up being relatively large and unwieldy.
Private-bus registration didn’t come for free, either — and relied on an undeclared ABI from a library whose maintainers explicitly don’t consider those symbols stable.
The hoped-for performance win didn’t get delivered: the very first Say All still had a noticeable startup delay — and ordinary arrow-key reading was already fast enough with the Qt bridge that the prototype didn’t make it noticeably any faster.
We still needed all the same custom runtime-monkey-patching Orca scripts: Because the private bus in practice could only be used as an additional oracle — not a full replacement — Orca still received events from Qt’s bridge, and still needed the same monkey patches for Say-All-redirect, etc.
So in the end, all the additions — adding ~3500 lines of new code, two extra child processes, dependence on unexported ATK symbols, and no reduction in runtime-monkey-patching by our custom Orca scripts — resulted in neither any real end-user performance benefits, nor any new end-user features beyond what we could already deliver with a far-smaller footprint and far-less-complicated architecture.
macOS: Browser-engine source code research
The Ladybird accessibility implementation was informed by studying the Blink/Chromium, WebKit/Safari, and Gecko/Firefox accessibility code. On macOS, all three implement the macOS accessibility protocol directly — they create their own Objective-C wrapper objects (not going through a toolkit bridge), giving them full control over role mapping, search predicates, and tree traversal. None of them need “workarounds” for the issues Qt’s bridge creates (see Qt: shared LadybirdAccessibilityElement on macOS above).
The remaining subsections of this section mostly provide macOS-specific details about the platform code for Chromium, Firefox, and WebKit. Information about the Linux platform code for Chromium and Firefox is in other parts of the document.
Chromium
Key files: `ui/accessibility/platform/ax_platform_node_cocoa.h` / `.mm`, `ui/accessibility/platform/one_shot_accessibility_tree_search.h` / `.cc`What we adopted:
IPC serialization model. Chromium serializes the accessibility tree via Mojo IPC into the browser process. That is the fundamental architecture we adopted.
OneShotAccessibilityTreeSearch. Chromium implementsAXUIElementsForSearchPredicatevia a depth-first tree-search class that skips subtrees based on search criteria. That informed our search-predicate implementation — particularly the concept of “leaf-like” roles whose children aren’t individually navigable.AXWebArearole. Chromium maps the document role to@"AXWebArea"(viaNSAccessibilityWebAreaRole) — which causes VoiceOver to offer web-specific navigation features (rotor categories, search predicates, text markers). That behavior isn’t documented by Apple; it is inferred from observation.Custom landmark role descriptions. Chromium has custom role-description strings for landmarks — rather than relying on
NSAccessibilityRoleDescription, which doesn’t recognizeAXLandmark*subrole strings. Landmark subroles are mapped via a staticBuildSubroleMap()function.
Where we diverge:
Base class.
AXPlatformNodeCocoainherits fromNSAccessibilityElement, notNSObject. We tried that but hit property-competition issues (see “Why NSObject, not NSAccessibilityElement” above). Chromium makesNSAccessibilityElementwork because it has a more elaborate initialization path and implements both the older “informal” protocol and “modern” property API in tandem.Dual API. Chromium implements both the older “informal” protocol (
accessibilityAttributeValue:,accessibilityAttributeNames,accessibilityIsIgnored) and the “modern” property API (accessibilityRole,accessibilityTitle, etc.) — with a migration flag to shift between them. We also implement both, but our informal protocol is the source of truth — with modern overrides delegating to it.Text markers on all elements. Chromium exposes text-marker parameterized attributes (
AXTextMarkerForPosition,AXAttributedStringForTextMarkerRange, etc.) on all elements — not just the document root. We restrict text markers to theAXWebAreaelement — because we found that exposing them on every element caused VoiceOver to treat structural containers as text content.No
shouldGroupAccessibilityChildren. Chromium doesn’t implement this method at all. We returnNOon all elements — to prevent VoiceOver from grouping children by screen position, rather than document order.ListItem not ignored. Chromium maps
ListItemtoNSAccessibilityGroupRoleand doesn’t ignore it — it’s a full navigation stop. Chromium’sOneShotAccessibilityTreeSearchhandles the navigation semantics so that VoiceOver doesn’t double-stop.
WebKit
Key files: `Source/WebCore/accessibility/mac/WebAccessibilityObjectWrapperMac.mm`, `Source/WebCore/accessibility/mac/AccessibilityObjectMac.mm`, `Source/WebCore/accessibility/AXObjectCache.h`What we adopted:
Coordinate system. By comparing against Safari’s element bounds in Accessibility Inspector, we confirmed that CSS pixels equal points on macOS — no device-pixel-ratio scaling is needed.
Landmark role descriptions. Safari provides custom role-description strings for landmarks — confirming that
NSAccessibilityRoleDescription()doesn’t recognizeAXLandmark*subrole strings.
Architecture notes:
Private API. WebKit uses Apple’s private
NSAccessibilityRemoteUIElementMach-port token-exchange API for cross-process accessibility. We chose not to use that approach.Search predicates.
handleUIElementsForSearchPredicateAttribute()parses VoiceOver’s search dictionary into anAccessibilitySearchCriteriastruct and delegates to a multi-frame search engine. The search is more sophisticated than ours — it handles cross-frame searches and a wider set of search keys.Lazy cache.
AXObjectCachecreates accessibility wrapper objects on demand viagetOrCreate()and maps them via multiple lookup tables (Node → AXID, RenderObject → AXID). Objects are removed when the DOM node is destroyed.
Firefox
Key files: `accessible/mac/mozAccessible.h` / `.mm`, `accessible/mac/MOXAccessibleBase.h` / `.mm`, `accessible/mac/MOXSearchInfo.mm`What we adopted:
Same IPC model as Chromium. Firefox serializes the accessibility tree from the content process to the parent process via IPDL IPC — the de facto standard for multi-process browsers.
Pre-order depth-first traversal. Firefox’s
Pivotclass (accessible/base/Pivot.h/.cpp) implements pre-order DFS for search predicates.Pivotis a stack-allocated tree searcher that takes a root accessible and traverses withNext()/Prev()usingPivotRulesubclasses.MOXSearchInfocreates specificRotorRulesubclasses (RotorRoleRule,RotorLinkRule,RotorControlRule, etc. inaccessible/mac/RotorRules.h) for each VoiceOver search key (AXHeadingSearchKey,AXLinkSearchKey, etc.).Search predicate parameters. Firefox’s
MOXSearchInfoprocessesAXDirection,AXStartElement, andAXResultsLimitfrom the search-predicate dictionary — the same fields we handle.
Where Firefox aligns with us (and differs from Chromium):
NSObject base class. Firefox’s
mozAccessibleinherits fromNSObject(viaMOXAccessibleBase), notNSAccessibilityElement. That is the same choice we made — suggesting theNSAccessibilityElementbase-class issues are a known difficulty.Modern protocol as primary API. Firefox defines a
MOXAccessibleProtocolwith property-based getters (moxRole,moxSubrole,moxTitle, etc.) and bridges the older informal protocol methods to them. The protocol serves as an internal abstraction layer.
Where we diverge from Firefox:
Text markers on all elements. Like Chromium, Firefox exposes text-marker support on all elements (via
moxTextMarkerDelegate), not just the root. Our restriction toAXWebAreais specific to our implementation.ListItem as a dedicated class. Firefox has a
MOXListItemAccessiblesubclass with specific overrides (e.g.,moxTitlereturns empty to avoid verbose announcements). ListItems aren’t ignored.No
shouldGroupAccessibilityChildren. Firefox doesn’t implement this, same as Chromium.
Key cross-engine observations for Chromium, WebKit, and Firefox on macOS
- All three engines implement NSAccessibility directly on their own wrapper objects. None go through a toolkit bridge.
- All three map the document root to
AXWebArea. - All three provide custom role-description strings for landmarks.
- All three implement
AXUIElementsForSearchPredicatedirectly. - Chromium and Firefox both serialize the tree to the UI process; only WebKit does that differently (via private
NSAccessibilityRemoteUIElementAPI). - Firefox uses
NSObjectas the base class (like us); Chromium usesNSAccessibilityElement. - Both Chromium and Firefox expose text markers on all elements; we restrict them to the document root.
- Neither Chromium nor Firefox implements
shouldGroupAccessibilityChildren.
Why our macOS implementation diverges from Firefox/Chromium/WebKit implementations
The divergence of Ladybird’s macOS accessibility implementation and that of other browsers isn’t caused by the IPC model — Chromium and Firefox also serialize over IPC; instead, the divergence stems from differences in wrapper-object lifecycle and tree management:
Snapshot vs. live tree. Our macOS NSAccessibility wrappers are thin shells over a full-tree snapshot that is replaced wholesale on each update. Chromium and Firefox back their wrappers with rich, live object models that support incremental updates and on-demand attribute computation.
Purpose-built traversal. Chromium’s
OneShotAccessibilityTreeSearchand Firefox’sPivotare sophisticated tree-traversal engines with predicate matching, subtree skipping, and cross-frame support. Our search predicate is a simpler DFS that required explicit leaf-role and container-descendant handling to avoid double-reading.Years of iteration. For macOS, Chromium and Firefox have accumulated extensive VoiceOver interaction debugging over many years. Constraints we discovered through testing (text markers restricted to
AXWebArea,shouldGroupAccessibilityChildrenreturningNO,NSAccessibilityElementproperty competition) are the kind of platform-specific quirks that the other engines must have either solved structurally — or else, because of their architecture — maybe never encountered to begin with.
AccessKit
AccessKit is a Rust library that provides cross-platform accessibility adapters for macOS (NSAccessibility), Linux (AT-SPI2), and Windows (UIA). It handles the platform plumbing — role mapping, element lifecycle, event notifications, coordinate conversion — so applications don’t have to implement each platform’s accessibility protocol directly.
Key files: platforms/macos/src/node.rs (role mapping, attributes, actions), platforms/macos/src/context.rs (element caching), platforms/macos/src/event.rs (notifications), consumer/src/filters.rs (tree filtering).
What AccessKit handles well:
Role mapping. ~140 roles including
AXWebAreafor web content, landmark subroles (AXLandmarkNavigation,AXLandmarkMain, etc.), and custom role descriptions.NSAccessibilityElementbase class. AccessKit successfully usesNSAccessibilityElementas its base class (we switched toNSObjectdue to property-competition issues; Firefox also usesNSObject).Incremental tree updates.
Tree::update_and_process_changes()supports partial updates, unlike our full-snapshot replacement.Filtering model.
FilterResult::Include/ExcludeNode/ExcludeSubtreeis cleaner thanaccessibilityIsIgnored—ExcludeNodepromotes children without needing platform-specific ignore swizzles.Element lifecycle. HashMap cache with on-demand creation, proper cleanup, and
UIElementDestroyedNotificationon drop.Focus forwarding. Patches NSWindow to forward
accessibilityFocusedUIElementto the content view — solving the same initial-focus problem we solved withmakeFirstResponder.Cross-platform. A single tree representation serves macOS, Linux (AT-SPI2), and Windows (UIA). The Linux adapter would give us AT-SPI2 support without relying on Qt’s bridge.
What AccessKit does not provide for web content:
No
AXUIElementsForSearchPredicate. Without search predicates, VoiceOver falls back to sibling-only navigation within groups — it cannot do VO+Right/Left linear navigation through web content. That is the most complex part of our implementation, and even if we used AccessKit, we would still need to build that part ourselves.No
AXTextMarker. No cursor-level text navigation. AccessKit has NSRange-based text attributes but not the native marker objects that web content requires for character-by-character navigation and text selection.No web-content navigation semantics. AccessKit is designed for native app accessibility, not browser-engine accessibility. The DFS traversal logic (leaf-role skipping, container-descendant skipping, search-key filtering) that makes VoiceOver navigation work smoothly in web content appears not to be part of AccessKit — and that seems like a critical deficiency.
Assessment: AccessKit would eliminate some of the relatively low-implementation-effort platform plumbing we’re doing — but not the hard parts. The web-specific code — search predicates, text markers, container-descendant skipping, ignored-element semantics for web content — would still need to be implemented on top. So the trade-off would be adding a dependency for what amounts to a cleaner version of the platform-plumbing layer. But even then, with AccessKit, it seems we’d still be lacking the DFS traversal logic (leaf-role skipping, container-descendant skipping, search-key filtering) that makes VoiceOver navigation work smoothly in web content. And that on its own is a critical deficiency that would make AccessKit unsuitable for our needs.
Future work: search-key filtering on macOS
Our macOS search-predicate implementation returns all non-ignored elements in DFS order without filtering by the AXSearchKey parameter. VoiceOver sends search keys like AXHeadingSearchKey, AXLinkSearchKey, AXLandmarkSearchKey, etc., to request elements of a specific type. Currently VoiceOver handles the filtering itself, but implementing search-key filtering on our side would improve performance and correctness.
Chromium’s OneShotAccessibilityTreeSearch (ui/accessibility/platform/one_shot_accessibility_tree_search.h / .cc) uses PredicateForSearchKey() to map each VoiceOver search key to a C++ predicate function, then walks the tree returning only matching elements. Firefox’s MOXSearchInfo (accessible/mac/MOXSearchInfo.mm) does the same — via PivotRule subclasses. Both approaches avoid returning the entire flattened tree when VoiceOver only wants headings or links.
Tests
Four categories of tests cover four different layers: tree-dump tests, Qt AT-SPI2 bridge tests, Orca-integration tests, and macOS NSAccessibility tests.
Tree-dump tests (`Tests/LibWeb/Accessibility/`)
An added Accessibility test mode in test-web follows the same basic pattern as Layout and Text tests:
- Tests are HTML files in
Tests/LibWeb/Accessibility/input/ - Expectations files are in
Tests/LibWeb/Accessibility/expected/ - The expectations tree-dump format shows the raw tree structure, including ignored elements and text leaves.
- You can use
test-web --rebaselineto generate/update the expectations files.
These catch role/name/ancestor regressions observable in the serialized data. They do not catch what our Qt AT-SPI2 bridge emits over D-Bus (role mapping, U+FFFC placement, per-character rects, event timing, action routing, multi-tab lifecycle), nor what the macOS NSAccessibility wrapper exposes at runtime, nor what Orca or VoiceOver output.
Qt AT-SPI2 bridge tests (`Tests/LibWeb/AccessibilityBridge/tests/`)
Python tests that launch a real Ladybird process on a fixture HTML page, connect as an AT-SPI2 client via gi.repository.Atspi, and assert on what the bridge actually exposes. These catch bridge-level regressions we can’t see just with a tree dump
Orca-integration tests (`Tests/LibWeb/AccessibilityBridge/tests_orca/`)
Python tests that import Orca and our Ladybird script into the test process, instantiate the script against the live Ladybird AT-SPI2 accessible, monkey-patch speech.speak, drive Orca commands directly, and assert on what Orca would have spoken.
macOS NSAccessibility tests (`Tests/LibWeb/AccessibilityBridgeMac/`)
Python tests that launch a Ladybird process on a fixture HTML page, connect as an NSAccessibility client via PyObjC + AXUIElement, and assert on what the macOS AppKit accessibility surface actually exposes — and on what VoiceOver would see while walking that surface.
[!NOTE]
We have no VoiceOver tests that correspond to our Orca tests — because VoiceOver is a closed binary, and there’s no public hook for capturing synthesized speech without installing a custom TTS driver.
Fixtures are reused from Tests/LibWeb/AccessibilityBridge/input/ rather than duplicated — since the role/name/structure invariants those fixtures exercise are platform-agnostic.
Running the tests requires the Accessibility (TCC) permission to be granted to the calling shell/IDE in System Settings → Privacy & Security → Accessibility.
File guide
`Libraries/LibWeb/CSS/`
SelectorEngine.cpp
- Extends the
:focus-visiblepseudo-class match to also match when the element is the document’s accessibility focus target (set by the accessibilityscroll_into_viewaction, described under Linux: QAccessibleInterface with custom Orca scripts above). That gives keyboard and screen-reader navigation the same focus-ring behavior without actually moving DOM focus.
`Libraries/LibWeb/DOM/`
AccessibilityTreeNode.cpp / AccessibilityTreeNode.h
- Walks the existing GC-managed accessibility tree and produces a flat
Vector<AccessibilityNodeData>. Extracts role, name, description, bounds, heading level,aria-livevalue, and focus state for each node. For text-leaf nodes, also populates per-character layout data.
Document.cpp / Document.h
- Builds the accessibility tree and then serializes it. Notifies the page client when the focused element changes — once the UI has requested the tree.
- Has a mechanism for driving Orca focus-ring display movement without moving DOM focus.
`Libraries/LibWeb/Page/Page.h`
- Has a mechanism for notifying the UI process when focus changes. Also has
accessibility_interested(), which the first tree request sets, and which gates the focus-change reports and the tree pushes.
`Libraries/LibWebCommon/`
WebView/AccessibilityNodeData.cpp / WebView/AccessibilityNodeData.h
- IPC-serializable struct that represents one node in the accessibility tree. Contains id, parent/child IDs, role, name, description, value, bounds, focus/disabled state, heading level, and
aria-livevalue — along with the IPCencode/decodespecializations.
Forward.h
- Forward declaration for
AccessibilityNodeData.
WebView/PageInfo.h
- Adds
AccessibilityTreeto thePageInfoTypeenum (used bytest-webfor accessibility tree dumps).
`Libraries/LibWebView/`
AccessibilityTreeManager.cpp / AccessibilityTreeManager.h
- Platform-agnostic class that caches the accessibility tree in the UI process. Provides node lookup, hit-testing, focused-node tracking, and DFS-ordered text-leaf enumeration. On
update_tree(), compares old and new trees — to detectaria-liveregion content changes.
ViewImplementation.cpp / ViewImplementation.h
- Has
request_accessibility_tree()andperform_accessibility_action()methods, pluson_accessibility_tree_receivedandon_accessibility_focus_changedcallbacks.
WebContentClient.cpp / WebContentClient.h
- Client-side IPC handlers for
did_get_accessibility_treeanddid_accessibility_focus_changemessages from WebContent. Dispatches to theViewImplementationcallbacks.
`Services/WebContent/`
ConnectionFromClient.cpp / ConnectionFromClient.h
- Builds and sends the full tree (and on the first request, sets
accessibility_interested()), and hasperform_accessibility_action— which looks up DOM nodes byUniqueNodeIDand dispatches to thefocusandscroll_into_viewaction handlers. Also implements theAccessibilityTreepage-info dump fortest-web.
PageClient.cpp / PageClient.h
- Sends focus-change IPC and pushes tree updates when the page changes — once the UI has requested the tree.
WebContentServer.ipc
- Has
request_accessibility_tree(page_id)andperform_accessibility_action(page_id, node_id, action)endpoints.
WebContentClient.ipc
- Has
did_get_accessibility_tree(page_id, nodes)anddid_accessibility_focus_change(page_id, node_id)endpoints.
`UI/Qt/` (both platforms)
WebContentView.h / WebContentView.cpp
- Owns the
AccessibilityTreeManagerand element cache. Sets up IPC callbacks, and requests the tree once an assistive technology is present: at load finish ifQAccessible::isActive(), or throughaccessibilityActiveChanged()for one that turns up later. Posts the document-root accessibility focus event. RewiresQAccessibleTextInterface::scrollToSubstringto thescroll_into_viewaccessibility action.
`UI/Qt/` (macOS-specific files)
LadybirdAccessibilityElement.h / LadybirdAccessibilityElement.mm
- The main NSAccessibility wrapper. Handles role mapping, ignored-element transparency, search-predicate navigation, hit testing, actions, table-navigation attributes, text-marker navigation, and coordinate conversion. Also exposes the extra attributes VoiceOver uses to anchor its cursor on page load.
LadybirdAccessibilityViewProtocol.h
- The protocol the host view implements for
LadybirdAccessibilityElement: element lookup, screen/view coordinate conversion, action dispatch, page URL, and first-responder state.WebContentAccessibilityViewconforms to it.
WebContentViewAccessibility.h / WebContentViewAccessibility.mm
- The NSView overlay that conforms to
LadybirdAccessibilityViewProtocoland owns the element cache, plus the three swizzles onQMacAccessibilityElement. Handles tree-update notifications, focus changes, and live-region announcements. Ensures the sharedLadybirdAccessibilityElementcan answerAXURLandAXFocusedon theAXWebArea.
`UI/Qt/` (Linux files)
AccessibilityInterface.h / AccessibilityInterface.cpp
QAccessibleInterfaceimplementation. MapsAccessibilityNodeDatanodes to Qt’s accessibility API. Also containsWebContentViewAccessible: aQAccessibleWidgetthat bridges theWebContentViewwidget into Qt’s accessibility tree.
Application.cpp
- Enables loading of our custom Orca script, suppresses some log messages that end up flooding the console when Orca’s in use, and fixes a problem that prevents Orca and other AT clients from seeing our accessibility tree.
`UI/Qt/OrcaScripts/Ladybird/`
Custom Orca screen-reader scripts, embedded as Qt resources and auto-installed to ~/.local/share/orca/orca-scripts/Ladybird/ on startup. A bootstrap block written into ~/.local/share/orca/orca-customizations.py re-registers that directory with Orca’s script manager (required due to Orca 49.7 having removed the loading mechanism it had previously supported).
script.py
- Extends Orca’s
web.Scriptwith overrides foractivate()andon_focused_changed().
script_utilities.py
- Extends Orca’s
web.Utilitieswith overrides foractive_document(),get_caret_context(), and_find_first_content_child(). Installs several runtime monkey patches (described in the Linux: QAccessibleInterface with custom Orca scripts section).
__init__.py
from .script import Script
Appendix: Qt AT-SPI2 bridge patches
The patches shown in this section are against Qt 6.11.0 (src/gui/accessible/linux/) and complement the “Qt-bridge bugs, by version” section above. They’ve not been upstreamed to the Qt sources; instead they’re included here as a way of having a record of them until they can do get upstreamed (at which point this appendix should be removed).
How to use these patches
Here is a full sequence of instructions creating a Ladybird build linked against Qt 6.11.0 with our patches applied.Get the Qt 6.11.0 sources. Either fetch the official source archive and extract it:
shcurl -LO https://download.qt.io/official_releases/qt/6.11/6.11.0/single/qt-everywhere-src-6.11.0.tar.xz tar xf qt-everywhere-src-6.11.0.tar.xzOr clone qtbase only (sufficient for these patches, since they only touch
src/gui/accessible/linux/):shgit clone --branch v6.11.0 --depth 1 https://code.qt.io/qt/qtbase.git qtbase-6.11.0-srcApply the patches. Save each fenced
diffblock from this appendix to its own file (e.g.patch1.diff,patch2.diff,patch3.diff); then, from inside the qtbase source tree, run:shpatch -p1 < /path/to/patch1.diff patch -p1 < /path/to/patch2.diff patch -p1 < /path/to/patch3.diffConfigure and build Qt. AT-SPI2 support requires
pkg-configand the AT-SPI2 development package —libatspi-devon Debian/Ubuntu,at-spi2-core-develon Fedora — to be installed before configuring. Then, from a fresh build directory next to the source tree:shmkdir build-qt-6.11-patched cd build-qt-6.11-patched ../qtbase-6.11.0-src/configure -accessibility -feature-accessibility-atspi-bridge -prefix /opt/qt-6.11.0-patched cmake --build . --parallel cmake --install .The
accessibilityandaccessibility-atspi-bridgefeatures are on by default if their dependencies are present; passing them explicitly is just to fail loudly if anything is missing.Build Ladybird against the patched Qt. Set
CMAKE_PREFIX_PATHso Ladybird’sfind_package(Qt6 REQUIRED COMPONENTS Core Widgets)(inUI/Qt/CMakeLists.txt) discovers the patched build, then run the normal build entry point:shCMAKE_PREFIX_PATH=/opt/qt-6.11.0-patched ./Meta/ladybird.py buildEquivalent forms:
sh# Pointing CMake directly at the Qt6 cmake config dir: Qt6_DIR=/opt/qt-6.11.0-patched/lib/cmake/Qt6 ./Meta/ladybird.py build # If the patched Qt was installed over an aqtinstall layout at $HOME/Qt/6.11.0: CMAKE_PREFIX_PATH=$HOME/Qt/6.11.0 ./Meta/ladybird.py buildThe environment variable only takes effect on the first configure pass —
Meta/ladybird.pyshort-circuits configure ifBuild/release/build.ninjaalready exists, and CMake then uses the cached Qt path on subsequent builds. To switch an existing build directory from system Qt to the patched Qt, delete it first:shrm -rf Build/release CMAKE_PREFIX_PATH=/opt/qt-6.11.0-patched ./Meta/ladybird.py buildVerify the link. Confirm Ladybird picked up the patched Qt rather than the system one:
shldd Build/release/bin/Ladybird | grep libQt6GuiThe output should reference
libQt6Gui.so.6under your install prefix (/opt/qt-6.11.0-patched/lib/...in the example above) — not a system path such as/usr/lib/....
Patch 1: QSpiDBusCache::GetItems populates the AT-SPI2 cache
Addresses the _“`QSpiDBusCache::GetItems()` returns an empty array”_ limitation. Firefox and Chromium pre-populate the AT-SPI2 cache through `libatk-bridge-2.0`, so that Orca can do its initial tree walk locally; Qt’s stub returns an empty array — forcing Orca to make per-element D-Bus calls for every node it needs to inspect. This patch walks the active `QAccessibleInterface` tree from `qApp` and returns a populated `QSpiAccessibleCacheArray` with path, parent, children, supported interfaces, name, role, description, and state for each accessible./* Detailed source-code truncated for AI context efficiency. */Patch 2: Expose EMBEDS relation from window to WebDocument child
Addresses the _“No `RELATION_EMBEDS` from window to `WebDocument` child”_ limitation. Orca’s `Utilities.active_document()` normally walks the AT-SPI2 EMBEDS relation from the active window to its embedded web document; stock Qt doesn’t expose that relation, so `active_document()` returns `None` and Orca falls back to its own tree search (which we also duplicate on the Ladybird side in `script_utilities.py`, for robustness).This patch extends AtSpiAdaptor::relationSet() so that for window-class accessibles (QAccessible::Window / Grouping / Pane) it walks up to five levels of children looking for a QAccessible::WebDocument — and if found, appends an ATSPI_RELATION_EMBEDS entry pointing at that document’s D-Bus path. That makes Utilities.active_document()’s fast path succeed on Qt-based browsers without requiring the Orca-side fallback.
/* Detailed source-code truncated for AI context efficiency. */Patch 3: Expose AT-SPI2 Document, Hypertext, and Hyperlink interfaces
Addresses the _“`AtkDocument` interface not exposed” and “`AtkHypertext` / `AtkHyperlink` interfaces not exposed”_ limitations. Together, those three interfaces let Orca treat a web document as a true hypertext container — with per-link offsets, link enumeration, document-level locale, and MIME attributes — rather than just a tree of text leaves and elements.Three separable additions in one patch:
org.a11y.atspi.Document: minimal stub inhandleMessage()that answersGetLocale,GetAttributeValue, andGetAttributesqueries (the latter reportsDocType/MimeType=text/html).accessibleInterfaces()advertises this interface onQAccessible::WebDocumentaccessibles so Orca finds it.org.a11y.atspi.Hypertext: newhypertextInterface()method answeringGetNLinks,GetLink, andGetLinkIndexby walking the accessible’s child list and treating non-StaticTextchildren as embedded “links” in hypertext terms. Note thataccessibleInterfaces()does not advertise Hypertext — see the comment in the diff:libatspi’s client wrapsGetLinkresults asHyperlinkobjects, andGetObjecton those paths returnsHyperlinkinstead ofAccessible, breaking Orca’s subsequent queries. The method is reachable via direct D-Bus probing by assistive technologies that don’t rely onlibatspi’s Hypertext wrapping.org.a11y.atspi.Hyperlink: newhyperlinkInterface()method answeringNAnchors,GetObject,GetURI,IsValid, and start/end index queries. Start/end indices are computed by walking siblings and summingStaticTextcharacter counts and U+FFFC object-replacement-character widths for non-text siblings.
The Document stub is clean and should be straightforward to upstream. Hypertext/Hyperlink is partial for the reason above — the libatspi interaction needs to be resolved before this is useful to libatspi-based assistive technologies, including Orca.
/* Detailed source-code truncated for AI context efficiency. */Documentation SmartPointers (`Documentation/SmartPointers.md`)
AK smart pointers
Introduction
There are three main C++ smart pointer types used in Ladybird. Each type describes the ownership (or lack thereof) of the pointee.
The reason for using these pointers is to make it explicit through code who owns which resources, and how ownership is transferred. They also serve as a guard against memory leaks and use-after-free bugs.
OwnPtr\ and NonnullOwnPtr\
OwnPtr is used for single-owner objects. An object held in an OwnPtr is owned by that OwnPtr, and not by anybody else.
This means that the OwnPtr is responsible for deleting the pointee when the OwnPtr goes out of scope.
These pointers cannot be copied. Transferring ownership is done by moving the pointer.
NonnullOwnPtr is a special variant of OwnPtr with one additional property: it cannot be null. NonnullOwnPtr is suitable as a return type from functions that are guaranteed to never return null, and as an argument type where ownership is transferred, and the argument may not be null. In other words, if OwnPtr is "*", then NonnullOwnPtr is "&".
Note: A NonnullOwnPtr can be assigned to an OwnPtr but not vice versa. To transform an known-non-null OwnPtr into a NonnullOwnPtr, use OwnPtr::release_nonnull().
Construction using helper functions
There is a make<T>() helper that constructs a new object and returns it wrapped in a NonnullOwnPtr. All arguments passed to it are forwarded to T's constructor. If it fails to allocate heap memory for the object, it terminates the program.
{
NonnullOwnPtr<Foo> my_object = make<Foo>();
my_object->do_stuff();
// my_object goes out of scope here, and the Foo will be deleted.
}The try_make<T>() helper attempts to construct a new object wrapped in an ErrorOr<NonnullOwnPtr<T>>. All arguments passed to it are forwarded to T's constructor. In case of allocation failure, an ENOMEM error is returned. This allows the calling code to handle allocation failure as it wishes.
auto my_object_or_error = try_make<Foo>();
if (my_object_or_error.is_error()) {
// handle allocation failure...
}
auto my_object = my_object_or_error.release_value();
my_object->do_stuff();Note: Objects constructed using try_make<T>() should only be dereferenced after a null check.
Manual construction
The helper functions cannot access private constructors, so in some cases, smart pointers need to be created manually. This is done by "adopting" a raw pointer, which moves its ownership to the smart pointer. Dereferencing the raw pointer or calling its destructor afterwards can cause undefined behavior.
Known non-null pointers can be turned into a NonnullOwnPtr by the global adopt_own() function.
NonnullOwnPtr<Foo> my_object = adopt_own(*new Foo);It is safe to immediately dereference this raw pointer, as the normal new expression cannot return a null pointer.
Any (possibly null) pointer to T can be turned into an OwnPtr<T> by the global adopt_own_if_nonnull() function.
OwnPtr<Foo> my_object = adopt_own_if_nonnull(new (nothrow) Foo);In this case, the non-throwing new should be used to construct the raw pointer, which returns null if the allocation fails, instead of aborting the program.
Note: Always prefer the helper functions to manual construction.
RefPtr\ and NonnullRefPtr\
RefPtr is used for multiple-owner objects. An object held by a RefPtr is owned together by every pointer pointing to that object.
Shared ownership is implemented via reference counting.
NonnullRefPtr is a special variant of RefPtr with one additional property: it cannot be null. NonnullRefPtr is suitable as a return type from functions that are guaranteed to never return null, and as an argument type where the argument may not be null. In other words, if RefPtr is "*", then NonnullRefPtr is "&".
Objects can only be held by RefPtr if they meet certain criteria. Specifically, they need to implement the functions ref() and unref().
To make a class T reference-counted, you can simply make it inherit from RefCounted<T>. This will add all the necessary pieces to T.
class Bar : public RefCounted<Bar> {
...
};Note: A NonnullRefPtr can be assigned to a RefPtr but not vice versa. To transform an known-non-null RefPtr into a NonnullRefPtr, either use RefPtr::release_nonnull() or simply dereference the RefPtr using its operator*.
Construction using helper functions
There is a make_ref_counted<T>() global helper function that constructs a new object and returns it wrapped in a NonnullRefPtr. All arguments passed to it are forwarded to T's constructor. If memory cannot be allocated for the object, the program is terminated.
NonnullRefPtr<Bar> our_object = make_ref_counted<Bar>();
NonnullRefPtr<Bar> another_owner = our_object;The try_make_ref_counted<T>() function constructs an object wrapped in ErrorOr<NonnullRefPtr<T>> which may be an error if the allocation does not succeed. This allows the calling code to handle allocation failure as it wishes. All arguments passed to it are forwarded to T's constructor.
auto our_object_or_error = try_make_ref_counted<Bar>();
if (our_object_or_error.is_error()) {
// handle allocation failure...
}
NonnullRefPtr<Bar> our_object = our_object_or_error.release_value();
RefPtr<Bar> another_owner = our_object;In the above examples, the Bar object will only be deleted once both our_object and another_owner are gone.
Manual construction
The helper functions cannot access private constructors, so in some cases, objects need to be manually wrapped into smart pointers. When constructing an object that derives from RefCounted, the reference count starts out at 1 (since 0 would mean that the object has no owners and should be deleted). The object must therefore be "adopted" by someone who takes responsibility of that 1. The raw pointer must not be used after its ownership is transferred to the smart pointer.
A known non-null raw pointer can be turned into a NonnullRefPtr by the global adopt_ref() function.
NonnullRefPtr<Bar> our_object = adopt_ref(*new Bar);Note: It is safe to immediately dereference this raw pointer, as the normal new expression cannot return a null pointer.
Any (possibly null) pointer to a reference-counted object can can be turned into a RefPtr by the global adopt_ref_if_nonnull() function.
RefPtr<Bar> our_object = adopt_ref_if_nonnull(new (nothrow) Bar);In this case, the non-throwing new should be used to construct the raw pointer, which returns null if the allocation fails, instead of aborting the program.
Note: Always prefer the helper functions to manual construction.
WeakPtr\
WeakPtr is used for objects that somebody else owns. When the pointee of a WeakPtr is deleted, the WeakPtr will magically become null.
Behind the scenes, this is implemented using the Weakable template. If you want to make it possible for a class T to be weakly-pointed-to, have it inherit from Weakable<T>.
To create a WeakPtr to a weakable object, use make_weak_ptr():
class Baz : public Weakable<Baz> {
....
};
WeakPtr<Baz> a_baz;
{
NonnullOwnPtr<Baz> my_baz = make<Baz>();
a_baz = my_baz->make_weak_ptr();
// a_baz now points to my_baz
}
// a_baz is now null, since my_baz went out of scope.Documentation StringFormatting (`Documentation/StringFormatting.md`)
String Formatting
Many places in Ladybird allow you to format strings, similar to printf(), for example ByteString::formatted()
, StringBuilder::appendff(), or dbgln(). These are checked at compile time to ensure the format string matches the
number of parameters. The syntax is largely based on
the C++ std::formatter syntax
but there are some differences.
For basic usage, any occurrences of {} in the format string are replaced with the other arguments, converted to string
form, in order:
ByteString::formatted("Well, {} my {} friends!", "hello", 42) == "Well, hello my 42 friends!";If you want to include a literal { in the output, use {{:
ByteString::formatted("{{ {}", "hello") == "{ hello";You can refer to the arguments by index, if you want to repeat one or change the order:
ByteString::formatted("{2}{0}{1}", "a", "b", "c") == "cab";To control how the arguments are formatted, add colon after the optional index, and then add format specifier
characters:
ByteString::formatted("{:.4}", "cool dude") == "cool";
ByteString::formatted("{0:.4}", "cool dude") == "cool";Format specifiers
In order, the format can contain:
- Fill character and alignment
- Sign
#Hash0Zero- Width
- Precision
- Type specifier
Each of these is optional. You can include any combination of them, but they must be in this order.
Fill and alignment
This is an optional fill character, followed by an alignment. The fill character can be anything apart from { or },
and is used to fill any space left when the input has fewer characters than the format requests. By default, it is a
space. ( )
The alignment characters are:
<: Align left.>: Align right.^: Align centered.
Sign
+: Always display a sign before the number.-: Display a sign for negative numbers only.- (space): Display a sign for negative numbers, and a leading space for other numbers.
Hash
# causes an "alternate form" to be used.
For integer types, this adds the number-base prefix after the sign:
0bfor binary.0for octal.0xfor hexadecimal.
Zero
0 pads the number with leading zeros.
Width and Precision
The width defines the minimum number of characters in the output. The precision is a . followed by a precision number,
which is used as the precision of floating-point numbers, or a maximum-width for string values.
Both the width and precision can be provided as a replacement field ({}, optionally including an argument index) which
allows you to use an integer argument instead of a hard-coded number.
Type specifiers
| Type | Effect | Example output |
|---|---|---|
| nothing | default format | Anything! :^) |
| b | binary | 110, 0b000110 |
| B | binary uppercase | 110, 0B000110 |
| d | decimal | 42, +0000042 |
| o | octal | 043 |
| x | hexadecimal | ff0, 0x00000ff0 |
| X | hexadecimal uppercase | FF0, 0X00000FF0 |
| c | character | a |
| s | string | well, hello friends! |
| p | pointer | 0xdeadc0de |
| f | float | 1.234, -inf |
| a | hex float | |
| A | hex float uppercase | |
| hex-dump | hexadecimal dump | fdfdfdfd, 3030 00 |
Not all type specifiers are compatible with all input types, of course.
Formatting custom types
You can provide a custom AK::Formatter<Foo> class to format Foo values. For the simplest case where you already have
a function that produces a string from your type, that would look like this:
template<>
struct AK::Formatter<Web::CSS::Selector> : Formatter<StringView> {
ErrorOr<void> format(FormatBuilder& builder, Web::CSS::Selector const& selector)
{
return Formatter<StringView>::format(builder, selector.serialize());
}
};More advanced formatters that make check for format-specifier flags can be written by referring to the fields
in StandardFormatter (which most Formatter classes extend).
Detecting if a type can be formatted
The AK::HasFormatter<T> template has a boolean value representing whether T can be formatted.
The FormatIfSupported<T> makes use of this to return either the formatted value of T, or a series of ?s if the
type cannot be formatted. For example:
// B has a Formatter defined, but A does not.
ByteString::formatted("{}", FormatIfSupported { A {} }) == "?";
ByteString::formatted("{}", FormatIfSupported { B {} }) == "B";Documentation TabPerformanceMonitor (`Documentation/TabPerformanceMonitor.md`)
Tab performance monitor
Enable Show tab performance monitor in Settings → Advanced, or setdebug.ui.show_tab_performance_monitor to true. It defaults to false.
The UI's toolbars display the current tab's statistics inline.
Each tab retains its own CPU history while monitoring is enabled.
LibWebView::TabPerformanceMonitor resolves browser ownership and suppliesTabPerformanceStats to the frontend. TabPerformanceAccumulator performs
counter differencing and windowing independently of the UI and OS APIs.
Metric definitions
- CPU: CPU-time deltas divided by actual monotonic elapsed time. 100% is
one logical CPU; values above 100% are expected. All live and terminated
threads in exclusively owned WebContent and WebWorker processes contribute.
Registered page and embedded-frame ownership identify WebContent owners.
Worker ownership is resolved transitively, including nested workers and
shared workers whose owners all resolve to the same tab. Processes shared
across tabs, unresolved owners and cyclic ownership remain unattributed.
The first sample establishes a baseline. The sparkline retains ten seconds. - MEM: An estimate of owned process memory, counted once per process.
macOS uses the kernel's physical-footprint ledger, including compressed
memory, rather than RSS. Linux uses resident minus shared pages from/proc/PID/statm, a cheap private-resident approximation that omits
file-backed private pages and swapped-out memory. These platform estimates
are not directly comparable. Other platforms report unavailable until a
collector is implemented. Shared helper/cache allocations and resources
without a process-level ownership charge are excluded. - Download/upload: HTTP response/request body bytes transferred by curl,
before response decompression. Disk-cache hits contribute no traffic.
Headers, TLS/TCP overhead, WebSockets, and helper-initiated background
requests without an originating page or exclusive worker are excluded.
A request keeps its originating process/page context when transferred to
another client. The browser resolves that context to a tab; traffic whose
original page or worker has already disappeared is left unattributed.
Byte units are decimal. Rates use a rolling second, with delayed batches
weighted by their actual sampling interval. - FPS: Accepted compositor backing-store swaps over the last second,
including compositor-driven animation. This measures frames delivered to
the frontend, not hardware display scanout orrequestAnimationFramecalls.
After a second without a frame the value isFPS —. FPS uses neutral text.
CPU, memory and FPS values use neutral text. Labels use 65% opacity and
the sparkline uses a subdued stroke. Network download is blue and upload
orange. Fixed-width columns and tabular digits prevent layout shifts.
Sampling and overhead
The browser samples owned process counters every 500 ms. RequestServer runs
its own 500 ms timer and pushes byte deltas asynchronously to the browser;
there is no UI polling or synchronous traffic-statistics IPC. Completed
requests contribute their final delta before their curl handle is released.
Batches coalesce requests with the same originating process/page.
Disabling the setting destroys both timers, clears histories and pending byte
batches, and disconnects the browser's traffic callback. RequestServer does
not sample curl counters or push traffic messages while disabled. Existing
request setup retains small ownership metadata, and the frame delivery path
has a disabled-state check. Re-enabling establishes fresh CPU and in-flight
request baselines; restarting RequestServer restores an enabled subscription.
Shared helper CPU is currently excluded: the network, image decoder, compiler
and compositor processes do not yet expose per-job CPU accounting. Their
whole-process CPU is never assigned to a tab. Additional explicitly owned
memory or helper CPU can be added to the accounting layer without putting
platform or ownership queries in toolbar widgets.
Documentation Testing (`Documentation/Testing.md`)
Testing Ladybird
Tests are located in Tests/, with a directory for each library.
Every feature or bug fix added to LibWeb should have a corresponding test in Tests/LibWeb.
The test should be either a Text, Layout, Ref, or Screenshot test depending on the feature.
Tests of internal C++ code go in their own TestFoo.cpp file in Tests/LibWeb.
Running Tests
[!NOTE]
To reproduce a CI failure, see the section on Running with Sanitizers.
The easiest way to run tests is to use the ladybird.py script. The LibWeb tests are registered with CMake as a test inUI/CMakeLists.txt. Using the built-in test filtering, you can run all tests with Meta/ladybird.py test or run
just the LibWeb tests with Meta/ladybird.py test LibWeb. The second way is to invoke the test-web test runner
directly with Meta/ladybird.py run test-web.
A third way is to invoke ctest directly. The simplest method is to use the Release preset from CMakePresets.json:
cmake --preset Release
cmake --build --preset Release
ctest --preset ReleaseNote that some tests require the LADYBIRD_SOURCE_DIR environment variable to be set to the root of the ladybird source tree.
# /path/to/ladybird repository
export LADYBIRD_SOURCE_DIR=${PWD}The tests can be run via ninja after doing a build.
cd Build/release
ninja
ninja testTo see the stdout/stderr output of failing tests, the recommended way is to set the environment variable CTEST_OUTPUT_ON_FAILURE to 1.
CTEST_OUTPUT_ON_FAILURE=1 ninja test
# or, using ctest directly...
ctest --output-on-failureJavaScript tests
Build test-js to build the runtime runner and js, and prepare the shared
AST/bytecode snapshot runner. From the build directory, run:
ninja test-js
./bin/test-js
./bin/test-js --suite runtime -f array
./bin/test-js --suite ast --suite bytecode -f class
./bin/test-js --suite bytecode --rebaseline -f classSet LADYBIRD_SOURCE_DIR to the repository root before running the command.
Without --suite, the command runs runtime, AST, and bytecode tests, continuing
through suite failures and returning a nonzero status if any suite fails.
CTest registers this as a single test-js test.
-f / --filter accepts repeatable, case-insensitive substring globs across
all selected suites. --jobs N controls snapshot parallelism. --rebaseline
updates only the selected snapshot expectations; without --suite, it updates
both AST and bytecode expectations. It never updates expectations for a script
that exits unsuccessfully.
-j / --json produces one JSON document with suites and failed_suites.--per-file includes individual runtime results. When selecting only--suite runtime, both options retain the original runtime JSON format.--test262-parser-tests selects only the runtime suite and retains its JSON
format for the test262 results collector.
Additional runtime options and custom test roots are forwarded to the runtime
runner, test-js-runtime; use --suite runtime --help-runtime to list its
options.
For a native debugging session, run test-js-runtime directly with the
original runtime options.
Inspecting test-web results
At the end of a run, test-web prints the location of its results directory.
Open index.html there for the interactive report, or summarize the saved
results from the repository root:
python3 Meta/test-results.py Build/release/path-to-results
python3 Meta/test-results.py Build/release/path-to-results --details -f your-testThe summary lists failed, timed out, and crashed tests with artifact paths and
suggested rerun commands. --details also prints text diffs and captured logs,
including helper process logs for the whole run. The optional filter is a
substring of the artifact name; it does not change the run's summary counts.
The reader accepts either a results directory or its results.js file and
does not execute the JavaScript or rerun tests. A successful read exits with
status zero even when the saved run contains failures.
Rerun commands are intended for the original build directory. Restore any
custom options from the recorded invocation, such as the test root, timeouts,
or repetition count. The reader uses this checkout's Tests/LibWeb to
distinguish query variants from literal @ characters in filenames; use--test-root for a different source tree. Repeated results omit the run-N/
artifact prefix when constructing the filter. No rerun command is suggested
if the corresponding source file cannot be found.
Running with Sanitizers
CI runs host tests with Address Sanitizer and Undefined Sanitizer instrumentation enabled. These tools catch many
classes of common C++ errors, including memory leaks, out of bounds access to stack and heap allocations, and
signed integer overflow. For more info on the sanitizers, check out the Address Sanitizer wiki page,
or the Undefined Sanitizer documentation from clang.
Note that a sanitizer build will take significantly longer than a non-sanitizer build, and will mess with caches in tools
such as ccache. The sanitizers can be enabled with the -DENABLE_FOO_SANITIZER set of flags.
The simplest way to enable sanitizers is to use the Sanitizer preset.
cmake --preset Sanitizer
cmake --build --preset Sanitizer
ctest --preset SanitizerOr from a Lagom build:
To ensure that the test behaves the same way as CI, make sure to set the ASAN_OPTIONS and UBSAN_OPTIONS appropriately.
The Sanitizer test preset already sets these environment variables.
export ASAN_OPTIONS='strict_string_checks=1:check_initialization_order=1:strict_init_order=1:detect_stack_use_after_return=1:allocator_may_return_null=1'
export UBSAN_OPTIONS='print_stacktrace=1:print_summary=1:halt_on_error=1'
cmake -GNinja -B Build/lagom -DENABLE_ADDRESS_SANITIZER=ON -DENABLE_UNDEFINED_SANITIZER=ON
cd Build/lagom
ninja
CTEST_OUTPUT_ON_FAILURE=1 LADYBIRD_SOURCE_DIR=${PWD}/../.. ninja testRunning the Web Platform Tests
The Web Platform Tests can be run with the WPT.sh script. This script can also be used to compare the results of two
test runs.
Example usage:
# Run the WPT tests then run them again, comparing the results from the two runs
./Meta/WPT.sh run --log expectations.log css
git checkout my-css-change
./Meta/WPT.sh compare --log results.log expectations.log css# Pull the latest changes from the upstream WPT repository
./Meta/WPT.sh update
# Run all of the Web Platform Tests, outputting the results to results.log
./Meta/WPT.sh run --log results.logImporting Web Platform Tests
You can import certain Web Platform Tests (WPT) tests into your Ladybird clone (if they're tests of type that can be
imported - and especially if any code changes you're making cause Ladybird to pass any WPT tests it hasn't yet been
passing). Here's how:
./Meta/WPT.sh import html/dom/aria-attribute-reflection.htmlThat is, you give ./Meta/WPT.sh import the path part of any http://wpt.live/ URL for a WPT test you want to import.
It will then download both that test and any of its JavaScript scripts, copy those to the Tests/LibWeb/<test-type>/input/wpt-import
directory, run the test, and then in the Tests/LibWeb/<test-type>/expected/wpt-import directory, it will create a file
with the expected results from the test.
Writing tests
Running the following python script to create new test files with correct boilerplate:
./Tests/LibWeb/add_libweb_test.py your-new-test-name test_typeThe accepted test_type values are "Text", "Layout", "Ref", and "Screenshot".
This will create a new test HTML file in Tests/LibWeb/<test_type>/input with along with a corresponding expectations
file in the appropriate directory in Tests/LibWeb/<test_type>/expected.
After you update/replace the generated boilerplate in your your-new-test-name.html test file with your actual test,
you will need to regenerate the corresponding expectations file to match the actual output from your updated test.
For Text or Layout tests, you can "rebaseline" the tests to regenerate the expectation file:
./Meta/ladybird.py run test-web --rebaseline -f Text/input/your-new-test-name.htmlFor Ref and Screenshot tests, you will need to supply the equivalently rendering HTML manually. Though for Screenshot
tests, you can generate the reference screenshot itself by running Ladybird in headless mode:
./Meta/ladybird.py run ladybird --headless --test-mode Tests/LibWeb/Screenshot/input/your-new-test-name.html
# This will log something like: "Saved screenshot to: ~/Downloads/screenshot-2025-06-07-08-37-45.png"
mv ~/Downloads/screenshot-2025-06-07-08-37-45.png Tests/LibWeb/Screenshot/images/your-new-test-name.pngText tests
Text tests are intended to test Web APIs that don't have a visual representation. They are written in JavaScript and
run in a headless browser. Each test has a test function in a script tag that exercises the API and prints expected
results using the println function. println calls are accumulated into an output test file, which is then
compared to the expected output file by the test runner.
Text tests can be either sync or async. Async tests should use the done callback to signal completion.
Async tests are not necessarily run in an async context, they simply require the test function to signal completion
when it is done. If an async context is needed to test the API, the lambda passed to test can be async.
Layout
Layout tests compare the layout tree of a page with an expected one. They are best suited for testing layout code, but
are also used for testing some other features that have an observable effect on the layout. No JavaScript is needed -
once the page loads, the layout tree will be dumped automatically.
Ref
Reference or "ref" tests compare a screenshot of the test page with one of a reference page. The test passes if the two
are identical. These are ideal for testing visual effects such as background images or shadows. If you're finding it
difficult to recreate the effect in the reference page, (such as for SVG or canvas,) consider using a Screenshot test
instead.
Each Ref test includes a special <link rel="match" href="../expected/my-test-ref.html" /> tag, which the test runner
uses to locate the reference page. In this way, multiple tests can use the same reference.
Screenshot
Screenshot tests can be thought of as a subtype of Ref tests, where the reference page is a single `` tag linking
to a screenshot of the expected output. In general, try to avoid using them if a regular Ref test would do, as they are
sensitive to small rendering changes, and won't work on all platforms.
Like Ref tests, they require a <link rel="match" href="../expected/my-test-ref.html" /> tag to indicate the reference
page to use.
Documentation Troubleshooting (`Documentation/Troubleshooting.md`)
Troubleshooting
In case of an error, you might find an answer of how to deal with it here.
Building Ladybird
CMake fails to configure the build because it's outdated
Ensure your CMake version is >= 3.30 with cmake --version. If your system doesn't provide a suitable
version of CMake, you can download a binary release from the CMake website.
GCC is missing or is outdated
Ensure your gcc version is supported by the build system withgcc --version. Otherwise, install it. If your gcc binary is not called gcc you have to specify the names of your
C and C++ compiler when you run cmake, e.g. cmake ../.. -GNinja -DCMAKE_C_COMPILER=gcc-13 -DCMAKE_CXX_COMPILER=g++-13.
Legacy renegotiation is disabled
Ensure your /etc/ssl/openssl.cnf file has the following options:
[openssl_init]
ssl_conf = ssl_sect
[ssl_sect]
system_default = system_default_sect
[system_default_sect]
MinProtocol = TLSv1.2
CipherString = DEFAULT@SECLEVEL=1
Options = UnsafeLegacyRenegotiation“Targets may link only to libraries. CMake is dropping the item” message (when building with the Qt UI on macOS)
When building with the Qt UI on macOS, you may encounter the following message:
CMake Warning at /opt/homebrew/Cellar/qt/6.7.0_1/lib/cmake/Qt6/FindWrapOpenGL.cmake:48 (target_link_libraries):
Target "ladybird" requests linking to directory "/usr/X11R6/lib". Targets
may link only to libraries. CMake is dropping the item.…followed by 14-line stack trace, the top of which is this:
Build/vcpkg/scripts/buildsystems/vcpkg.cmake:859 (_find_package)…and all of it shown in bright yellow, making you think it must be important and something must need to be fixed. But that’s not the case. Instead, despite that, you’ll be able to build successfully with the Qt UI.
Running Ladybird
Race condition on exit when running headless on systems with `llvmpipe`
When running --headless=text or --headless=layout-tree on a UNIX system with the llvmpipe software rasterizer
(common default on virtual machines), Ladybird will occasionally terminate with:
double free or corruption (!prev)Run Ladybird with --force-cpu-painting to make that go away.
Likely explanation (this thread):
suspect […] atexit handlers are registered twice possibly due to llvmpipe and fork/exec interaction issues when starting WebContent process
Documentation WebDriver (`Documentation/WebDriver.md`)
Investigating websites with WebDriver
Ladybird's WebDriver service can launch a browser, navigate pages, evaluate
JavaScript, and capture screenshots. It also accepts --resource-map to
substitute local files for HTTP or HTTPS resources while retaining their URLs.
Run a complete example
From the repository root, build WebDriver and its browser dependencies:
cd Build/release
ninja WebDriverRun the following example from that build directory. It uses Python's standard
library, starts WebDriver on loopback, creates a headless session, and loads a
page and script supplied entirely by a resource map. It prints the script's
result and saves webdriver-example.png and webdriver-example.log in the
build directory. These two files are overwritten on subsequent runs.
/* Detailed source-code truncated for AI context efficiency. */Deleting the session closes its browser. Terminating WebDriver cleanly removes
the temporary browser profile it creates by default. The Python context manager
removes the example's resource files; the screenshot and log remain available.
WebDriver launches the browser and its helper processes, so they do not need to
be started separately.
Adapt the example to a website
Change the navigation URL and the JavaScript passed to /execute/sync. Remove--resource-map and its argument to load all resources from the network, or
retain a map containing only the resources you want to instrument. For example:
{
"substitutions": [
{
"url": "https://example.com/assets/app.js",
"file": "/absolute/path/to/instrumented-app.js",
"content_type": "text/javascript"
}
]
}Use absolute local paths. Entries support an optional status_code (default
200), and content_type defaults to a guess from the local filename. URL matching
ignores the query and fragment, so the entry above also replaceshttps://example.com/assets/app.js?v=123. The map is loaded when RequestServer
starts; create the mapped files before starting the session. Restart the
WebDriver session after changing map entries or replacing a mapped file so
the sandbox can grant access to the new file. Mapped files are read when served,
but browser caching can still affect subsequent loads.
Navigation waits for page loading according to the session's page-load strategy.
For application work that continues afterward, use /execute/async with an
application event or promise and call the callback supplied as the last script
argument. Avoid fixed sleeps to decide when a page is ready. The example's short
startup polling interval only waits for the WebDriver listening socket.
To inspect a visible browser, remove --headless and set ladybird:headless tofalse. For a browser process waiting for a debugger, add, for example,--debug-process WebContent and allow enough request time to attach. Use--force-cpu-painting when investigating differences involving GPU painting.
For authenticated investigations, --profile-path /absolute/path/to/profile
selects a persistent profile. WebDriver otherwise uses its own temporary profile.
See ./bin/WebDriver --help (or the bundle path above on macOS) for other options.
Documentation WrapperArchitecture (`Documentation/WrapperArchitecture.md`)
LibWeb wrapper architecture
LibWeb exposes internal C++ implementation objects to JavaScript through a
three-layer wrapper model:
Web::Bindings::Wrappableis the implementation-side base for objects that
can be reflected into JavaScript.- Wrappers are
JS::HostObjects that hold the implementation object in their
wrappable slot. For each interface, the WebIDL generator emits a host class
table, whose hooks implement the Web-facing object behavior, and acreate_*_wrapper()function inWeb::Bindings. Web::Bindings::WrapperWorldowns wrapper identity for one observable world.
Wrapping an implementation object means looking up or creating the wrapper in
the caller'sWrapperWorld.
This gives LibWeb one wrapper identity per (implementation object, WrapperWorld) pair, while still allowing the same implementation object to be
observed through different realms or isolated worlds.
World model
Each HTML agent owns one main-world WrapperWorld. For windows this means one
cell per similar-origin window agent, not per Page: same-agent auxiliary
pages such as same-origin window.open() can directly observe each other's DOM
objects and must therefore share main-world wrapper identity. Worker agents
naturally get their own main-world cell. Cross-origin windows in one WebContent
process currently share its SimilarOriginWindowAgent, so they also share this
cell; proper agent-cluster separation is future work (including the same-process
COOP/noopener popup case noted in PageClient.cpp).
Non-main worlds are realm-local wrapper-cache cells. The current concrete types
are Internal and Extension; each has its own wrapper cache and must not
share main-world slots. Non-main worlds that may preserve wrappers must be
explicitly detached before the embedder drops them. The destructor verifies
this unconditionally, except during CollectEverything collection where weak
wrappable references cannot be walked safely. Wrapping, preserving, or cache
insertion through a detached world is likewise rejected by unconditionalVERIFYs, including in release builds.
Wrappable keeps an inline weak main-world wrapper as a fast-path
representative, but the authoritative cache is the WrapperWorld map. This is
important because the same implementation object can be visible through
different realms in the same agent without splitting identity. A main-world
wrapper cache hit from a different main-world cell is treated as an invariant
violation; cross-agent object graphs are not directly shared.
Realm rule
Generated bindings make one realm decision per IDL member. Instance members
default to the validated receiver realm; static members, constructors, and
namespace members use the caller/current realm; [NeedsCallerRealm] is the
single opt-out for instance members whose observable allocation realm is the
caller realm.
The detailed policy, including exception wrapping and current opt-out
categories, lives in
REALM_POLICY.md.
WindowProxy has one special receiver rule: for main-world proxies,Bindings::this_value_realm() resolves to the active [[Window]] realm; for
non-main-world proxies, it stays in the proxy's own realm.
Realms for DOM developers
Implementation code should not mention realms unless the algorithm is
explicitly binding-adjacent or JS-object-materialization code. Bindings choose
the observable realm for exceptions, return values, callbacks, and ordinary
wrapper creation.
For day-to-day DOM implementation work, realm decisions should fit in this
contract:
Implementation code never mentions realms. Bindings choose realms. You think
about global ownership in exactly two situations: (1) a non-Nodewrappable
may be observed from several globals before it is wrapped — overriderelevant_global_impl()and return the owningWindow,Document's window,
or worker global-scope implementation; (2) you mint aPromisefor later
resolution — use the promise vending helper for the relevant object or
settings object. Everything else — exceptions, return values, events,
callbacks — is realm-free in ordinary implementation code.
Existing implementation-side realm mentions are transitional unless they are
needed for spec-selected JS object materialization, stream/chunk conversion,
structured serialization, module/script plumbing, or callback invocation. New
realm mentions outside Bindings/,HTML/Scripting/, and WebIDL/ should be treated as architecture changes and
must be justified in the lint allowlist.
Preservation and wrapper state
Any non-default JavaScript state on a platform wrapper preserves that exact
wrapper. This includes:
- ordinary, accessor, and symbol expandos;
- expando creation through inline-cache paths;
- subclass construction state;
- custom prototypes;
- non-default extensibility/frozen/sealed state.
The C++ inline-cache preservation trigger isJS::Object::Flag::RequiresSlowAddOwnProperty: platform wrappers set this flag
so fast expando writes route through the preserving add-own-property path.
Preservation is intentionally append-only for a wrapper. Deleting an expando
does not unpreserve the wrapper; tests for deletion should assert the deleted
property stays deleted after GC, not that the wrapper becomes collectable.
Tests should use withCollectedWrapper(makeAndMark, reacquire, verify) fromTests/LibWeb/Text/input/include.js when checking preserved-wrapper behavior.
That helper verifies internals.wrapperIsPreserved() before forcing GC, drops
the local wrapper reference, then re-acquires through the implementation object.
Liveness model
Ordinary wrapper/implementation edges are:
- wrapper to implementation: strong, generated wrapper member;
- implementation to cached wrapper: weak, through
WrapperWorldand the
inline main-world representative; - implementation to preserved wrapper: strong, visited by
Wrappable.
Pending activity is explicit. Objects such as timers, XHR, WebSocket, and
ResizeObserver take and release GC::ActivityRoots at their state transitions
instead of relying on silent GC virtual predicates.
LibGC finalization clears wrapper cache entries before weak pointers are
observed as live again. Code that depends on weak wrapper caches relies on that
ordering: wrapper finalization removes the cache entry, then weak references can
clear.
Window and Location cross-origin descriptors
Cross-origin property descriptor maps live on the per-world wrapper, not on the
implementation object. The Window and Location wrappers both keep theirs in a
companion cell in the wrapper's host data slot, created on the first
cross-origin property access and reached throughHTML::cross_origin_property_descriptor_map(). Descriptor cache reachability is
ordinary wrapper reachability: cached descriptor values and accessors are traced
through the wrapper, but they are not roots, so a wrapper/realm cycle can still
be collected when nothing outside the cycle reaches it. This removes the need
for world serials or detach-time pruning of implementation-owned maps.
Each world's wrapper has its own descriptor map. A descriptor cached for one
world cannot alias another world's descriptor because it is stored in a
different wrapper object.
Accepted risks
- Adopted nodes may keep using a wrapper from their original main-world realm
when that wrapper was already preserved. This is accepted because clearing the
edge would make wrapper identity depend on navigation/bfcache timing. - Preservation can over-retain wrappers after state returns to default, such as
after deleting an expando. This is accepted because append-only preservation is
simple, deterministic, and avoids a fragile "all wrapper state is default"
detector. - A non-
Nodeimplementation without a relevant-global override falls back to
the caller's preferred realm. If its unpreserved wrapper dies, a replacement
wrapper may therefore be allocated in a different realm in the same world.
Wrapper-world identity remains unchanged; implementations with an override
derive stable placement from their owning global implementation. - Cross-origin descriptor cache keys use raw environment-settings-object
addresses. This is accepted because the key is only used inside a descriptor
map owned by one live wrapper, and entries are not reused after the wrapper
dies. The raw addresses are identity tokens for same-lifetime cache lookup,
not owning references.
Future implementation representation
The binding layer must not assume implementation objects are permanently
GC-allocated. The expected long-term state is heterogeneous: some implementations
may remain GC cells, some may become C++ refcounted objects, and some may become
Rust-backed handles.
Keep new wrapper work behind the narrow binding surface:
wrap();impl_from<T>()/wrappable_impl();preserve_wrapper();WrapperWorldcaches;- the generated root wrapper's implementation member.
Do not add ad-hoc implementation-pointer plumbing or new GC-specific liveness
side channels outside that surface. When non-GC implementation objects arrive,
the migration points are the generated implementation handle, weak
implementation-to-wrapper caches, preserved-wrapper roots, and the currentActivityRoot model.
Testing wrapper and GC behavior (mandatory rules)
These rules exist because early wrapper work produced ~10 regression tests
that could not fail and two whose checked-in expectations encoded the bug they
were meant to catch. They are hard requirements for any test touching wrapper
identity, preservation, realms, or GC:
- Drop and re-acquire. A wrapper held in a live local across
internals.gc()can never be collected, so the test asserts nothing.
Create/mutate inside an IIFE (or null the variable),gc(), then
re-acquire through the implementation (getElementById, collection
access) and assert on the re-acquired object. PreferwithCollectedWrapper()fromTests/LibWeb/Text/input/include.js.
Holding a ref is only correct when the test's point is that a held ref
pins identity — say so in a comment. Functiontakes (params..., body).Function("x => f(x)")makes the
string the function body of a zero-argument function. For cross-realm
calls useframe.contentWindow.Function("arg", "return arg.item(0)")and
verify the result is notundefined.- Read every expectation file before committing it. If the expected
output containsfalse,undefined, or an unchanged "before" value,
either the test records a failure or it is an intentional negative
assertion — if the latter, say so in the test. - Prove the test can fail. For every regression test guarding a fix,
revert the fix locally, watch the test fail, restore, and record the
failure text in the change description. A test that passes both with and
without the fix is a coverage pin — label it as such. - Never mark work done with an unmet acceptance criterion — record an
explicit deviation instead. When several test failures share a signature,
diagnose the shared cause before writing per-site fixes; a green gate is a
necessary condition, never the goal.
Documentation EditorConfiguration AndroidStudioConfiguration (`Documentation/EditorConfiguration/AndroidStudioConfiguration.md`)
Android Studio Project Configuration
The Android port of Ladybird has straightforward integration with the Android Studio IDE.
Prerequisites
Ensure that your system has the following tools available:
- Android Studio Narwhal 2025.1.1 or later
- CMake 3.23 or higher as the default CMake executable
- 20G or more storage space for SDKs + Emulator images + Gradle dependencies + build artifacts
Opening the project
After opening the ladybird directory in Android Studio (NOT the UI/Android directory!)
there should be a pop-up in the bottom left indicating that an Android Gradle project was detected
in UI/Android.
In the top left of the screen in the Project view, navigate to UI/Android. Or, click the
highlighted text in the notification for that path. Open the settings.gradle.kts file. At the
top of the file should be a banner that says Code Insight unavailable (related Gradle project not linked). Click the Link Gradle project text on the right side of the banner. After the IDE
loads the Gradle view to the right of the code window, go back to the banner at the top of thesettings.gradle.kts file and click Load Script Configurations to finish loading the Gradle
project.
Gradle will index the project, and download all the required plugins. If it complains about no NDK,
follow the instructions in Android Studio to install an appropriate NDK version. If it still
complains about the NDK version, open File->Invalidate Caches... and click Invalidate and Restart.
Getting the most out of the IDE
See the sections in the CLionConfiguration for Excluding Build Artifacts,
and Code Generation Settings.
Documentation EditorConfiguration CLionConfiguration (`Documentation/EditorConfiguration/CLionConfiguration.md`)
CLion Project Configuration
CLion can integrate with CMake to provide code comprehension features.
After opening the ladybird repository in CLion as a new project, the "Open Project Wizard" window will open.
Select the Release Preset in the Settings -> Build, Execution and Deployment -> CMake window and check the Enable profile checkbox.
Then select the Debug profile and uncheck the Enable profile checkbox.
If the build complains that there is no Default Toolchain, go to the Settings -> Build, Execution and Deployment -> Toolchains
tab and copy the currently defaulted host toolchain and rename it to Default. Make sure that the compiler chosen has a version of
supported by the build system.
Excluding Build Artifacts
Source files are copied to the Build directory during the build, if you do not exclude them from CLion indexing they will show up
in search results. This is often confusing, unintuitive, and can result in you losing changes you have made to files. To exclude
these files navigate to the Project tool window, right-click the Build folder and select Mark Directory as | Excluded.
Include headers and source files for code insight
To get proper code insight mark the folders AK and Libraries by right-clicking on them and selecting Mark Directory as | Project Sources and Headers.
A symptom of this not being configured correctly is CLion giving a warning for every single file:
The file does not belong to any project target, code insight features might not work properly.
Code Generation Settings
To make code generated by CLion match the Ladybird coding style, import the CLionCodeStyleSettings.xml from this directory as code style scheme viaSettings -> Editor -> Code Style -> C/C++ -> Scheme -> Cog icon -> Import Scheme...
CMake Error Messages
This section outlines common CMake error messages and provides guidance on how to resolve them.
Unable to Find Package
You may encounter an error message similar to the following:
CMake Error at AK/CMakeLists.txt:60 (find_package):
By not providing "Findsimdutf.cmake" in CMAKE_MODULE_PATH, this project has
requested CMake to find a package configuration file for "simdutf", but
CMake could not locate one.
...This error typically arises when CLion is not configured to use the correct build directory.
Solution: Ensure that CLion's build directory is set to the correct build directory for the selected profile.
Navigate to Settings -> Build, Execution, Deployment -> CMake and in your selected profile, set the Build directory according to the profile:
- Default -> "
Build/release" - Debug -> "
Build/debug" - Sanitizer -> "
Build/sanitizer"
Notes for WSL Users
Toolchain
If the ladybird directory is on the WSL filesystem you need to configure the CLion toolchain to be WSL.
To set that up go to File->Settings->Build, Execution, Deployment->Toolchains and click on the + icon, then select WSL. In Toolset select the distribution you have the ladybird directory on.
Terminal
It is possible to set the embedded terminal in CLion to the one that your WSL distribution provides.
This way you can build and run ladybird without leaving the IDE.
Note that following will only help if you don't use an X-window server to access qemu.
It is possible to install qemu natively on Windows and allow WSL to use it instead of installing qemu first on (wsl) linux and then use X server to launch ladybird inside of it.
Check the updated manual here.
Locate the terminal emulator for your linux distribution.
Open CMD with elevated privileges and cd toC:/Program Files/WindowsApps/.
The directory is usually hidden and requires additional privileges. You should be able to cd as administrator.dirand look for your distribution in directory names. In case of Ubuntu, it starts withCanonicalGroupLimited.Ubuntu20.04onWindows_2004.2020.424.0_x64.
cd to it. The directory should contain the shell executable. In my case it's namedubuntu2004.exe.
Copyabsolute/path/to/ubuntu2004.exe.Go to your IDE settings:
File->Settings->Tools->Terminaland paste the path you just copied toshell path. Click OK.Close CLion and restart.
The default IDE terminal should now be changed to WSL, and now you can run CLion/run.sh.
You may also want to copy ladybird/Meta/CLion/run.sh to your project directory and run it from there, so that you don't have to fight with git every time you modify the script.
Documentation EditorConfiguration EmacsConfiguration (`Documentation/EditorConfiguration/EmacsConfiguration.md`)
Emacs Project Configuration
Emacs can be configured with lsp-mode and clangd to work well.
clangd
The official clangd extension can be used for C++ comprehension.
Run cmake (Meta/ladybird.py run ladybird or similar) at least once for clangd
to work, as doing so will generate the Build/release/compile_commands.json
that is needed by clangd.
lsp-mode
(use-package lsp-mode
:hook ((c++-mode) . lsp-deferred)
:commands lsp
:config
(setq lsp-clients-clangd-args '("-j=4" "-background-index" "--log=error" "--clang-tidy" "--enable-config"))
;; Optionally, set the location of clangd -- See notes below for options.
(setq lsp-clangd-binary-path "/usr/bin/clangd"))clangd
There are a few different ways to specify which clangd to use:
- By default, without configuration
lsp-modewill try to find and use your systemclangd. This is the easiest solution, but your system clangd might be out of date. - You can manually specify any
clangdbinary withlsp-clangd-binary-path, as shown in the use-package example above. - You can have
lsp-modemanage yourclangdinstallation with emacs'lsp-install-server. This will install aclangdbinary for you. - You can build the LLVM toolchain, including
clangd, from Ladybird's repository. This is an advanced option that is not currently documented.
clang-format
There are multiple packages to handle auto formatting withclang-format, within emacs. Choose what works best for your setup:
Alternatively, this can be done without additional packages, using lsp-mode.
You can use the following .dir-locals.el file placed in the project root:
((c++-mode
(eval add-hook 'before-save-hook #'lsp-format-buffer nil t)))Documentation EditorConfiguration HelixConfiguration (`Documentation/EditorConfiguration/HelixConfiguration.md`)
Helix Configuration
Helix comes with support for clangd and clang-format out of the box! However, you also need to configure the clangd server to not insert headers improperly. To do this, create a .helix/languages.toml file in the project root:
[language-server.ladybird]
command = "clangd"
args = ["--header-insertion=never"]
[[language]]
name = "cpp"
language-servers = ["ladybird"]Documentation EditorConfiguration NvimConfiguration (`Documentation/EditorConfiguration/NvimConfiguration.md`)
Neovim Project Configuration
There are two documented ways to support clangd in Neovim for the Ladybird
project:
- With nvim-lspconfig (assumes existing setup)
- With coc.nvim (from scratch, potentially out of date)
For both setups, make sure you ran ./Meta/ladybird.py run ladybird at least
once.
With nvim-lspconfig
Note: This guide assumes Lua is being used, but can easily be adapted to
VimScript.
If you have an nvim-lspconfig, setup
already, registering clangd is similar to other LSPs:
require('lspconfig').clangd.setup {
-- If you have an on_attach function, this is where you'd put it. If not,
-- you can delete this line.
-- on_attach = ...,
-- This is where you'd put capabilities (i.e. if you're using nvim-cmp)
-- capabilities = ...,
-- If you have another clangd installation, put it here. Note that we use
-- clangd version 18 for the Ladybird project.
-- cmd = { '/path/to/clangd' },
}A base setup call should be enough to register the LSP, but if false-negative
LSP errors occur upon opening Neovim, make sure your clangd installation is at
least version 18.
With coc.nvim
Note: This guide assumes VimScript is being used, but can easily be adapted to
Lua. It also starts from scratch.
Neovim can be configured to use the coc-clangd
plugin to provide code-completion as well as inline
git blame using vim-plug.
Formatting can also be configured with formatter.nvim.
Install vim-plug
See https://github.com/junegunn/vim-plug.
Install coc.nvim
The config file for neovim is at ~/.config/nvim/init.vim or if
set $XDG_CONFIG_HOME/nvim/init.vim.
Add the Plugin:
Plug 'neoclide/coc.nvim', { 'branch': 'release' }Run :PlugInstall inside nvim.
Install coc-clangd via CocInstall
:CocInstall coc-clangdNote: This guide is tested with clangd version 14.0.6 and 15.0.6.
In case you have not installed clangd already install it with:CocCommand clangd.install.
This will install a separate version of clangd just for neovim.
Configure coc-clangd in coc-settings.json
Use the following settings to ensure that coc-clangd works out of the box.
Note: You might want to adjust the
clangd.fallbackFlagsdepending on your build
system and customize theinlayHints.sepbased on your preference.
{
"clangd.fallbackFlags": ["-std=c++23"],
"semanticTokens.enable": true,
"inlayHint.subSeparator": "︴",
"inlayHints.enableParameter": true,
"clangd.inlayHints.sep": "⇝"
}To change the coc-settings.json go to the file ~/.config/nvim/coc-settings.json
or type :CocConfig in the command line.
Note: In case you already had another c++ language server configured in the
coc-settings.jsonyou might want to nuke it first and
work towards your desired config by adding the other parts back in to avoid
conflicts.
Note: If you have configured
clangdas a languageServer incoc-settings.json, you should remove it to avoid running clangd twice!
Note:
clangd.inlayHints.sepbreaks onclangd 15.0.6.
Formatting
For code formatting the formatter plugin can be used.
Plug 'mhartington/formatter.nvim'Configuration
To use the formatter plugin one needs to opt-in to specific formatters. An example lua configuration which uses clang-format for cpp files:
require("formatter").setup{
filetype = {
cpp = {
require("formatter.filetypes.cpp").clangformat
}
}
}Install git blame (Optional)
Plug 'f-person/git-blame.nvim'Run :PlugInstall inside nvim.
Configure your init.vim
The config file for neovim is at ~/.config/nvim/init.vim or if
set $XDG_CONFIG_HOME/nvim/init.vim.
The init.vim excerpt:
/* Detailed source-code truncated for AI context efficiency. */Documentation EditorConfiguration QtCreatorConfiguration (`Documentation/EditorConfiguration/QtCreatorConfiguration.md`)
Qt Creator Project Configuration
Setup
First, make sure you have a working toolchain and can build and run Ladybird. Go here for instructions for setting that up.
- Install Qt Creator. You don't need the entire Qt setup, just click 'Qt Creator' on the left side, and install that.
- Open Qt Creator, select
File -> New File or Project... - Select
Import Existing Project - Give it a name (some tools assume lower-case
ladybird), and navigate to the root of your Ladybird project checkout. Click Next. - Wait for the file list to generate. This can take a minute or two!
- Ignore the file list, we will overwrite it later. Click Next.
- Set
Add to version controlto<None>. Click Finish. - In your shell, go to your Ladybird project directory, and invoke the
Meta/refresh-ladybird-qtcreator.shscript to regenerate theladybird.filesfile. You will also have to do this every time you delete or add a new file to the project. - Edit the
ladybird.configfile (In Qt Creator, hit ^K or CMD+K on a Mac to open the search dialog, type the name of the file and hit return to open it) - Add the following
#defines to the file:text#define ENABLE_COMPILETIME_FORMAT_CHECK - Edit the
ladybird.cxxflagsfile to say-std=c++23 -fsigned-char -fconcepts -fno-exceptions -fno-semantic-interposition -fPIC - Edit the
ladybird.includesfile to list the following lines (adapt to the actual path of your skia folder):text./ Libraries/ Services/ Build/release/ Build/release/Libraries/ Build/release/Services/ Build/release/vcpkg_installed/x64-linux/include/skia/ AK/
Finally, search in the options for "BOM" (Text Editor > Behavior > File Encodings > UTF-8 BOM), and switch to "Always delete".
Qt Creator should be set up correctly now, go ahead and explore the project and try making changes. Have fun! :^)
Auto-Formatting
You can use clang-format to help you with the style guide. Before you proceed, check that you're actually using the clang-format version specified in the style guide, as some OSes will ship other clang-format versions by default.
- In QtCreator, go to "Help > About Plugins…"
- Find the
Beautifier (experimental)row (for example, by typingbeauinto the search) - Put a checkmark into the box
- Restart QtCreator if it asks you
- In QtCreator, go to "Tools > Options…"
- Type "beau" in the search box, go to "Beautifier > Clang Format"
- Select the "customized" style, click "edit"
- Paste the entire content of the file
.clang-formatinto the "value" box, and click "OK" - In the "Beautifier > General" tab, check "Enable auto format on file save"
- Select the tool "ClangFormat" if not already selected, and click "OK"
Note that not the entire project is clang-format-clean (yet), so sometimes you will see large diffs.
Use your own judgement whether you want to include such changes. Generally speaking, if it's a few lines then it's a good idea; if it's the entire file then maybe there's a better way to do it, like doing a separate commit, or just ignoring the clang-format changes.
You may want to read up what git add -p does (or git checkout -p, to undo).
QtCreator tends to interpret IPC definitions as C++ headers, and then tries to format them. This is not useful. One way to avoid that is telling QtCreator that IPC definitions are not C++ headers.
- In QtCreator, go to "Tools > Options…"
- Type "beau" in the search box, go to "Environment > MIME Types"
- In the little search box, type "plain", and select "text/plain"
- In the "details" section, you should now see the list of Patterns, something like
*.txt;*.asc;*,v. Extend it in the following way:*.txt;*.asc;*,v;*.ipc;*.gml - Click "OK" to close the dialog.
- Maybe you need to close and open again the IPC files. You can check what QtCreator is doing by right-clicking the filename in the editor tab, and clicking "Properties...". In the third line, you should see
MIME type: text/plain.
License template
You may have noticed how Andreas just types lic and the license appears.
In order to so, create a new file anywhere, for example license-template.creator, with the standard license:
/*
* Copyright (c) 2024-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/In QtCreator, select the menu "Tools", item "Options", section "C++", tab
"File Naming" (don't ask me why it's here). At the bottom there should be the
option "License template:". Click "Browse…", select your file (i.e.,license-template.creator). Click "OK", and you're done! :)
Documentation EditorConfiguration VSCodeConfiguration (`Documentation/EditorConfiguration/VSCodeConfiguration.md`)
Visual Studio Code Project Configuration
Visual Studio Code requires some configuration files, and a tailored settings.json file to understand ladybird.
The WSL Remote extension allows you to use VS Code in Windows while using the normal WSL workflow. This works well, but for code comprehension speed you should put the ladybird directory on your WSL root partition.
The recommended extensions for VS Code include:
Configuration
Run ./Meta/ladybird.py build at least once to kick off downloading and building vcpkg dependencies.
The CMake Tools plugin should automatically detect the CMakePresets.json at the root of the repository.
Selecting and activating the default preset should be enough to get started after the initial build.
You can also use the Debug preset to build with debug symbols, or the Sanitizer preset to build with ASAN/UBSAN.
For additional settings recommendations, see the Settings section below.
Code comprehension
Clangd has the best support for modern compilers, especially if configured as noted below. The Microsoft C/C++ tools can work, but may require more configuration.
clangd
The official clangd extension can be used for C++ comprehension. It is recommended in general, as it is most likely to work on all platforms.
clangd uses compile_commands.json files to understand the project. CMake will generate these in Build/release.
Run ./Meta/ladybird.py run ladybird at least once to generate the compile_commands.json file.
Known issues
- clangd has a tendency to crash when stressing bleeding edge compiler features. You can usually just restart it via the command palette. If that doesn't help, close currently open C++ files and/or switch branches before restarting, which helps sometimes.
Microsoft C/C++ tools
Note that enabling the extension in the same workspace as the clangd and clang-format extensions will cause conflicts.
If you choose to use Microsoft C/C++ Tools rather than clangd and clang-format, use the
following c_cpp_properties.json to circumvent some errors. Even with the configuration in place, the extension will likely still report errors related to types and methods not being found.
.vscode/c_cpp_properties.json
{
"configurations": [
{
"name": "ladybird-gcc",
"includePath": [
"${workspaceFolder}",
"${workspaceFolder}/Build/release/",
"${workspaceFolder}/Build/release/Libraries",
"${workspaceFolder}/Build/release/Services",
"${workspaceFolder}/Libraries",
"${workspaceFolder}/Services"
],
"defines": [
"DEBUG"
],
"cStandard": "c17",
"cppStandard": "c++23",
"intelliSenseMode": "linux-gcc-x86",
"compileCommands": "Build/release/compile_commands.json",
"compilerArgs": [
"-Wall",
"-Wextra",
"-Werror"
],
"browse": {
"path": [
"${workspaceFolder}",
"${workspaceFolder}/Build/release/",
"${workspaceFolder}/Build/release/Libraries",
"${workspaceFolder}/Build/release/Services",
"${workspaceFolder}/Libraries",
"${workspaceFolder}/Services"
],
"limitSymbolsToIncludedHeaders": true,
"databaseFilename": "${workspaceFolder}/Build/release/"
}
}
],
"version": 4
}Formatting
clangd provides code formatting out of the box using the clang-format engine. clang-format support is also included with the Microsoft C/C++ tools (see above). The settings below include a key that makes the Microsoft extension use the proper style.
Settings
These belong in the .vscode/settings.json of Ladybird.
{
// Excluding the generated directories keeps your file view clean and speeds up search.
"files.exclude": {
"**/.git": true,
"Build/**": true,
},
"search.exclude": {
"**/.git": true,
"Build/**": true,
},
// Force clang-format to respect Ladybird's .clang-format style file. This is not necessary if you're not using the Microsoft C++ extension.
"C_Cpp.clang_format_style": "file",
// Tab settings
"editor.tabSize": 4,
"editor.useTabStops": false,
// format trailing new lines
"files.trimFinalNewlines": true,
"files.insertFinalNewline": true,
// git commit message length
"git.inputValidationLength": 72,
"git.inputValidationSubjectLength": 72,
// If clangd was obtained from a package manager, its path can be set here.
"clangd.path": "clangd-18",
"clangd.arguments": [
"--header-insertion=never" // See https://github.com/clangd/clangd/issues/1247
]
}Customization
Custom Tasks
You can create custom tasks (.vscode/tasks.json) to quickly compile Ladybird.
The following three example tasks should suffice in most situations, and allow you to specify the build system to use, as well as give you error highlighting.
.vscode/tasks.json
/* Detailed source-code truncated for AI context efficiency. */Debugging
Mac
If you want to run the debugger, first place the content below in .vscode/launch.json in the root of the project.
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to WebContent",
"type": "lldb",
"request": "attach",
"program": "${workspaceFolder}/Build/debug/bin/Ladybird.app/Contents/MacOS/WebContent",
}
],
}then run Ladybird with the debug preset and with the --debug-process WebContent flag:
CC=$(brew --prefix llvm)/bin/clang CXX=$(brew --prefix llvm)/bin/clang++ BUILD_PRESET=Debug ./Meta/ladybird.py run ladybird --debug-process WebContentRunning Ladybird in this way will pause execution until a debugger is attached. You can then run the debugger by going to the Run and Debug menu and selecting the Attach to WebContent configuration.
Linux
For Linux, the launch.json will instead be the file below.
{
"version": "2.0.0",
"configurations": [
{
"name": "Attach and debug",
"type": "cppdbg",
"request": "attach",
"program": "${workspaceRoot}/Build/debug/libexec/WebContent",
"MIMode": "gdb",
},
]
}Running Ladybird as follows:
BUILD_PRESET=Debug Meta/ladybird.py run ladybird --debug-process WebContentThen follow the same steps found in the Mac section. Notice also that you need to have gdb (the GNU Debugger) installed.
License snippet
The following snippet may be useful if you want to quickly generate a license header, put it in .vscode/ladybird.code-snippets:
{
"License": {
"scope": "cpp,c",
"prefix": "license",
"body": [
"/*",
" * Copyright (c) $CURRENT_YEAR, ${1:Your Name} <${2:[email protected]}>.",
" *",
" * SPDX-License-Identifier: BSD-2-Clause",
" */"
],
"description": "License header"
}
}Documentation EditorConfiguration VimConfiguration (`Documentation/EditorConfiguration/VimConfiguration.md`)
Vim Project Configuration
Vim can be configured to use the YouCompleteMe
plugin to provide code-completion.
Once you've installed the YouCompleteMe plugin you should whitelist the
configuration file that is part of the ladybird repository. You can
do this by adding the following option to your .vimrc file:
let g:ycm_extra_conf_globlist = ['~/ladybird/.ycm_extra_conf.py']Documentation HumanInterfaceGuidelines Text (`Documentation/HumanInterfaceGuidelines/Text.md`)
Guidelines for user interface text in Ladybird
Capitalization
Ladybird employs two capitalization styles:
- Book title capitalization
- Sentence-style capitalization
Book title capitalization
In this style, we capitalize the first letter of the first and last word,
as well as all words in between, except articles (a, an, the);
the seven coordinating conjunctions (for, and, nor, but, or, yet, so);
and prepositions with up to four letters (at, by, for, with, into, ...)
Examples:
- Create New Layer
- Copy URL
- Move to Front
- Save and Exit
- Sort by Name
Used for:
- Button text
- Icon labels
- Menu names
- Menu items
- Tab titles
- Window titles
- Tooltips
Sentence-style capitalization
This style follows basic English sentence capitalization.
We capitalize the first letter of the first word, along with the first letter
of proper names, weekdays, etc.
Examples:
- An error occurred
- Use system defaults
- Copy the selected text
- Enable Linux compatibility hacks
Used for:
- Check box labels
- Group box labels
- List items
- Messages (as in message boxes)
- Radio button labels
- Status bar text
- Text box labels
Ellipses
The ellipsis, represented by a series of three periods (...), has two special
functions in the interface:
- Eliding text
- Foreshadowing additional user input
The first occurs programmatically, but the second requires care when setting
text manually.
Control text which implies an action whose effect is incomplete pending further
user input should end in an ellipsis. Opening a new window does not in itself
justify the use of an ellipsis; the dialog must be an intermediate step toward
completing the action.
Ellipses should be used sparingly elsewhere to avoid confusion with elision.
Examples:
- Save As...
- Browse...
- Insert Emoji...
Documentation Style RustCSSMigration (`Documentation/Style/RustCSSMigration.md`)
Rust stylesheet ownership
Downloaded link and import sheets useParser::parse_stylesheet_off_thread. CSSOM parsing borrows native text synchronously.
Workers receive owned parsing context, never document or interner access.
ParsedStyleSheet is immutable and thread-shareable. Its weak parse cache is
keyed by native sheet text and parsing context. Parsed selectors, declarations,
descriptors and URL spelling use shared Rust storage. Parsing and destruction
on a worker must not call C++ or the main-thread Utf16FlyString interner.
NativeRuleList retains that graph and reserves stable identities without
creating mutable rule or declaration owners. Its visible-rule indexes support
indexed CSSOM access. A sparse map holds exposed rules; order-changing mutation
can materialize a list without recursively materializing descendants.RuleRef traverses both exposed and untouched rules. Keyframes use the same
child list for compilation and CSSOM, with no parallel frame-owner list.
rule/compilation.rs binds names on the document thread and publishes directly
into the Rust style engine. Compiled targets retain immutable declarations,
source identities and condition inputs, not CSSOM objects. Cascading borrows
native stylesheet and inline declaration data.
Media evaluation and condition/layer traversal follow imports directly in Rust.
The C++ entry points borrow the media environment once; layer traversal reuses
one UTF-16 prefix buffer across the graph. Rust also merges cascade layer order
and publishes condition gates directly. Compilation and layer publication use
native operations; C adapters exist only for host callers.
StyleSheetState and StyleSheetImport own document/loading state independently
of the GC-allocated CSSStyleSheet and CSSImportRule facades. Imports retain
their loading media identity. Font, function, keyframe and property-registration
consumers read shared rule data without creating CSSOM wrappers.
CSSOM facades retain native rules and borrow their payloads where possible.
Live declaration and descriptor handles share mutation state; independent
sheets share immutable data until mutation. Explicit immutable snapshots
remain valid across mutation. C++ property caches needed by CSSOM/resource
consumers are populated directly, without an intermediate cached FFI array.
CSS URL spelling may contain Unicode. URL resolution is a later document-thread
operation, not worker stylesheet parsing. LibURL accepts borrowed ASCII/UTF-16,
and parsed URL components and base URL snapshots stay ASCII. The IDNA boundary
still calls C++/ICU. Rust and AK use the same C allocator for transferable buffers.
The Rust load-and-compile invariant covers worker parsing, imports, media,
namespaces, layers and definition readers. It requires zero C++ worker calls
and zero mutable rule, declaration or descriptor owner allocations for an
untouched sheet. HTML/JS tests cover mutation, sharing, imports and wrapper
identity. Keep those guarantees when simplifying this boundary.
Documentation Style StyleEngine (`Documentation/Style/StyleEngine.md`)
StyleEngine: differential style resolution
This document describes LibWeb's style engine: its data structures, algorithms, protocols, and memory rules, as implemented. Normative statements ("never", "must") are invariants the implementation upholds and that changes must preserve; they are review tests, not aspirations. Testing and debugging workflows live in StyleEngineTesting.md.
StyleEngine is LibWeb's implementation of CSS selector evaluation, style invalidation, cascade, and computed-value computation. Mutations reach it as typed input deltas (§4.3), and compiled delta routing (§6.3) carries each delta to the transpose entry points that mention that input: every selector program that can change under the delta is reached, and subjects are match-checked before any truth delta is emitted. Selector answers, cascade winners, and computed styles are shared, interned values: elements hold a compact StyleRecordID into interned computed-value groups (§9.8), and every consumer of style (layout, paint, hit testing, accessibility, script APIs, and the rest of the inventory in §12) reads through that shared handle.
Correctness rests on the engine's internal fallback chain: incremental evaluation, then exact evaluation of the affected scope, widening to the whole document when required (§10.6). Every path reads the same authoritative live DOM, and the exact cold evaluator is the reference implementation the others are checked against; the machinery that continuously enforces this is described in StyleEngineTesting.md.
One organizing frame runs through the whole document and is stated in §1.1: StyleEngine is a database, specifically an incremental view maintenance engine over a change stream. Mechanisms are described together with their database-world counterparts, in notes marked Database counterpart.
1. What the engine does
Conventional style engines do two things per change: conservatively determine which elements may be affected, then re-run selector matching, cascade, and computed-value construction for all of them. Work is proportional to the possible effects of a change rather than its actual semantic effects.
StyleEngine models styling as a memory-bounded incremental computation. DOM structure, element state, stylesheets, CSSOM mutations, cascade topology, and environment values are versioned inputs. Selectors, cascade decisions, and computed values form a shared logical dependency program. A style flush discovers and propagates exact changes through that program and stops as soon as a semantic value is unchanged.
The pipeline:
Style input transaction
-> tree, state, stylesheet, cascade, and environment deltas
-> exact selector-truth deltas
-> exact matched-rule deltas
-> exact cascade-winner deltas
-> exact computed-property deltas
-> layout, paint, animation, and observable-style changesEach arrow is a stopping point. If a stage's output identity is unchanged, the next stage receives nothing.
The cost of a transaction is:
work = normalize + discover + propagate + reconstruct + observediscover can dominate even when no semantic output changes, so it is accounted separately and the engine switches to an exact batch plan when a supposedly selective plan does too much discovery.
The ordinary path is the primary kernel. Tag, ID, class, attribute, state, compound, and conventional combinator evaluation must be the fastest and smallest path through the engine. :has() is a forcing function for the architecture, not the center of the workload: it extends the same selector primitives with reverse-direction existential maintenance, and it must impose no cost on documents that do not use it (zero relational allocations, zero relational fields).
1.1 The engine is a database
StyleEngine is a database engine: an incremental view maintenance engine over a change stream. The queries are standing rather than ad-hoc (attached selector programs change only when the program itself is edited), inputs arrive as typed deltas, and the engine's entire job is to keep a set of partially materialized views consistent with the base data for less than the cost of recomputing them. That places it in the family of streaming materialization systems (differential dataflow, DBSP, and the partially stateful Noria line), not general-purpose OLTP stores, and that family's known results, failure modes, and remedies apply here directly.
The canonical mapping, used throughout:
| StyleEngine mechanism | Database counterpart |
|---|---|
| Authoritative DOM, CSSOM, browser state (Tier 0) | Base tables, the single source of truth |
| Attached selector and cascade programs | Standing (continuous) queries |
| Ephemeral selector programs (§6.10) | Ad-hoc queries |
| Normalization journal (§4.4) | Changelog (CDC stream) with key-level compaction |
| Style read epoch (§4.2) | MVCC snapshot read view, without copying |
| Style transaction (§4.4, §11) | Transaction: atomic commit, isolated from script |
| Match program (§6.3) | Query plan: predicate evaluation per candidate row |
| Transpose program (§6.3) | Delta rule: the standing query's derivative |
| Delta-routing registry (§6.3) | Trigger dispatch index |
| Impact region (§6.4) | Delta key range; coverage is fail-closed (gaps widen) |
| Feature postings (§6.6) | Secondary (inverted) indexes, non-clustered |
| Rule matching (§6.8) | A relational join |
| Selective-plan cutoff (§6.7) | Cost-based optimizer: index scan versus full scan |
| Incremental discipline (§3) | Incremental view maintenance (fast refresh) |
| Exact batch plan (§3) | Complete refresh of the affected region; the upquery |
| Witnesses, winner groups, match answers | Partially materialized views |
| Tier-3 budget and eviction (§10) | Buffer pool with admission control |
| Cold-batch protocol (§13) | Storage scan API with projection and predicate pushdown |
| Cost model and counters (§15, §16) | Optimizer cost model and its statistics |
Three consequences of the framing carry weight throughout:
- Maintain-versus-refresh is a per-delta optimizer decision, not an architectural loyalty. No mature system promises that incremental maintenance always wins; it prices the delta against the rescan and chooses. §6.7 is that decision for selector plans, and the same decision governs every retained view, fed by §16's counters. When applying a delta costs more than enumerating the affected scope, rescanning is the optimizer's correct output, not a failure of the design.
- Every view is partial. Any materialization may be missing, and a miss is answered by an upquery to base facts through the exact cold evaluator, never by a wrong answer. That is why eviction is always legal (invariant 5), why memory never scales with the product of DOM and selector count (invariant 3), and why the eviction policy matters: a policy that starves the views whose maintenance would pay converts the engine into a full-scan engine with extra bookkeeping.
- Propagation stops at unchanged output. Every stage boundary in the §1 pipeline is a distinctness check: a delta whose output identity is unchanged is consolidated away and the next stage receives nothing. That is streaming consolidation, and it is invariant 1.
Where the analogy stops: there is no durability and no recovery, which is why the journal is a changelog and not a write-ahead log (§4.4); the data model is an ordered tree whose axes (ancestry, sibling order, the flat tree) are join dimensions no relational engine indexes natively; and the query set is fixed at parse time, so plan compilation is a program-change event, not a per-query cost. Importing machinery for problems the engine does not have (locking, recovery logging, general ad-hoc planning) is a misreading of the framing.
Where a mechanism's counterpart has a known failure mode or remedy in the database literature, a short Database counterpart note names it; consult that literature before inventing a new remedy.
2. Invariants
These hold for every code path. They are not optional optimizations.
- If a semantic value does not change, no downstream semantic work is emitted.
- DOM mutations, state changes, CSSOM changes, and environment changes are equally first-class inputs.
- Core persistent memory is proportional to DOM size, stylesheet program size, and live observable style state, never to the product of DOM size and selector count.
- Selector matches, reverse dependencies, witnesses, proofs, and indexes are optional materialized views unless explicitly classified as core state (§10.1).
- Every optional materialization can be discarded at any time without affecting correctness.
- High fanout uses exact pull evaluation rather than millions of explicit dependency edges.
- Cold, disconnected, hidden, and unobserved content does not receive per-selector state merely because it exists.
- Every cache and arena reports exact byte usage and participates in the document budget.
- Memory pressure reduces retained acceleration state, never correctness.
- Interning, compaction, and physical relabeling never create semantic style changes.
- The exact cold evaluator is part of the architecture. Optional incremental state may accelerate it but is never required for correctness.
- Every selector dependency has a compiled delta-routing entry point. Routing semantic inputs to selector programs is required program state, not an evictable view.
- Every scoped evaluation carries an impact-region coverage obligation (§6.4). Unknown scope is never treated as empty scope.
- Style reads use the authoritative live DOM under a mutation-free read epoch. A complete copied DOM is never allocated.
- Hot transpose and impact-region traversal has bounded evaluator-local access to every required tree relation, with no boundary crossing per relation step.
- Every broad or degraded path is named, counted, and reachable in tests. Nothing silently degrades to full-document invalidation.
2.1 Explicitly rejected as primary architecture
Alternatives with known failure modes, and why they were rejected:
- Full materialization of every view: storing every selector state, match, witness, and reverse edge approaches the product of DOM and stylesheet size. The database framing of §1.1 survives this rejection precisely because its views are partial and evictable.
- Conservative invalidation followed by restyle: preserves the separation between possible effect and semantic change, and therefore preserves its work amplification.
- Permanent proof subscriptions: exact dependency edges are good for hot low-fanout queries, unacceptable as mandatory state for every match and negative result.
- Rebuilding on CSSOM program change: dynamic stylesheet insertion and editing are normal page behavior, especially during load; they use incremental query-program operations.
- A complete computed-style object per element: repeated style groups, inherited environments, and animation bases share storage through compact handles.
2.2 Engineering principles
These are the working principles the invariants compile down to in day-to-day changes. They are review tests, not aspirations.
Work is mutation-sized; a whole-DOM traversal is a purchase, never a default. A style node may be visited only when the mutation named it, a compiled route reaches it, or its own output changes. A whole-document pass is one of three things: output (that many styles changed), a purchase (one counted pass that provably beats the per-node work it replaces), or a named, counted correctness floor. The review test for every loop: name what bounds it; if the bound is the document and the output is not, the loop is a bug, even while it is fast. Visit a node at most once per flush across all stages, and never restart an interrupted pass; grow its window instead.
Represent state data-oriented: dense tables over sparse maps, columns over objects. A dense integer key (element index, atom, rule) indexes a vector or segmented column, never a hash map; hash maps are for content-keyed intern tables. Every dense per-node structure takes on the retirement story the day it is born, so a reused identity cannot inherit its predecessor's state. An evicted structure reads as unknown, never as empty.
Choose presence structures by clearing cost. A bitmap with a companion set-index list when the set lives for a transaction (cleared through the list, not memset); an epoch-stamped seen array when the set clears per candidate; a sorted vector when built once and only queried. A maintained population count is free exact cardinality where a plan cutoff wants one.
Sort once at a build boundary; inherit order on the hot path. Convert order labels to dense ordinals at program build and read integers after. A per-subject sort during evaluation is a smell: inherit order from pre-sorted program structures and merge. Sorting the changed set once per transaction is the right shape; sorting anything document-sized falls under the traversal principle.
Spend document-order coordinates once they are bought. With preorder positions and subtree ends, a subtree is a slice, ancestry an interval comparison, and skipping a subtree an index jump. The coordinates answer for one named tree relation and the current side only, and they are transaction scratch unless measurement justifies persistence.
3. Evaluation disciplines and plans
Two evaluation disciplines share one semantics:
Incremental: the normal discipline. Retained derived state (prefix states, retained answers, winner groups) and exact deltas minimize discovery and downstream work.
Exact evaluation of a scope: the engine ignores optional derived state and exactly evaluates an affected region with the exact evaluator against authoritative facts. It may inspect every style node in the region but emits only actual selector, rule, cascade, and computed-value changes. This is the correctness path and the universal response to eviction, memory pressure, missing state, or planner uncertainty. An exact batch is still an exact transaction: broad input scope, only changed outputs.
The choice is made per routed region, as a Plan:
Selective { candidates } drive from an enumerable candidate source
ExactBatch { nodes } sweep the region with the exact evaluator
Conservative neither is executable; keep the routed region as-isPlan::Conservative is not a restyle mode: it keeps the conservatively routed region, whose members are still checked by the match program before any delta is emitted, so precision is lost but correctness is not. There is no separate "recompute and republish" fallback mode; every degraded path narrows to one of the shapes above, and the broad ones are counted.
4. Inputs, epochs, and transactions
4.1 Terminology
- Semantic input: an authoritative fact from DOM, CSSOM, browser state, or environment.
- Derived value: a selector result, rule match, cascade winner, computed value, or damage result computed from semantic inputs.
- Logical program: the immutable, shared description of selector, activation, cascade, and property dependencies.
- Materialized view: a cached physical representation of a derived value or relation.
- Style transaction: a batch of input changes applied atomically from the perspective of style observation.
- Style read epoch: a mutation-free interval in which style reads the authoritative live DOM and other versioned inputs.
- Style node: the engine's compact identity for a DOM element.
- Style feature atom: a compact identity for a selector-mentioned tag, ID, class, attribute, value, namespace, or state fact. It does not own duplicate string payload.
- Match program: a logical-IR evaluator answering whether a candidate subject matches a selector during one read epoch.
- Transpose program: compiled inverse routes that start from a changed selector input and enumerate a safe superset of subjects whose match truth can change.
- Delta-routing registry: required program state mapping a typed semantic input key to transpose entry points.
- Impact region: the normalized union of subjects, relation ranges, and dependent scopes emitted by transpose programs for a transaction.
- Impact-region attribution: the split between regions attributed to a specific rule and program (eligible for exact planning) and unattributed regions (evaluated conservatively).
- Match-answer identity: a compact identity for one normalized exact selector answer.
4.2 Style read epoch
The epoch is a logical immutability contract, not a copied DOM or CSSOM snapshot, and not a reified object: it is upheld by phase rather than by a header. The evaluator runs synchronously at a point where script cannot mutate style-visible inputs, an FFI callback guard rejects reentrant entry into the engine, and identity retirement is deferred (retired StyleNodeIDs are released for reuse only at a later safe boundary), so nothing an active evaluation can name is destroyed or reused under it. The evaluator queries current tree relations, element features, and document state from their authoritative live objects; nothing walks, clones, flattens, or retains the DOM.
The final live tree is the authoritative new state. The old side is not pinned: the normalization journal's first record per key preserves the pre-transaction value (§4.4), retained derived state supplies old selector truth where resident, and tree/program transactions reconstruct their before side from those sources when planning after commit. Compact mutation records carry only the old-side facts needed to discover everything whose output may have changed.
Database counterpart: the isolation is phase-based rather than MVCC: one writer-free interval per flush, with before-images carried in the changelog instead of retained versions.
4.3 Typed deltas
Input kinds (InputKind, transaction.rs):
TreeRelations parent/sibling/scope/slot/host relation changes
LocalFeature tag, ID, class, attribute, namespace fact changes
State element and document state facts (:hover, :checked, ...)
ElementDeclaration element-owned declaration blocks
ElementStyleInput edge-triggered per-element style actions (not retained fact state)
Program stylesheet program changes
CascadeTopology layer/order topology changes
Environment media, viewport, preference, and other environment inputsAnimation state is not an input kind: an element whose animation-relevant inputs changed is reached through the kinds above, and animation sampling is an overlay on published style (§9.11), not a change stream into the engine.
Typing matters: treating every change as a generic version bump moves work from mutation time into an unnecessarily broad lazy validation later.
ElementDeclaration covers declarations sourced from one style node rather than a stylesheet rule: the style attribute, HTML presentational hints, and SVG presentation attributes such as fill and stroke. The authoritative HTML/SVG mapping produces an immutable old/new declaration block with its language-defined cascade placement; StyleEngine does not reinterpret attribute syntax.
One mutation can publish several independent typed effects. Changing an SVG fill attribute may change an attribute-selector feature, a presentation declaration, and an attr() input. Routing sends each effect to its own consumers; handling one never stands in for the others.
4.4 Transaction boundaries and the normalization journal
Script performs many mutations before style is observed. Record them and normalize at the transaction boundary:
- Adding then removing the same class produces no final class delta.
- Inserting then deleting an unobserved rule produces no selector or cascade work.
- Changing a declaration three times retains only the old and final declaration block versions.
- Inserting several sheets during parsing produces one ordered program update.
- Moving a subtree twice retains only its original and final relationships.
Operations whose Web-platform semantics force synchronous observation establish a boundary. Style reads (resolved-style queries, layout-dependent APIs where required) flush all earlier input changes before returning. Normalization never combines changes across a required observation boundary.
Journal structure. Pending changes go into a keyed normalization journal:
- DOM/state keys: style-node identity + typed fact or relation.
- Program keys: stable sheet/rule/declaration/attachment/topology identity + edited field.
The first record for a key preserves the pre-transaction value; later records replace only the pending final value; cancellation removes the key. Raw mutation count is therefore not the default work multiplier.
Database counterpart: the journal is the changelog, a CDC stream, and not a write-ahead log: it exists for change propagation, not durability or recovery, so WAL disciplines (idempotent replay, checkpointing) do not transfer. Key-level last-writer-wins is log compaction, the retained pre-transaction value is the before-image, and the overflow marker below is compaction of a key range into one coarse record whose replay is a scoped rescan.
For tree and feature mutations, the first record retains the minimum old-side information required by the applicable transpose rules:
- old and new feature atoms;
- old and new parent, adjacent-sibling, tree-scope, flat-tree, slot, host, and pseudo-relation endpoints;
- operator-specific boundary identities for affected sibling or subtree ranges.
A removed style-node identity and any required relation tombstone stay valid until the transaction retires. The journal does not copy unchanged descendant fields and does not preserve a traversable old DOM. Old-side facts exist to establish a complete impact region, not to reconstruct history.
Resident derived state supplies old selector truth when available. If it was evicted, discard the affected derived fragment, evaluate the final state from the live DOM, and compare with the last committed style output.
Journal overflow. The journal is byte-accounted against its own memory category. Its capacity allows four fine-grained entries per connected element and is clamped between 4 MiB and 32 MiB. When growth would cross that capacity, the most numerous reconstructible fact, relation, program, or topology input kind's fine-grained entries are replaced with a typed complete-scope marker covering the whole document for that kind, and later records of that kind are dropped outright. Edge-triggered ElementStyleInput actions are never marker-coarsened: their reaction and inherited-group bits remain attached to every target and repeated actions on one target are unioned. At flush a marker widens the impact region to the document, and the plan evaluates that region exactly (§3). A pathological script can force broad discovery work but cannot discard an action whose target cannot be reconstructed from final facts. (Environment markers are exempted from disabling the retained-selector fast paths, since environment changes cannot alter selector incidence.)
4.5 Mutations during evaluation
During evaluation, DOM and CSSOM topology, element selector state, attached program order, and other style-visible inputs are logically immutable: the evaluator runs synchronously while the document cannot run mutating script, and the FFI boundary's callback guard rejects reentrant entry into the engine, so the phase contract is enforced at the boundary rather than by a per-read lock or version check. Reentrant or concurrently delivered changes (including parser, resource, animation, and environment changes that become ready mid-evaluation) are queued for a later transaction.
The read interval ends only after computed outputs and observer consequences commit atomically. Style node identities, program versions, and environment handles named by an active evaluation cannot be destroyed or reused before retirement; retired identities are released for reuse only at a later safe boundary.
5. Style node identity and tree representation
5.1 Identity
StyleNodeID is a document-local u32. A 32-bit identity is sufficient for one document and halves the size of many indexes and handles versus native pointers.
Pseudo-element matching projects declarations from an originating element's match set. A PseudoElementTarget carries the pseudo kind without allocating another style node. A first-class pseudo-tree representation may introduce its own identity model when view-transition pseudo-trees need one.
5.2 Column inventory
Do not blindly duplicate fields already on DOM elements. Persistent style-side storage is:
- a minimal style-data handle reachable from an element;
- required evaluator-local relation-navigation state;
- directly indexed columns for optional per-element fact metadata;
- compact dense columns for live computed-style publication handles;
- reusable temporary columnar batches for bounded-region evaluation;
- indexes containing only style-relevant facts.
parent required, dense
first element child required, dense
next element sibling required, dense
previous element sibling required, dense
depth required, dense
tree scope conditional, allocated only for multi-scope documents
assigned slot allocated per segment
shadow host allocated per segment
fact metadata header optional payload in a dense directly indexed column
prefix local-fact identity optional, Tier 3 segmented column
computed publication handles compact dense columns, Tier 1 for observed contentShadow-relation and prefix-cache columns are allocated per segment only when useful. Fact metadata and computed publication use dense directly indexed columns whose vacant rows carry no external payload; they trade empty slots below the highest used identity for constant-time access. A document with no shadow trees allocates no shadow relationship columns.
5.3 Required relation navigation
Transpose programs must traverse inverse selector relations without leaving the evaluator. That is why parent / first-element-child / next-element-sibling / previous-element-sibling are dense required columns rather than optional acceleration.
Concretely: for .theme .item and a class mutation on a container, the transpose step for the changed A operand is "descendants that can satisfy B". That set is not in the TreeDelta, so the evaluator must either stream the subtree from the authoritative side or intersect a resident .item posting with subtree membership. StyleNodeID is not a tree-order label, so membership costs Hregion relation steps per candidate. Without resident columns, every one of those steps is a boundary crossing, and the hot path degenerates into reverse cold requests or per-element FFI. Both are forbidden.
Rules:
- Dense
u32, allocated for every connected style node, charged to Tier 1, counted against the mandatory per-node byte budget (§10.5). - The DOM remains the Web-facing semantic owner, while these columns are the engine's committed evaluation projection. C++ must publish every relation mutation, and the columns must agree with the final live tree at every epoch boundary; the exact evaluator reads this projection rather than fetching the DOM in reverse.
- Not evicted under ordinary memory pressure; Tier 3 is evicted first. Their allocation is required engine state, and a missing committed relation row is an invariant violation rather than a cold-path signal.
- Ownership follows the tree: the subsystem that mutates DOM structure publishes relation-column updates as part of the same
TreeDeltathat reports the mutation, so they cannot drift within a read epoch. Mismatch is an invariant violation, not a recoverable condition.
Tree-scope navigation has the same traversal requirement once multiple scopes are supported, because scope-membership transposition works the same way. Allocate the dense tree-scope column only for documents that need it, and charge it to the conditional byte allowance (§10.5). Flat-tree operations derive from the sparse assigned-slot and shadow-host relations instead of duplicating a third parent column.
5.4 Temporary packing
Packing is an execution choice, not snapshot construction. Small or selective work queries the resident columns directly. A sufficiently large transaction compiles its affected regions into a preorder topology charged to Tier-4 scratch; broad matching holds an immutable shared view of the primary fact columns, while transaction-specific pre-images remain bounded overlays. Scratch is generation checked and released after the transaction. Whole-document cold evaluation never allocates a second complete flattened DOM or fact-store copy.
5.5 Multiple tree relations
CSS does not operate over one universal tree. The logical input distinguishes:
- DOM ancestry
- shadow-including ancestry
- flat-tree inheritance relationships
- slot assignment
- part exposure
- pseudo-element origin relationships
- view-transition capture and generated pseudo-tree relationships
- style-scope membership
Every selector operator states which relation it consumes. This keeps special cases out of the generic parent and descendant operators. A generic descendant walk does not pierce a shadow root or follow slot assignment.
5.6 Document order
Build preorder ranges in transaction scratch for the scope being evaluated; retain no per-node tree-order label. Queries depend on relative order and ancestry, never on the numeric representation of an order label, so physical relabeling is invisible to semantic dependencies.
A subtree move emits relationship deltas for its boundary only. Internal relationships remain shared unless the move changes the relevant tree relation, scope, slotting, or inherited context.
6. Selectors
6.1 Element and document state
State is represented as semantic facts, not bespoke invalidation entry points:
Hovered(element) Active(element) Focused(element)
FocusVisible(element) Checked(element) Disabled(element)
Valid(element) Target(element) PlaceholderShown(element)
Open(element) PopoverOpen(element)A transition publishes old and new fact values. Only selector operators depending on that state receive the delta. Derived states such as :focus-within are logical operators over tree relationships; the same machinery supports :has(:focus), :has(:checked), and state nested in selector-list pseudo-classes.
High-frequency states use the same semantics but may be scheduled in a low-latency lane: a hover change must not wait behind compilation of an unrelated large stylesheet when its dependencies can be evaluated independently.
Visited-link privacy. Visited state is a routed state fact like any other; a dedicated privacy-taint component in operator, cache, and record identities is not yet implemented. When visited-dependent styling is implemented to specification, tainted materializations must not expose visited information through timing diagnostics, developer APIs, or cache keys observable across privacy boundaries, and untainted and tainted results must never share a proof merely because their value bytes match.
6.2 Selector logical IR
Selectors compile into a compact logical IR. The IR defines semantic equality independently of how an operator is evaluated. Representative operators:
LocalFeature And Or Not
Parent Ancestor NextSibling FollowingSibling
NthPosition RelativeExists
ScopeStart ScopeEnd Host Slotted Part
PseudoElementProjection StatePredicate6.3 Bidirectional compilation and delta routing
Every selector compiles into two views of the same semantics:
match program: candidate subject -> exact match result
transpose program: changed input -> possible affected subjectsThe match program is exact. A transpose program may over-approximate but must never omit a subject whose selector truth can change. It starts at each occurrence of the changed input in the selector and traverses inverse combinator relations toward the subject. Resulting subjects are checked by the match program before any selector-truth delta is emitted.
| Match fragment | Changed operand | Transpose step toward possible subjects |
|---|---|---|
A B |
A |
Descendants that can satisfy B |
A B |
B |
The changed B node |
A > B |
A |
Element children that can satisfy B |
A > B |
B |
The changed B node |
A + B |
A |
The next element sibling if it can satisfy B |
A + B |
B |
The changed B node |
A ~ B |
A |
Following element siblings that can satisfy B |
A ~ B |
B |
The changed B node |
Compound operands transpose to the node whose local fact changed. Structural pseudo-classes add typed child-sequence entry points. RelativeExists transposes a possible witness change through the inverse relative-selector axes to possible anchors, then transposes an anchor-truth change through the outer selector to its subjects (§7). Every step names its DOM, shadow-including, flat-tree, slot, part, pseudo, or scope relation explicitly.
Database counterpart: the transpose program is the standing query's delta rule, its derivative with respect to one input: given that input's change, it enumerates the output rows that can change. Match-checking every enumerated subject is delta verification, and it is what makes safe over-approximation legal.
Delta-routing registry. Required program state mapping each semantic input key to the transpose entry points that mention it:
(input kind, StyleAtomID or relation kind) -> transpose entry-point slice (stable order)It covers tag, ID, class, attribute, namespace, state, tree relation, structural, scope/topology, and activation inputs. Selectors with no selective local atom go into a typed always-consulted slice for the input kinds that can affect them, not one global universal bucket. A child-list mutation must not consult programs that depend only on an environment predicate.
Construction and bytes are charged to program compilation and Tier 2. Each input key indexes one insertion-ordered route slice, and all slices are packed into a flat RouteID vector; route headers and their variable-length paths live in parallel columnar arenas. The structure is proportional to selector-input incidence, not element count.
The registry is required, not optional: scanning every selector header for every ordinary mutation changes the hot-path asymptotics. Feature postings (Tier 3) may accelerate subject enumeration, but evicting them never changes which transpose entry points run. Program replacement keeps old registry entries alive through old-result removal and installs the new program and registry atomically in the next epoch.
6.4 Impact regions
For a normalized transaction T and attached program version P:
Impact(T, P) = normalize(union(transpose_entry(delta) for delta in T))The result can contain individual style nodes, sibling intervals, subtrees, tree scopes, and dependent flat-tree or shadow scopes. It is a transaction-local execution plan, never a retained element-by-selector relation. Overlapping regions coalesce without changing their named tree relation.
Every selector operator and every non-selector dependency that can request exact scope evaluation has a transpose rule satisfying:
Between the last committed state and the final live state in the read epoch, every style node whose semantic selector, cascade, computed value, or observer output can differ is contained in
Impact(T, P)or in an explicitly chained downstream region produced from it.
Coverage is fail-closed: any gap widens the region; unknown scope is never empty scope. Explicit epoch-bound impact certificates are not retained today; the coverage obligation is the same, enforced by widening and verification.
Consequences to implement:
- Inheritance transposes through the flat tree.
- Slot reassignment emits both participating light-tree and shadow-tree regions.
- A scope or stylesheet-topology change names every scope whose attached program or cascade order can differ.
- When an operator cannot construct a narrower proven region, it widens to that operator's complete tree scope, and ultimately to the document.
Regions carry attribution: a region attributed to a specific rule and selector program is eligible for exact per-entry planning, while an unattributed region is evaluated conservatively. Any gap (a missing posting, an incomplete retained answer, an unroutable input) surfaces as a typed Missing/Incomplete lookup whose only legal response is widening, ultimately to the document. A verification gate can additionally assert that a scoped plan publishes nothing it cannot attribute.
The exact batch plan consumes only a complete impact region.
6.5 Execution kernels
One selector semantics, three cooperating execution kernels. The boundaries are physical, so operators can move between them later without changing query meaning.
Local-compound kernel: the dominant hot path. Evaluates tag names, IDs, classes, attributes, namespaces, element states, and Boolean compound structure. Uses fixed-width interned atom IDs at the C++/Rust boundary, a rightmost-feature dispatch key, and an arena of fixed-width enum nodes with shared operand and literal tables. Candidate tests are ordered cheap/high-rejection first and short-circuit on failure. A compound with no relational operator allocates no witness. (Non-relational chains do participate in the retained prefix automaton below when admitted; that state is Tier-3 and evictable, never required.)
Selector feature keys have two sparse directions with independently bounded sizes:
feature atom -> candidate StyleNodeID posting Tier 3, optional
feature atom -> transpose entry points Tier 2, required, program-proportionalThe first avoids scanning DOM facts and may be evicted. The second lets a class, attribute, tag, ID, or state mutation activate only routes mentioning the changed feature. If the posting is evicted, the routed transpose program reads authoritative DOM facts or uses an exact scope batch. No element-by-selector relation is ever required.
Directional-combinator kernel: child, descendant, adjacent-sibling, and subsequent-sibling relationships consume the same local compound programs, under operation-specific schedules:
- Cold initial style: stream style nodes, gather candidate program offsets from each node's local feature atoms, match relationships right-to-left with direct tree walks.
- Dynamic selector insertion: reverse direction: start from the new program's safest rightmost-feature posting and check only those candidates when selective.
- Mutation to an attached program: start from changed local truths. Descendant and child effects run as a top-down prefix-state pass over the exact affected scope, warm-started from the retained prefix states of the previous transaction where those survived.
- Sibling effects: adjacent-sibling inspects the immediately participating siblings; subsequent-sibling and structural effects use the same automaton's child-sequence machinery and stop at unchanged outputs.
These are schedules over the same logical IR.
The prefix automaton. Selector-prefix truths are retained across transactions as a Tier-3 cache: interned prefix states (the "context tokens" entering each node), per-element entering states, per-element terminal match sets, and per-element positional truth bits. Registration is budgeted: an automaton carries at most 32 structural-test truth bits, and a chain that would overflow is refused whole and stays with its exact routes, so refusal degrades to exact matching, never to a wrong answer. The cache's bytes cycle through scratch during a flush and are retained at the end of a patched flush; it is prioritized above the retained answers it maintains (§10.3), and discarding it is always legal; the next flush rebuilds from exact evaluation.
Relational-query kernel: RelativeExists reuses local compounds and directional traversal to answer an existential question per anchor. Witnesses belong only to this kernel. A document with no relational selector allocates no relational queues, per-node relational fields, or relational cache tables (the witness table is empty and unallocated, and relational routing early-returns on an empty route set).
Selector lists and Boolean functional pseudo-classes compile to branch nodes in the same IR. Specificity, dispatch analysis, and match metadata stay static program data rather than being reconstructed per candidate or scope-dispatch build.
6.6 Local feature indexes
Selector posting keys are semantic feature atoms, not strings: tag name, ID, class, attribute name, directionality, part name, and custom state. Namespace, ordinary state, root, heading, and other fixed facts are read from the authoritative row when dispatch reaches a candidate. Every attribute operator drives from the attribute-name posting; exact equality can reject on its value atom before matching, while substring and token operators run their exact text test in the compound evaluator.
Representation: chunked sorted postings, sorted by StyleNodeID. While the Tier-3 category is admitted, every published selector feature is maintained eagerly, whether or not the current program mentions it. Forgetting an element removes all of its posting memberships; memory pressure evicts the complete posting category, and later selective demand may rebuild its missing keys from authoritative facts.
One key stops retaining a posting when its membership grows beyond the greater of 4096 elements and one quarter of the live elements. The key then reads as missing acceleration, so every consumer takes its exact fallback instead of retaining a near-document-sized secondary index.
Database counterpart: secondary inverted indexes, and non-clustered ones: StyleNodeID is not a tree-order label, so proving a posting candidate lies in a region is the row lookup a non-clustered index pays when the predicate lives on the clustering dimension.
Because StyleNodeID is not a tree-order label, a subtree impact region is not a numeric posting interval. For a selective plan, enumerate posting candidates and prove region membership through the named tree relation, charging ancestor/sibling steps explicitly. Those steps walk the required relation columns, so a selective plan costs Hregion in-evaluator pointer hops rather than boundary crossings. For a broad region, stream the region in preorder and test local features from authoritative facts instead of building a full posting-to-preorder join.
Transaction-local preorder arrays make repeated relation checks constant time only after their construction cost and StyleNodeID-to-preorder mapping bytes are charged to discovery and Tier 4.
6.7 Selective-plan cutoff
The plan choice is made once, before enumeration, from candidate cardinality and region size: the exact batch plan is chosen when the smallest safe candidate source covers more than one quarter of the region (SELECTIVE_SHARE_DIVISOR = 4). A candidate source of 64 or fewer skips the cost model entirely and goes straight to direct membership checks.
The cutoff uses exact cardinality of normalized impact ranges. If exact cardinality is unavailable, the batch plan is chosen rather than a smaller scope guessed. Missing Tier-3 acceleration can make the exact batch plan cheaper, but memory accounting does not change the plan after enumeration begins.
6.8 Query outputs
SelectorMatches(SelectorProgramID, StyleNodeID, MatchMetadata)MatchMetadata contains only what later semantics need (selector-list entry, specificity, scope proximity, pseudo-element target) and is factorized when fields are static for the selector.
Rule matching is a join:
SelectorMatches
AND RuleUsesSelector
AND RuleAttachedToScope
AND ActivationPredicateIsTrue
-> ActiveRuleMatchesThis separation lets declaration edits, condition changes, and attachment changes reuse selector truth.
Concrete matches are enumerated into a transaction-local sorted StyleNodeID vector and discarded after cascade consumption.
6.9 Specificity
Specificity is program metadata with explicit aggregation rules. Each top-level complex selector in a rule's selector list carries its own static specificity. If multiple entries match the same style node, MatchMetadata retains the greatest matching entry specificity together with any independently required scope and pseudo metadata. Short-circuiting may skip work only when it cannot hide a later matching entry with a greater cascade contribution.
:is() and :not() contribute the maximum specificity of their argument list as a static property of the program, regardless of which branch establishes the Boolean result. :where() contributes zero. :has() uses the corresponding static argument-list rule even though witness discovery is dynamic. Structural and nesting operators use their specification-specific contribution; none of them inherit a generic runtime "specificity of whichever branch matched" rule.
The cold evaluator and the incremental evaluator consume the same static specificity table.
6.10 Ephemeral selector consumers
Selectors passed to querySelector(), querySelectorAll(), matches(), and closest() are match-only query programs. They compile with the same selector semantics and evaluate with a bare match evaluator over the engine's fact store, but they are not attached style programs: no transpose routes, no routing registry entries, no rule identities, no witness reads or writes, no cascade state. Scope roots, shadow boundaries, relative-selector anchoring, and syntax failure are explicit compiler inputs. Result ownership belongs to the DOM API, not StyleEngine.
Two mechanisms make this correct and fast against a live document:
Settling. A connected query must see the current DOM generation, which may have pending staged input. Preparing a query commits the pending fact rows into the resident store while stashing their pre-images; the next flush (or normalize) restores the pre-images first, so the style transaction still computes its before/after diff as if the query had never intervened. A query observes current facts; invalidation keeps its native orientation.
Isolated engines for disconnected roots. A query rooted in a disconnected tree runs against a small throwaway engine populated from that subtree alone. These engines are cached per root (an LRU keyed by root and mutation versions) so repeated queries over the same detached tree reuse one population, and a compiled query program is cached for the lifetime of its SelectorQuery object rather than recompiled per call.
6.11 Push/pull division
Push exact deltas only when the transaction or a resident view (witness, winner column) directly names the consumer. Everything else is validated by exact pull evaluation of the affected scope.
When explicit subscriber fanout would cross a hard edge threshold, do not allocate the edge set; pull from the exact affected scope instead.
7. Relational selectors and `:has()`
RelativeExists is an existential query relating an anchor to a relative-selector witness. Its semantic output is Boolean even when many witnesses exist. Only transitions between zero and nonzero witnesses can affect selector truth.
7.1 Compilation
A relative selector compiles into operators over: anchor scope; child or descendant traversal; adjacent or following sibling traversal; ordered sibling-descendant combinations; local compounds and selector-list logic. Every traversal operator names its tree relation and scope boundary.
7.2 Simple-query grammar
A :has() query is simple when all of:
- the relative selector contains exactly one axis (a selector-list argument compiles each branch into its own relative query, each classified independently);
- that axis is one of: implicit descendant, child, adjacent sibling, or following sibling;
- that axis is followed by exactly one local compound;
- the compound contains at least one positive indexable driving feature;
- the query contains no structural pseudo-class, nested relational operation, internal combinator, pseudo-element projection, or shadow/scope-crossing construct.
Simple: :has(.error), :has(> .item:hover), :has(+ .selected), :has(~ .selected). Complex: :has(.group > .item.error), :has(:nth-child(2)). A list such as :has(.a, .b) is two simple queries, each with its own axis and driving feature.
Complex ≠ unsupported. Complex queries use the exact direct relative-selector evaluator and retain no witness.
Selectors Level 4 forbids nesting :has() inside :has(); reject that syntax at compile time rather than treating it as an incremental-coverage case.
7.3 Witness state
Retain at most one witness per simple positive observed query. Consequences:
- Adding another witness does not change state and emits nothing.
- Removing a non-retained witness requires no repair.
- Removing the retained witness triggers a replacement search.
A retained witness is a proof cache, never semantic state; evicting it is always legal.
Database counterpart: :has() truth is an EXISTS aggregate, so only zero-to-nonzero transitions are deltas. The retained witness is a one-row partial materialization of the subquery; losing it is a view miss repaired by an upquery, never a semantic event.
Bounds. A retained witness is re-verified on read (liveness, axis membership, and a match re-check), with a fixed step cap on the following-sibling back-walk; any verification failure clears the entry and routes conservatively. The witness table itself is capped (currently 16,384 entries). Limit-crossing growth remains available for its current quota period, and the table is dropped whole at the following boundary. Replacement after a lost witness is the ordinary forward walk of the query's axis; the routing-side cutoff (§6.7) bounds how witness-side discovery drives anchors. A Boolean result is never approximated.
7.4 Negative results
A negative :has() result must not allocate dependency edges to every element in the searched region, and it allocates nothing at all: a query that finds no witness stores no state. Absence is recomputed on demand, and the false-to-true transition is caught from the witness side: a mutation that could create a witness routes through the relative-selector transpose entry points to its possible anchors (§7.5), so there is no cached negative to invalidate and no stale negative can exist.
7.5 Witness creation (false-to-true)
When a local mutation can create a witness, routing activates the relative-selector transpose entry point. For a simple query, possible-anchor discovery is bounded by the single inverse axis:
| Relative axis from anchor | Possible anchors for a newly matching witness |
|---|---|
| Child | Its element parent |
| Descendant | Its element ancestors up to the query's scope boundary |
| Adjacent sibling | Its immediately preceding element sibling |
| Following sibling | Its preceding element siblings in the same child sequence |
Each possible anchor is checked exactly, including anchor compound and scope semantics. A false-to-true Boolean transition then enters the outer selector's transpose program, so .card:has(.error) .button reaches only possible .button subjects in the affected anchor context. A new additional witness for an already-true anchor emits no Boolean delta and never runs outer propagation.
Complex relative selectors use the same principle with multiple inverse axes; their transpose program may widen to a sibling interval, ancestor path, subtree, or complete tree scope, but never omits a possible anchor. Discovery work is charged even when every anchor check rejects.
7.6 Non-subject `:has()`
For .card:has(.error) .button, the relative query produces a Boolean anchor result that updates the anchor's outgoing top-down selector context. Descendant .button matches change only where the context transition changes their rule output. Adding a second .error while a witness already exists emits no anchor-truth delta and therefore no descendant selector work.
8. Stylesheet program
8.1 CSSOM versus semantic program
- CSSOM objects preserve API identity, ownership, indices, and serialization behavior.
- The semantic program is an immutable, content-keyable representation used for computation.
Editing a CSSOM object creates a new semantic version without replacing the wrapper object.
8.2 Stable identities
SheetID stable semantic sheet identity
RuleID stable semantic rule identity
SelectorProgramID immutable compiled-selector identity (content-interned:
equal compiled selectors share one ID)
DeclarationBlockID declaration-block version (a fresh identity per edit)
CascadeLayerID layer identity
ProgramVersion one document-global program version, bumped per changeCSSOM wrapper objects hold their semantic identities on the C++ side (a stylesheet knows its SheetID, a rule its RuleID). A declaration edit preserves RuleID and SelectorProgramID while installing a new DeclarationBlockID; a selector edit preserves the rule identity but changes the compiled selector program and match relation. Rule contents are updated in place under the rule's single version slot; there is no retained history of prior rule versions, and nothing reads one: evaluation always runs against the current program, with old truth supplied by the journal's before-images and retained derived state (§4.2). Activation state (media, supports, container, sheet disabled) is tracked as current condition booleans per rule and sheet, updated by typed inputs.
8.3 Rule version contents
RuleID
SelectorProgramID
DeclarationBlockID
conditions-hold flag
CascadeOrigin
CascadeLayerID
StyleScopeID
StyleSheetOrderToken
NestedRuleOrderTokenSelector list entries may carry separate static specificity and matching output identities while sharing a declaration block. Nested group rules form a persistent tree: editing one descendant creates new nodes only along the path to it; unchanged selectors, declarations, and conditions keep their identities.
8.4 Attachment
Attachment connects a compiled program to one or more style scopes and is distinct from parsing and compilation. It covers parser-created style/link elements, dynamically inserted stylesheet nodes, load completion, constructed stylesheets, adoptedStyleSheets changes, sheets shared by multiple shadow roots, enabling/disabling, and UA/user/author origins.
One compiled program attaches to multiple scopes without duplicating its logical selector or declaration representation. Scope-specific indexes and materializations stay separate where tree membership requires it.
Selector programs contain process-global StyleAtomID values and immutable equal programs are shared across document engines on the StyleEngine thread. Each document retains references to the global atoms it uses; the final document releases the underlying interned-string identity and makes the numeric atom reusable. Entry and rule identities, routing registries, attachments, order tokens, and all result materializations remain document-local, so no document generation, tree scope, or result state crosses the sharing boundary.
The memory lease for a shared selector payload belongs to the process-global selector-program table. Document controllers report only their local program identities and indexes; the shared table reports each immutable payload once and keeps that charge until the payload's final document reference drops.
8.5 Source order
Source order composes from per-axis components:
origin a fixed enum ladder (with importance reversal)
tree context encapsulation-context depth
layer order per-scope layer ranks, rebuilt from the ordered layer list
stylesheet order order-maintenance tokens, one axis per tree scope
nested rule order order-maintenance tokens, one axis per sheetThe two order-maintenance axes use u64 gap labels: insertion writes one midpoint label, and exhausting a gap relabels the axis. Numeric relabeling changes no semantic version, because comparisons depend on relative order only.
Moving a sheet updates its sheet-order token; rules refer to that token indirectly, so their program nodes are not rewritten. Moving a nested rule updates the smallest enclosing order token.
Reordering affects selector truth only if scoping or attachment changes. Otherwise it is purely a cascade priority delta.
Database counterpart: gap-labeled ordering keys (fractional indexing). Comparisons read relative order only, so relabeling is a physical operation with no semantic version change.
8.6 CSSOM mutation surface
Every operation maps to a typed program delta. None produces generic document invalidation.
| Mutation | Primary semantic delta |
|---|---|
Attach, detach, or move a style/link element |
Attach, detach, or reorder a sheet program |
Change text inside a style element |
Structurally replace the sheet program |
| Finish loading a linked or imported sheet | Attach the compiled program at its semantic position |
Change href on a stylesheet link |
Detach the old resource; later attach the new program |
Change media or disabled |
Update an activation predicate |
Reorder adoptedStyleSheets |
Update sheet-order tokens |
| Add or remove an adopted sheet | Attach or detach an existing compiled program |
insertRule() (incl. grouping rules) |
Add a rule program at a new order token |
deleteRule() (incl. grouping rules) |
Remove a rule program |
Change selectorText |
Replace the selector-program edge of one rule |
setProperty(), removeProperty(), declaration cssText |
Replace one declaration block |
| Change a condition rule's text | Replace an activation predicate |
replace() / replaceSync() |
Structurally replace a sheet program |
| Edit keyframes through CSSOM | Update the named animation program and its consumers |
| Register a custom property | Update the registration input for that property name |
Change the style attribute |
Replace one element declaration block |
| Change an HTML presentational-hint attribute | Replace the mapped element declaration block, plus any independent selector/attribute inputs |
| Change an SVG presentation attribute | Replace the mapped element declaration block, plus any independent selector/attribute inputs |
CSSOM collections and wrapper objects reflect API-required mutations synchronously; semantic style work may remain queued until the next required observation. Stable object identities plus immutable program versions let those coexist.
8.7 Insertion
- Parse or receive the immutable stylesheet program.
- Compile selector, declaration, condition, layer, and scope nodes.
- Assign a stylesheet order token between neighbors.
- Attach the program to the applicable style scope.
- Evaluate only selector-program nodes whose results are not already available in that scope.
- Gate selector matches through the sheet's activation predicates.
- Add exact matched-rule deltas to the cascade.
No existing selector truth is invalidated because a sheet was inserted. If the new declarations lose the cascade everywhere, computed styles are unchanged even though the sheet matched many elements.
Batch planning. A program transaction is planned as a batch, not a loop over inserted rules. Compile all new selector fragments, then choose selective or exact-batch evaluation once:
- A small number of selective rules drives evaluation from sparse feature postings.
- A large sheet or burst of sheets builds temporary rightmost-feature buckets and runs one columnar pass over the relevant live style nodes, evaluating all new fragments together.
Temporary buckets and candidate arrays are Tier-4 scratch, discarded afterward.
If no style was observed before parser or script mutations completed, compute only the final program and style state. If an observer required an intermediate state, apply later sheets incrementally to that committed state.
Loading and @import. Network completion order does not determine cascade order. A linked or imported sheet receives its semantic position from document and rule order before or while it loads; when contents arrive, attachment uses that existing token. An @import rule is an import program node carrying layer, supports, media, and source-order inputs; load completion attaches the imported program beneath it without rebuilding or renumbering the parent sheet. Several sheets completing before the next observation join one normalized transaction while keeping their independently established positions. If script forces style between completions, each observed completion commits as its own transaction.
Parsing and immutable program compilation may run ahead of attachment, off the main style-evaluation path, subject to CSSOM visibility and loading-order requirements.
8.8 Removal
Removing a sheet detaches its rules from the scope. Determine affected matched rules without retaining a complete inverse match relation per rule:
- Reject a removed rule immediately when the complete winner-group column proves it won no declaration.
- Otherwise route the detached selector programs from their indexed subject features and evaluate them exactly before retiring them.
Bulk removal is grouped: detaching a sheet or rule subtree evaluates its program-owned selector set as one change and removes its declarations from cascade structures in one operation. Cold content validates against the new program version lazily. Removing a sheet with no winning declarations emits no computed-property deltas.
8.9 Rule-level edits
insertRule() adds one immutable rule version at an order token between neighbors and compiles and evaluates only the new selector and condition fragments. Existing rules are neither renumbered nor rematched.
deleteRule() removes the rule from its matching elements and repairs cascade winners for the properties it declared. A cold deleted rule is re-evaluated to enumerate its affected elements. Deleting a group rule detaches the persistent subtree rooted at that group; shared descendants stay alive if referenced elsewhere.
selectorText replacement keeps the RuleID and order token, compiles the new selector program, and records one typed input carrying the old and new program identities. Both programs are then evaluated through the retained-answer patch machinery: old-program matches produce removals, new-program matches produce additions, and the cascade-winner comparison stops propagation where the outcome is unchanged, except that an element whose recorded winner came from the edited rule always takes the full cascade reduction, since the fast comparison cannot prove its winner survived. Answer identities are keyed by program, so an element matching both the old and new selector still transits two answer identities; the stop happens at the winner layer, not the match layer.
Declaration change does not invalidate selector truth. A resident exact match relation feeds the declaration-block delta directly into the cascade; if absent, exactly reconstruct only the edited rule's matches and charge that selector work to reconstruct. Never rematch unrelated rules. Only properties present in the old or new declaration block are winner-change candidates.
Inline style uses the same declaration-block mechanism, with a match relation containing exactly its originating style node. HTML presentational hints and SVG presentation attributes use the same element-owned relation with distinct declaration kinds and their spec-defined cascade placement. They do not masquerade as inline style and do not enter the selector program merely because their source syntax is an attribute.
8.10 `replace()` / `replaceSync()`
Replacement reuses rule identities positionally: the sheet's style rules are snapshotted, and each incoming style rule reclaims the next prior RuleID in order, carrying its old declaration block forward and recompiling its selector. Compiled selector programs are content-interned, so an unchanged selector keeps its SelectorProgramID and its match relation for free. A sheet containing any non-style rule (or a rule-kind mismatch mid-list) falls back to whole-sheet detach-and-reattach, which really does swap the rule list; that path still compares selector truths and winners before emitting downstream changes, so correctness never depends on the reuse heuristic finding a correspondence. A content-based structural diff that survives insertions at the top of the sheet is a future idea.
8.11 Layers and conditional rules
Cascade layers form their own versioned topology. Layer statements, nested layers, and sheet attachment can change relative layer priority; rules reference layer identities rather than copied numeric ranks. A layer topology change updates property-specific cascade comparisons for participating matched declarations and does not invalidate selector truth. Important declarations use the reversed layer ordering through the cascade-priority operator.
Program deltas are typed for: @media and sheet media attributes, @supports, @container, @scope, @starting-style, @property, @font-face, @keyframes, @counter-style, @font-feature-values, namespace and layer statements. Each publishes deltas to its semantic consumers: changing a keyframes rule begins at animations referencing its name, not at every element in the scope.
9. Cascade and computed values
9.1 Match-answer identities
Comparable selector answers are immutable and interned under a compact integer identity. The identity names the normalized RuleMatch vector independently of the style node it was evaluated for, so equivalent answers can reuse cascade expansion and prefix-cache results without copying or renormalizing the vector.
These identities are document-local acceleration, not a retained per-element match column. A style ask publishes only the identity of the answer it just produced, and answer caches may carry that identity to their consumers. Program changes reach their candidates through selector dispatch and exact evaluation of the old or new program as appropriate. Do not retain a complete RuleID -> elements relation; inverse match sets are optional query-level materializations only.
Scope dispatch versions and encapsulation depths are resolved at matching-context
preparation. Matching and answer materialization borrow the prepared dispatch;
they do not rebuild a program or update a last-scope memo. The prepared scope set
includes host, slotted and part projections. Ancestor requirements are prepared
per topology before a batch, using dense rows for the document topology and
selected primary rows for other topologies. An adaptive retry prepares its
extended facts and primary ancestor summary before matching. Unbounded ancestry
rejects nothing, and secondary scope projections do not use primary ancestry.
Prefix transition rows and memoization belong to each caller's evaluation
context. Rows are prepared against the fact generation and count before
matching. Adaptive retries own separate row domains; a failed attempt drops
its effects. The ordinary child ask reads private rows and completed node
transitions before retained columns, computing missing selector ancestors
and preceding siblings into its own scratch. Completed ordinary transitions
return ordered effects installed at context completion, before retention
compacts IDs. Local-fact cohort IDs are prepared per fact batch before matching;
only preparation mutates their interner. Representative rows belong to the
preparation's fact domain, while contexts keep independent ID columns across
nested adaptive attempts. Appending rows preserves the existing cohorts.
Composite convergence preserves conservative unique cohorts for changed facts.
Local-fact column publication joins the transition effects. Persistent
state, match-set, truth and result interning remains eager during matching;
its overlay and remapping wait until prefix discovery moves into the
evaluation walk. Sparse convergence uses the same private scratch
and ordered effects, keeping retained old-comparison inputs distinct from
new parent and preceding-sibling outputs until convergence completes.
Same-flush prefix answer probes normalize non-prefix matches into a borrowed
slice and hash. Cache entries own that key content and confirm exact equality
on lookup, including hash collisions; keys no longer allocate or retain a
match-answer identity. Ordinary retained-answer reads borrow catalog slices,
reacquiring them after mutable operations rather than cloning their Rc owners.
Prefix contributions and answer-result identities remain eagerly interned for
a later completed-payload and canonical installation step.
Retained answer and cascade-input replacements, exact-cache discoveries,
publication and observation are owned effects. Node and content lookups consult
pending outputs before retained columns, including across an incomplete host
batch. Before-change comparisons continue reading the unchanged old columns.
The catalog's pending reference category pins numeric identities and charges
payloads to BatchScratch. Completed transaction and host-batch boundaries transfer
those references and install columns before retention, prefix release or catalog
sweeping. Input handoff finishes the previous host context before discarding
its publication; transaction entry does the same before committing new inputs.
Those boundaries also finish prefix effects and drop private transition IDs
before their arenas can be reset. Explicit abandonment releases pending
references; incomplete batches keep them until resumption or teardown.
Host consumers read pending publications without requiring their vector to have
been sorted. Deferred-pseudo comparisons borrow the before-change answer from
the old column, which owns it until installation, and the new answer from effects.
Match-program relation answers, sibling cursors, sibling sequences, type ranks
and positional answers live in caller-owned MatchScratch. An evaluator borrows
it exclusively; recursive matching cannot mutate another evaluator's scratch.
Sibling geometry is computed only on demand, and readers borrow sequence slices.
Current and old-fact evaluations share current geometry but keep distinct type
ranks and answers. Old-tree evaluation uses separate geometry. Prefix convergence
holds separate old/current scratch while both evaluators are live.
Completed current-fact relational queries return owned witness retain/clear
effects. Matching never reads the retained witness table. Transaction, document
matching and host-batch boundaries install those effects in evaluation order;
a following transaction installs pending host observations before routing reads
the table. Routing still revalidates retained witnesses against current facts;
stale-witness clears join the same ordered effects.
Ephemeral queries and before-side evaluators produce no witness effects.
9.2 Cascade priority
A declaration's priority is a comparison program over stable identities:
origin and importance
encapsulation context
element-declaration kind and language-defined placement
layer topology
specificity
scope proximity
source orderThe element-declaration component distinguishes inline style from HTML and SVG presentational hints; the mapping layer supplies correct origin, specificity behavior, and position relative to stylesheet rules. Components that change globally (layer or stylesheet order) are referenced indirectly, so updating a topology token does not rewrite every declaration.
9.3 Provenance and candidate access
For each observed style node, retained cascade state identifies each property's winning source