Vixen
Vixen is a modern-Linux Firefox replacement built in Rust: a minimal desktop browser, first-class headless/CDP automation, and the most web capability per byte.
The hard, spec-heavy subsystems are delegated where that keeps Vixen smaller and more correct: Stylo/selectors for CSS matching and cascade, deno_core/V8 for JS execution and host packaging, WebRender for paint, and html5ever for HTML. Vixen owns the product glue, modern-Linux Relm4/libadwaita shell, networking/security layer, persistence, headless tooling, WPT reporting, and the Rust layout engine.
Start here
- Project Direction — current focus and constraints.
- Architecture — crate layout and dependency direction.
- Plan and Milestones — implementation roadmap.
- Development — local workflow and contribution mechanics.
- GNOME SDK Flatpak builder — the Flatpak build/release path used by CI.
Repository
- Source: https://github.com/adonm/vixen
- Releases: https://github.com/adonm/vixen/releases
- Docs: https://vixen.adonm.dev/
Project direction
This is the short source of truth for product focus. Detailed implementation
notes live in the crate docs, DECISIONS.md, and code.
North star
Vixen is a Firefox replacement for modern Linux: a focused desktop browser plus first-class CLI/CDP automation, optimized for the most web capability per byte of binary and per MiB of memory.
The product should feel closer to Ghostty than to a kitchen-sink browser: small, fast to build, efficient to run, easy to iterate on, and boringly reliable.
The ambition is a real browser, not a demo shell. Vixen should first make a measured corridor of everyday sites reliable, then keep widening toward ordinary Firefox-replacement use: accessible documents and applications, media, offline storage/workers, richer graphics and communications, automation, and a credible security/release lifecycle. The constraint is not lower ambition; it is refusing duplicate engines, duplicate renderers, broad unbacked API shape, and UI features that do not move the browser toward daily usefulness.
The product bet is: a small, integrated Linux-first browser can be useful before it is universal if it is honest, inspectable, scriptable, and improving against a public compatibility loop.
Primary users
- Desktop Linux users who want a focused daily browser.
- CLI/CDP users running headless workflows, Playwright-style automation, and terminal-oriented apps such as https://adonm.github.io/zuko/app.html.
- Maintainers and agents using text reports to drive rapid, high-quality iteration.
Product metric
The leading metric is maximum capability for the smallest binary. When two solutions are both correct enough for the target WPT/spec surface, prefer the one with:
- smaller runtime/binary footprint,
- lower memory use,
- faster local builds,
- fewer moving parts,
- clearer text output for automation and review.
Correctness beats smallness at security/trust boundaries, data-loss boundaries, and rendering invariants. “Small” means fewer duplicate models and less framework gravity, not skipping the browser semantics users rely on.
Priority ranking
The user-facing rank is:
- CSS cascade, layout, and rendering — a Firefox replacement must draw real pages. Vixen owns layout; keep it WPT-driven and small.
- DOM/WebIDL/Web API runtime — modern pages need correct host APIs over
deno_core/V8. - Network/security/fetch/cookies — real browsing needs safe, fail-closed loading before breadth.
- Storage/history/session — required for real browsing and app-like sites.
- Minimal Relm4 desktop shell — focused browser UI, not a feature buffet.
- Headless CLI + CDP/Playwright-compatible seams — automation and text reports are product features, not test-only scaffolding.
- WPT/imported fixture coverage and reports — correctness driver for every item above. Treat it as cross-cutting, not optional polish.
- HTML parsing/serialization — essential but mostly delegated to
html5ever; Vixen must preserve tree shape and integration semantics. - CLI ergonomics — keep commands stable, scriptable, and useful.
- Embeddable Rust API — important as an internal seam, but not a separate product until the browser is credible.
Design lessons now baked in
Recent work proved that shared fetch/storage/runtime pieces are valuable, but it also exposed a larger risk: shell and headless/CDP still assemble those pieces with separate lifecycle state. The following lessons are now requirements:
- One browser state graph. Profile → browser → browsing context → document is the ownership hierarchy. Build or choose that owner, then expose it to JS, CDP, WPT, GUI, and headless. Parallel navigation, history, runtime, permission, or profile coordinators are temporary migration debt.
- A component seam is not lifecycle integration. Sharing
Page,Network, orJsRuntimetypes is insufficient if frontends decide independently when to create, commit, cancel, persist, or destroy them. Alpha requires a production engine-owned lifecycle. - Asynchrony needs identity. Context, navigation, document, request, runtime, and download work carries stable ids/generations. Cancellation invalidates the generation, and late work cannot mutate current state or emit success.
- Trust boundaries are product features. Validate URL/header/body/storage inputs near entry, fail closed, and apply response policy before exposure, execution, decode, cache insertion, persistence, download, or UI handoff.
- Automation must share the browser. CDP events, waits, DOM queries, input, and screenshots observe the same lifecycle and network/rendering paths as the GUI. Protocol shape without independent live targets is not multi-page support.
- Profiles are durable, bounded browser state. Cache, cookies, storage, history, sessions, permissions, downloads, and security state need one owner, partitioning, limits, recovery, and clear-data integration.
- Observability is an API. Stable errors and bounded privacy-minimal traces are product contracts. They distinguish policy, transport, unsupported, cancellation, stale state, and resource exhaustion without leaking content.
- Measure before budgeting; reduce before claiming. Size/performance limits need reproducible baselines. Every broad feature needs focused fixtures and a WPT path; every real-site bug becomes a reduction or an explicitly tracked unreduced failure.
Non-goals before alpha
- Cross-platform release targets beyond modern Linux.
- A kitchen-sink UI or clone of every Firefox chrome feature.
- WebKit fallback, runtime engine switching, or a generic JS-engine abstraction.
- A second desktop GUI toolkit path; Relm4/libadwaita is the GUI path.
- A CPU paint fallback that competes with WebRender.
- Media/WebGPU/WebRTC/service workers unless promoted by a later ADR.
- Full WPT/browser parity claims before measured profiles justify them.
- A full extension ecosystem, mobile port, or cross-platform packaging story.
- Site isolation/OOPIF work before the single-process browser is measured enough to know what isolation architecture is actually needed.
Alpha means
Alpha is not broad API completeness. Alpha means the architecture is frozen and validated for full delivery:
- one JS runtime target (
deno_core/V8), - one desktop GUI path (Relm4/libadwaita),
- one display list and one WebRender paint path,
- one layout architecture,
- one WPT/reporting workflow,
- hk-enforced git lifecycle gates,
- honest compatibility docs with measured local/imported fixture results.
Alpha also requires a production browser core: one profile service, one context registry, one generational navigation/document lifecycle, and one command/event path used by shell, headless, CDP, WPT, and page runtime. Two contexts must run independently while sharing only intended profile state, active navigation must be cancellable, and live DOM mutation must reach the visible render path. Narrow surfaces are acceptable; duplicate models are not.
Delivery horizon in one sentence
- Beta: a controlled real-site corridor is usable in GUI and headless, with measured compatibility/performance and known gaps.
- v1.0: Vixen is an honest daily-driver minimum for focused Linux users and a useful Playwright/CDP automation target, with security/reliability limits documented instead of hidden.
- Replacement horizon: continue through accessibility, media, offline apps, richer graphics/communications, ecosystem support, and stronger isolation until ordinary modern-Linux browsing—not only a curated corridor—is credible.
After alpha, API surface can still change, but architecture changes need a new ADR and human approval.
Roadmap
This is the delivery sequence from the current component-rich prototype to the full project goal: a credible Firefox replacement for modern Linux, with a focused desktop shell and first-class headless/CDP automation. It is deliberately more ambitious than a demo-browser plan, but it does not turn ambition into an unsupported compatibility claim.
Historical phase instructions live in PLAN.md, executable evidence
in MILESTONES.md, and measured support in
COMPAT.md. Already-landed feature inventories do not belong in the
future milestones below.
Destination and release ladder
The stages are capability gates, not dates:
- Alpha — one browser architecture. GUI, headless, CDP, WPT, page scripts, and profile services drive one engine-owned browser lifecycle. Narrow behavior is acceptable; parallel state models are not.
- Beta — a measured useful browser. A controlled real-site corridor works in GUI and automation with representative rendering, interaction, persistence, downloads, diagnostics, and Linux integration.
- v1.0 — an honest daily-driver minimum. The published corridor is reliable enough for focused daily use, release/security operations are credible, and every supported capability has reproducible evidence.
- Replacement horizon — broad modern-browser capability. Media, accessibility, offline applications, richer graphics/communications, extension support, and stronger process isolation expand the useful site set until “Firefox replacement” describes ordinary use rather than only a corridor.
No stage implies global Firefox or WPT parity. Compatibility claims always name the profile, platform, command, and measured result.
Proven baseline — use it, do not roadmap it
As of 2026-07-10 the repository has these building blocks:
- A seven-crate workspace, hk/
justdevelopment gates, stable diagnostics, fuzz targets, and a WPT/fixture harness. The committed manifest currently measures 269 fixtures / 2,015 checks at 100%;COMPAT.mdowns the detailed counts. html5everparsing, Stylo-backed selector/cascade integration, a Vixen-owned layout tree and focused formatting helpers, one display list, and one WebRender path used by GUI/headless screenshot surfaces.- A persistent
deno_core/V8 runtime seam, generated WebIDL scaffolding, focused page-backed DOM/CSSOM/geometry/events/forms/selection behavior, and explicit Rust ops/resources for selected stateful Web APIs. - Shared network policy primitives for URL validation, redirects, cookies, CSP, mixed content, referrer policy, CORS/preflight, cache revalidation, SRI, and stable network events; fetch and the minimal XHR surface reuse these parts.
- Bounded redb tables for cookies, Web Storage, fetch cache, history, sessions, downloads, permissions, and HSTS/security records, plus explicit clear-data selections and app-ID/XDG path helpers.
- A useful CDP slice covering navigation, runtime evaluation/handles, DOM basics, input, lifecycle/network/console/dialog events, screenshots, permissions, tracing-lite, and stable protocol errors, with an external Playwright smoke.
- A Relm4/libadwaita shell vertical with tabs, URL loading, basic navigation, visible WebRender output, diagnostics, and bounded session restore.
These are substantial components now routed through one initial browser owner, not yet a broadly compatible browser. API shape or inert reflection is still not counted as implemented behavior when the underlying subsystem does not exist.
The critical gap
The production path is now the browser-scoped BrowserHandle seam rather than
the older tab-shaped vixen_api::Engine trait. Shell, headless CLI, CDP targets,
and the WPT adapter create contexts in one vixen-engine::browser::BrowserCore;
they no longer own parallel Page, JsRuntime, network, cookie, history, or
profile-session state. CDP has one core context/runtime generation per target,
with bounded generation-scoped remote handles.
The largest remaining delivery risk has moved from frontend ownership duplication to the live document lifecycle. Parser/script/resource work still runs synchronously on the core owner after source loading, and compatibility projections still coexist with the live page/runtime. Adding broad API shape before converging those paths would preserve plausible but disconnected state.
Other material gaps remain:
- main-document source loading is asynchronous, cancellable, and generation checked, but parser, script, and discovered-resource work is still synchronous and not cooperatively interruptible;
- DOM/runtime snapshots and compatibility projections still coexist with live page state;
- layout uses deterministic text metrics and narrow block/inline/flex/grid coverage rather than shaped text and a broad formatting pipeline;
- images, fonts, subresource loading, media, accessibility, workers, IndexedDB, service workers, and multi-frame/multi-page execution are absent or only browser-shaped probes;
- downloads have persistence/protocol shape but no complete HTTP transfer lifecycle; and
- Linux cert/proxy/font/portal/GPU behavior and release measurements are not yet proven across a supported matrix.
Design rules for every stage
- One authoritative state graph. Profile → browser → browsing context → document owns state. Frontends send commands and observe events; they do not create alternate history, network, runtime, or permission models.
- One behavior, many adapters. GUI, headless, CDP, WPT, and page scripts must reach the same navigation, DOM, layout, network, and profile operations.
- State owner before API shape. Stateful WebIDL/CDP/shell surfaces land only after an engine owner exists. Pure immutable value objects may remain JS-only.
- Generational asynchronous work. Every navigation and document has a stable id. Cancellation invalidates the generation, transport aborts where possible, and stale completions cannot commit state or emit success events.
- Policy before exposure or side effects. Validate untrusted inputs and apply URL/CSP/CORS/mixed-content/integrity/storage policy before script exposure, cache insertion, persistence, download creation, or UI handoff.
- Bound everything user or content controlled. Queues, caches, object handles, traces, profile tables, decoded resources, DOM growth, script work, and diagnostics need explicit limits and useful failure modes.
- Inspection cannot invent a second page. CDP snapshots and geometry may request an explicit style/layout update or return a stable stale-state error; they may not maintain automation-only DOM or layout state.
- Measure, then budget. Binary size, memory, startup, navigation, frame time, and profile growth get reproducible baselines before hard thresholds. Do not preserve fictional limits from an obsolete dependency graph.
- Real-site failures become reductions. A screenshot starts triage. A local fixture, pinned WPT case, or explicitly tracked unreduced failure prevents regression.
Alpha — converge on one browser core
Alpha freezes an architecture capable of carrying the full goal. Complete these in order; later work may proceed in parallel only when it does not create a new state owner.
Progress as of 2026-07-10: A1 is routed through the dependency-free typed
vixen-api command/event seam and one vixen-engine::browser::BrowserCore.
BrowserCore owns one engine thread, profile Store/network/cookies, bounded
context/runtime registries, history, evaluation, inspection, and paint inputs.
WPT, headless CLI, CDP, and the GTK shell are thin adapters over that owner; two
CDP/shell contexts prove independent globals/sessionStorage/history with intended
profile sharing. The 2,015-check fixture manifest, GTK-free shell tests, and the
external Playwright smoke remain green.
The first A2 slice is also landed: dispatch acknowledges navigation before source completion; a bounded Tokio loader performs cancellable HTTP/file reads; each completion carries its context/navigation generation; current-generation cookie deltas merge at the core boundary; and deterministic navigate/navigate plus navigate/stop races prove late source results cannot commit, append history, overwrite cookies, or emit terminal success. Parser/runtime construction and post-commit script/resource work remain on the owner thread and are the next A2 boundary.
A1. Engine-owned profile, browser, and browsing contexts
- Implement one production browser core in
vixen-engineand expose it through an evolvedvixen-apicommand/event seam. - One profile service owns the store, cookies, cache, permissions, HSTS, downloads, clear-data policy, and host configuration. One browser service owns the tab/context registry. Each context owns its active document, runtime, session history, viewport/input state, and navigation controller.
- Give tab/context, document, frame, navigation, request, runtime-context, and download records stable typed ids. Include those ids in diagnostics/events so stale work and cross-target routing are testable.
- Run the non-
SendDOM and V8 state on one engine-owned local executor. Shell and protocol I/O may use workers, but engine ownership must not be split across per-frontend state machines. - Replace shell/headless direct orchestration with thin adapters. Direct
vixen-net/vixen-storeuse outside the engine is migration debt, not an extension point.
Proof: a production Engine/browser-core implementation, two tabs sharing
profile state but not session state, GUI/headless/CDP navigation through the same
commands, dependency-boundary checks, and tests that no frontend owns an
independent navigation history.
A2. Asynchronous navigation and document commit
- Model navigation as explicit phases: intent → policy → request → response → commit → parse → scripts/subresources → DOMContentLoaded → load → settled or failed/cancelled.
- Make reload, stop, redirects, history traversal, form submission,
location/history APIs,document.write, error pages, and session restore enter the same controller. - Propagate cancellation through transport, body reads, parser/resource tasks,
and runtime jobs. A superseded navigation must not mutate the current document,
cache forbidden data, append history, or emit a later
load. - Separate provisional and committed documents so errors before and after commit have deterministic behavior.
Proof: race tests for navigate/navigate, navigate/stop, redirect/stop, history/reload, late network completion, and runtime reset; matching shell and CDP lifecycle traces; no stale commit after cancellation.
Current proof: navigate/navigate and navigate/stop use gated transport plus
forced late completions; shell/headless/WPT/CDP wait for matching typed terminal
events; the external Playwright smoke covers navigation, history, reload, and
document.write/setContent. Redirect/stop, history/reload races, parser/runtime
cooperative cancellation, and asynchronous CDP event delivery remain.
A3. Live document/runtime integration
- Replace remaining snapshots and string-expression projections with live page-backed Node/Element/Document, CSSOM, events, focus, selection, forms, history, storage, and geometry resources.
- Execute parser-discovered classic/module scripts in document order with an event loop and microtask checkpoints tied to the document lifecycle.
- Make DOM mutation invalidate style/layout/paint and inspector state through one explicit mechanism. Preserve browser event ordering and realm teardown.
- Establish the frame/realm model needed for same-origin child frames and cross-origin boundaries, even if initial frame support is narrow.
- Delete compatibility shims as their supported behavior moves to the live path; unsupported APIs must remain explicit rather than returning plausible fiction.
Proof: DOM/events/forms/history/storage profiles running through V8 against the live document, script-driven rendered mutations, realm/navigation teardown tests, and CDP queries that observe exactly the same nodes.
A4. Real document loader and profile policy
- Build one resource loader for document, script, style, image, font, fetch/XHR, and download requests, with shared request ids, redirect/policy processing, cookies/cache, priorities, cancellation, and diagnostics.
- Integrate profile-shared cookies, cache, permissions, HSTS, localStorage, and history. Keep sessionStorage and in-flight work context scoped; define partition keys before adding third-party persistence.
- Finish streaming/abort/progress semantics where observable and ensure response policy runs before exposure, execution, decode, persistence, or cache insert.
- Treat cert roots, proxies, XDG paths, portals, fonts, and GL/EGL capabilities as explicit host services with structured diagnostics.
Proof: multi-tab profile tests, resource waterfall assertions, CORS/CSP/SRI/ mixed-content/cache WPT profiles, cancellation tests, and controlled Linux host integration smokes.
A5. Owned style, layout, and paint lifecycle
- Replace the compact cascade projection with full Stylo computed values behind the authoritative document/style state.
- Replace deterministic text metrics with real font discovery, shaping, fallback, line breaking, glyph runs, and intrinsic measurement.
- Carry DOM/style invalidation through the Vixen layout tree to one display list and WebRender path; no frontend geometry or post-pass coordinate correction.
- Establish scroll, hit-test, selection/caret, image, clipping, stacking, transform, and animation state as engine-owned data.
Proof: shaped-text and fallback-font fixtures, script mutation → repaint, GUI/headless pixel comparisons, layout/ref profiles, and inspector queries during invalidation.
Alpha exit gate
Alpha is reached only when:
- a production browser core owns lifecycle/profile state;
- shell, headless, CDP, and WPT are adapters over it;
- two independent contexts can load, script, render, inspect, and share only the profile state they should share;
- active navigation can be cancelled without stale commits;
- the live DOM/runtime drives visible layout/paint; and
- the architecture, compatibility counts, known gaps, and measurements are reproducible from checked-in commands.
Beta — turn the architecture into a useful browser
Once ownership is singular, broaden capability by user-visible risk rather than by easiest API count.
B1. Rendering and content fidelity
- Complete common block/inline formatting, floats, positioned/fixed/sticky layout, overflow/scroll, flex, grid, tables, intrinsic sizing, replaced elements, and enough fragmentation/print behavior for the measured corridor.
- Support responsive raster images, SVG document/image basics, web fonts, gradients/borders/shadows, transforms, opacity/compositing, filters, animation, caret/selection, and native-looking form controls through the same pipeline.
- Prioritize typography, intrinsic sizing, tables, form controls, and scrolling: they dominate real-page breakage even when small synthetic layout tests pass.
B2. Browser runtime and application basics
- Widen live DOM, HTML, CSSOM, events, forms, navigation, URL, encoding, streams, timers, observers, messaging, WebSocket, and EventSource behavior from failing profiles and corridor reductions.
- Add multi-frame execution, same-origin access rules, sandboxing, module loading, workers, and resource timing sufficient for representative app-like pages.
- Keep inert probes out of the supported matrix until observable behavior exists.
B3. Network, security, privacy, and downloads
- Complete HTTP transfer streaming, upload/download progress, redirects, authentication/proxy behavior, HTTP/2 interoperability, cache freshness, and a real download manager with safe filenames, resume where supported, and profile history.
- Integrate Permissions Policy, sandboxing, COOP/COEP/CORP, HSTS, nosniff, Trusted Types sinks, partitioned state, private-network access, and prompt/user decisions into the shared loader/profile model.
- Diagnose policy block, DNS/connectivity, TLS/cert, HTTP/protocol, unsupported feature, and likely anti-bot/fingerprinting failure separately.
B4. Daily-smoke desktop product
- Deliver robust tabs, address/search, reload/stop, back/forward, find, zoom, downloads, permission prompts, error/recovery pages, history, session restore, settings, clear-data/privacy controls, keyboard navigation, and safe external opens.
- Make every chrome transition resilient to late engine events, tab close, document/runtime reset, and failed profile writes.
- Keep the shell focused: UI exists to browse, recover, inspect, automate, or control profile/security state—not to become a feature buffet.
B5. Automation and inspection as products
- Support independent targets/runtimes, browser contexts where semantics exist, reliable navigation waits, DOM/runtime handles, input, downloads, dialogs, network/console/lifecycle events, screenshots, permissions, and bounded traces.
- Drive additions from external Playwright workflows and documented protocol contracts, not from method-name coverage alone.
- Stress inspection during animation, mutation, style/layout invalidation, navigation, downloads, errors, and runtime recovery.
B6. Compatibility, performance, and reliability loop
- Expand pinned WPT profiles across parsing, DOM/events/forms, CSS/layout/paint, network/security, storage/history, runtime APIs, and accessibility-relevant behavior. Report local/imported and source×category results.
- Publish a reproducible real-site corridor spanning static content, docs, forms, downloads, app-like pages, and automation-heavy pages.
- Track startup, first navigation, style/layout/paint, frame stability, memory, screenshot latency, transfer throughput, binary/install size, and profile growth. Add budgets only after representative baselines exist.
- Eliminate panics/data loss on malformed content; bound content-controlled work; make recovery and diagnostics useful.
Beta exit gate
The corridor loads in GUI and headless, supports meaningful interaction and persistence, survives restart/error/cancellation cases, and has published screenshots, reductions, WPT/profile counts, automation results, performance measurements, and known gaps on the supported Linux matrix.
v1.0 — honest daily-driver minimum
Vixen may call itself v1.0 when all of the following are true:
- common document, documentation, form, download, and app-like pages in the published corridor are readable and usable with stable typography, images, layout, scrolling, interaction, navigation, and profile state;
- shell and Playwright/CDP use the same engine paths and recover predictably from network, document, runtime, renderer, and profile failures;
- supported network/security/privacy behavior is fail-closed, observable, and backed by tests/fuzzing/audit; single-process isolation limits are prominent;
- Flatpak install/update, certs, fonts, portals, downloads, GPU/EGL, settings, session restore, and clear-data flows pass on the declared Linux targets;
- compatibility, performance, memory, binary/install size, and unsupported capabilities are published from reproducible commands; and
- every v1 claim maps to an acceptance gate, fixture/profile or smoke, and an owner in the shared architecture.
v1.0 is a useful supported subset, not the end of the Firefox-replacement goal.
Replacement horizon — continue until ordinary browsing is credible
After v1, prioritize these programs by measured site impact:
- Accessible browser: accessibility tree, AT-SPI integration, screen-reader smoke, keyboard/caret/selection fidelity, forced colors, reduced motion, zoom, and accessible native controls.
- Media platform: GStreamer-backed audio/video, common codecs, controls, captions/tracks, fullscreen/PiP, autoplay/permission policy, Media Source where justified, and WebAudio basics.
- Offline/application platform: IndexedDB, Cache Storage, service workers, workers/shared workers, file/blob streaming, notifications, installable apps, and offline lifecycle semantics.
- Communications: production WebSocket/EventSource, WebRTC and device permissions, richer streaming/compression, and WebTransport only where demand and security design justify it.
- Graphics and documents: Canvas 2D behavior, SVG breadth, WebGL, WebGPU, print/PDF, color management, advanced typography/writing modes, and the long tail of CSS layout/paint.
- User ecosystem: a deliberately scoped extension model, content blocking, password/autofill integration, import/export, developer tools, and policy controls without cloning every Firefox chrome feature.
- Defense in depth: renderer/content process sandboxing, site isolation or OOPIF, brokered host access, crash containment, and update/signing hardening. Treat this as an explicit architecture generation, not an ad-hoc worker pool.
- Broader compatibility: continuously widen WPT and real-site profiles until exceptions are uncommon enough that replacement is an honest default-use description. Cross-platform ports remain separate product decisions.
Immediate execution order
The first three convergence batches are complete. The source-loading part of the fourth is complete. The next coherent batches are:
- Finish A2 across redirects, parser/runtime jobs, history/reload races, and asynchronous CDP lifecycle delivery; preserve exactly one terminal outcome.
- Move parser-discovered scripts and supported DOM mutations onto the live document/runtime, deleting replaced snapshot/string shims.
- Land font discovery/shaping/fallback and image subresource decode as the first broad rendering verticals, then widen layout by failing imported ref tests.
- Build the HTTP download lifecycle and shell/CDP events over profile-owned downloads.
- Establish measured real-site, Linux-host, performance, and size baselines that gate beta work.
Working rule
Every milestone lands with:
- one named authoritative owner and no new parallel state model;
- a browser-visible path through the shared core;
- focused unit tests plus an integration fixture/profile/smoke;
- stable, bounded diagnostics at trust and lifecycle boundaries;
- compatibility/limitation updates when observable behavior changes; and
- the cheapest focused checks followed by the relevant hk/
justgate.
Prefer small, boring vertical slices. A large surface of plausible objects is less valuable than one real navigation, document, render, or persistence path that every frontend shares.
Vixen build plan
Phased execution runbook. Each phase ends in a green test suite, a working binary, and a measured size. Do not start the next phase until the previous one's gate passes.
For current focus, start with PROJECT_DIRECTION.md and
ROADMAP.md. This file is the historical phase runbook; avoid
adding broad status prose here when a concise ADR, roadmap entry, or code test is
enough.
Tick-tock discipline applies throughout: each phase is a tick
(capability lands); the post-phase cleanup is the tock (dead-code
removal, module ≤ 1 kLOC, references cited). See docs/ACCEPTANCE.md
for the per-phase gates.
For current delivery order use docs/ROADMAP.md; use
docs/MILESTONES.md only to map executable evidence to
just gate-* commands. New browser features should extend the shared browser
core/document path, not land only as isolated prep modules.
For larger alpha/dev batches, follow docs/DEVELOPMENT.md:
partial capability is acceptable only when it is visible, tested, fail-closed,
and bounded by a named maintainability follow-up.
Phase 0 — Scaffolding (≈ 3 days)
Create the workspace from docs/ARCHITECTURE.md. Empty crates with
stub lib.rs so the workspace compiles.
Steps:
- Workspace
Cargo.tomlwith all 7 crates as members. Rootsrc/main.rscallsvixen_shell::run()(which is a stub for now). vixen-apipopulated:Enginetrait,EngineDelegate(Send),EngineInspector,EngineProfile, DTOs,EngineDiagnosticshape — perdocs/ARCHITECTURE.md.vixen-shellskeleton:Appcomponent with emptyFactoryVecDeque<TabModel>and a placeholder window. Establish the Relm4 worker/factory patterns early per ADR-010 — the shell's idioms should be set in Phase 0, not retrofitted later.vixen-net,vixen-store,vixen-wpt,vixen-headless,vixen-engineall empty withpub mod placeholder;stubs.justfileadapted:check-all-hostbuilds the workspace;test-apiruns the API crate;gate-phase0bundles the phase's executable proof..gitignore,LICENSE(Apache 2.0),data/,build-aux/skeleton,fixtures/(empty),benches/(empty)..mise.tomlpins the dev toolchain (rust,just,cargo-binstall) somise bootstrap --yesconverges a fresh machine by delegating project work tojust setup. The library MSRV (1.88) is in each crate'srust-version; the developer toolchain is pinned in.mise.toml. The GNOME 50 SDK is not installed on the host — it is managed inside a flatpak-builder container (just flatpak-update-sdk/just flatpak-build); seedocs/guidance/gnome-sdk-flatpak-builder.mdandmise bootstrap.
Gate: just gate-phase0 passes (the workspace builds and vixen-api DTO /
trait tests pass). The shell's
empty App launches and renders an empty window.
Phase 1 — Networking and storage crown jewels (≈ 1 week)
Build the well-tested, fail-closed subsystems first. These are pure Rust with no upstream-crate dependencies.
Steps:
vixen-net/src/network.rs: reqwest + rustls HTTP client, HTTP/2, gzip, brotli, redirect handling, max body size, cookie header generation. Test surface: every error variant ofNetworkError.vixen-net/src/cookie.rs: RFC 6265 jar, every rule indocs/SPEC.md"Cookie contract". Test surface: every rejection rule, every outgoing-header rule, the 512-entry cap, FIFO eviction.vixen-net/src/url_policy.rs: blocklist perdocs/SPEC.md"URL policy", including the precise CGNAT check (100.64.0.0/10, not all of100/8).vixen-net/src/csp.rs: directive parser + enforcer perdocs/SPEC.md"CSP contract". Test surface: every directive, every source-list grammar element.vixen-net/src/permissions.rs,origin.rs,fetch_types.rs,http_helpers.rs: small supporting modules.vixen-store/src/lib.rs: redb-backed persistence, per-origin partitioning, schema perdocs/ARCHITECTURE.md"App ID and profile paths".
Shell session restore slice landed. vixen-shell::profile now uses the
app-ID scoped profile database to load/save vixen-store::SessionRecord for the
GTK shell. Startup falls back to the configured start page for an empty profile,
restores persisted tab URLs and active tab when present, and clamps/truncates
shell-written records to the store's bounded tab limits before persistence.
Gate: just gate-phase1 passes (vixen-net / vixen-store, just audit,
and the just fuzz-security 1 M iteration targets).
Phase 2 — JavaScript runtime (≈ 1 week)
Stand up the JS engine.
Steps:
deno_coreimplementation landed:deno_core/V8 powersvixen-engine::script::JsRuntimeand the Phase 2 eval gate.- Stable Vixen seam: keep the public
JsRuntime/JsValueseam so headless, CDP, and Page tests do not depend on runtime internals. - Host hook registration, minimum viable:
console.log,fetch(delegating tovixen-net::Network),document.titlegetter. Defer the full DOM/Event/Storage surface to Phase 6. - Host internals follow ADR-014: package Vixen-owned Web API bindings as
deno_coreextensions — small feature modules, explicit registration, local JS bootstrap, resource/permission boundaries, and focused tests per host family.
Gate: just gate-phase2 passes (basic engine tests and
vixen-headless --url file:///.../hello.html --eval '1+2' returns 3). Binary
size recorded.
Phase 3 — HTML parse + Stylo cascade (≈ 2 weeks)
Wire up HTML parsing and CSS cascade.
Steps:
html5everparse into RcDom. Already a dependency. Done — seevixen-engine::doc.- Selector matching via Stylo (done) —
vixen-engine::style_domimplementsselectors::Elementover the RcDom (a precomputedElementArenakeeps the moduleforbid(unsafe_code)). This powers--extract-selector, the WPT selector checks, and the:valid/:invalid/:checkedpseudos. Phase 3's gate (WPT CSS fixtures) now passes against the selector surface. The sharedvixen-engine::page::Pagefacade now owns URL + parsed document state for headless and WPT; cascade/layout/paint slices extend that facade in order. - Milestone 1 computed-style cascade projection (done) —
Page::computed_stylemaps the stable selectornode_idback to the element and returns a compact author/inline cascade.vixen-engine::style_cascadeloads<style>blocks, matches selectors through Stylo's selector engine, applies specificity, source order, cascade layers, media/supports conditions, custom-propertyvar()resolution, inherited custom properties, CSS-wide keywords, and author/inline!important, and keeps the WPTcomputed-stylecheck vertical behindPage.Page::evaluate_dom_expressionalso projects small CSSOM smoke seams forgetComputedStyle(document.querySelector(...)),CSS.supports(),document.styleSheets, and read-only CSSStyleRule/CSSStyleDeclaration state while full computed values and stylesheet host objects land. vixen-engine/src/style.rs(next slice): replace the compact projection with full Stylo style data: load<style>/<link rel=stylesheet>intoStylesheetlist →Stylist::update_stylist→ cascade via Stylo'sSharedStyleContext/ traversal. Exposecomputed_values_for(NodeId) -> Arc<ComputedValues>. Requires implementing the fullTNode/TElement/TDocumenttraits; budget 3–4 days for trait conformance. Consult.tmp/ref/firefox/dom/base/for DOM API behavior and.tmp/ref/firefox/servo/components/style/dom.rsfor the Stylo trait definitions being implemented.- CSS-wide keywords,
@layer,@supports,@media, and custom properties +var()are now covered in the compactPage::computed_styleprojection for the v1 layout/paint seam. Full Stylo still owns the long tail (@property,@import,@keyframes, shorthand expansion, full computed-value serialisation). Verify via WPT fixtures.
Pure-logic foundation landed (testing-strategy item).
vixen-engine::length implements CSS Values 4 <length> parsing + the
absolute/relative unit conversions the cascade and layout resolves against
(px/em/rem/%/vh/vw/vi/vb/vmin/vmax/sv*/lv*/dv*/
ex/ch/pt/pc/in/cm/mm/Q).
Rust-unit-tested per "Rust tests cover only pure logic (CSS length
arithmetic, …)".
The rest of the CSS Values 4 dimension family landed. <length> was
the first; the family is now complete for v1.0:
vixen-engine::color— CSS Color 4 sRGB family: 3/4/6/8-digit hex,rgb()/rgba()(legacy comma + modern space forms),hsl()/hsla()with hue normalisation, the 148 named colours,transparent/currentcolorkeywords, premultiplied-alpha arithmetic, and linear-sRGB interpolation (the primitive gradients and transitions reduce to).oklch/lab/lch/color()fail closed withUnsupportedColorSpace(deferred slice).vixen-engine::angle—<angle>(deg/rad/grad/turn) with degree/radian normalisation,cos_sin()for transforms and conic gradients.vixen-engine::time—<time>(s/ms) with millisecond normalisation for transitions/animations.vixen-engine::resolution—<resolution>(dpi/dpcm/dppx/x) with dots-per-pixel normalisation for media queries.xis the historical alias fordppx(CSS Images 4 § 7.3).vixen-engine::ratio— CSS Values 4 § 4.4<ratio>(number | number / number): the numerator/denominator pair with the quotient theaspect-ratioproperty and theaspect-ratio/device-aspect-ratiomedia features reduce to. A zero denominator is the § 4.4 "infinite ratio" encoding; the single-number shorthand meansN/1; the legacy Media-Queries-4 integerwidth/heightform folds in unchanged.
Each is #![forbid(unsafe_code)], mirrors the length parse/resolve shape,
and stays Rust-unit-tested (cascade/paint integration lands when Stylo /
WebRender plug in).
Note on Stylo sourcing. Stylo is now published on crates.io as
stylo (lib name style); the
"needs a Servo git dependency" caveat from earlier revisions of this
plan no longer applies. See ADR-011.
Gate: just gate-phase3 passes; fixtures/css/computed-advanced.html
proves the Milestone 1 cascade seam (@media, @supports, @layer, inherited
custom properties, var() fallback, and CSS-wide keywords) through the WPT
computed-style check. Full Stylo computed values remain the implementation
replacement behind the same Page facade (step 4), not a new public seam.
Phase 4 — Vixen-owned Rust layout (≈ 4–8 weeks for v1 subset)
Turn cascade output into a positioned box tree.
Steps:
- Build Vixen's Rust layout engine per ADR-013. The architecture reference is
Ladybird LibWeb at
0de15a5dd2a9, especially.tmp/ref/ladybird/Libraries/LibWeb/Layout/TreeBuilder.cppand.tmp/ref/ladybird/Libraries/LibWeb/Layout/*FormattingContext*. vixen-engine/src/layout_tree.rs: convert the Stylo-computed DOM into an arena-backed layout tree with stableLayoutNodeIds, explicit dirty bits, and no cross-crate pointers.vixen-engine/src/layout.rs/formatting_context.rs: run block, inline, flex, and grid formatting-context passes over that tree and produce positioned fragments.- Feed positioned fragments into the existing display-list builder; layout never owns a paint backend.
- Tables, floats, full vertical writing, page fragmentation, and advanced intrinsic sizing are post-v1 unless a WPT/real-site gate promotes them.
Implementation crate note. Keep the layout engine Vixen-owned, but use
small helper crates where they reduce risk without taking over web layout
semantics: smallvec for common-case child/fragment lists, bitflags for
dirty/invalidation state, slotmap/thunderdome if raw arena ids become
error-prone, and euclid when replacing ad-hoc geometry with typed units.
Defer text-specific crates (rustybuzz, fontdb, unicode-linebreak,
unicode-bidi, unicode-segmentation) until the inline formatting slice.
Do not use generic UI layout engines (taffy, stretch, etc.) for CSS layout
without a new ADR.
Vertical layout-tree + fragment slice landed. vixen-engine::layout_tree now builds
the first arena-backed Vixen layout tree behind Page::layout_tree, and
vixen-headless --dump-layout-tree exposes a deterministic dump. The first
block formatting-context slice consumes cascade-projected width/height,
margin, border-width/border, padding, and box-sizing through the
existing box_model resolver, so authored block dimensions now affect node
boxes. The existing Page::dump_lines projection derives visible text from the
tree instead of raw body text, keeping the line-layout and paint surfaces on the
same spine. Page::layout_fragments(viewport) now projects block backgrounds
and wrapped text lines from that tree into paint-consumable fragments; the
display-list builder consumes that seam instead of re-walking layout nodes.
Next slices replace the deterministic average-width text metric with styled
glyph fragments and enrich grid/inline placement without changing the CLI seam.
Gate: Visual-hash WPT check on 20+ fixtures matches reference
baseline within tolerance. Specifically, nested-flex/grid + padding +
margins + gaps must produce correct absolute coordinates without any
post-pass coordinate fixup. docs/COMPAT.md records the achieved WPT profile
for the shipped subset.
Pure-logic foundation landed (Phase 4 prep).
vixen-engine::box_model implements the CSS2 § 10.3.3 block-level
horizontal-constraint solve (auto-width leftover absorption, one/two
auto-margin distribution + centering, box-sizing: border-box content
subtraction) and the four-box nesting (margin ⊃ border ⊃ padding ⊃ content) the layout tree feeds off. Pure given cascade-resolved edges;
Rust-unit-tested per "Rust tests cover only pure logic".
Flexbox main-axis resolution landed (Phase 4 prep).
vixen-engine::flex_resolve implements CSS Flexbox 1 § 9.7 "Resolving
Flexible Lengths" end-to-end: the used-flex-factor selection (grow if items
under-fill, shrink otherwise), the inflexible-item freeze step, the
proportional free-space distribution (scaled by shrink × flex_basis for
the shrink case), and the iterative min/max-violation clamping that
terminates when every item is frozen. Pure given cascade-resolved
flex-basis + grow/shrink + min/max per item. Cross-axis alignment
and line packing stay in Vixen's formatting-context pass where they compose
against real text metrics.
CSS Grid track sizing landed (Phase 4 prep).
vixen-engine::grid_resolve implements CSS Grid 1 § 12.5 "Distribute
Extra Space" + § 11.7 "Maximize Tracks" — the natural complement to
flex_resolve for grid columns / rows. GridTrack carries the
§ 11.2 min track size (caller-resolved definite base) + the § 11.3 growth
limit + the Nfr flex factor; resolve_tracks distributes the
container's leftover to flex tracks proportionally to their flex factor,
freezes any track that hits its growth limit, redistributes the excess to
the remaining flex tracks (iterative, the same freeze-on-violation pattern
flex_resolve uses), then grows non-flex tracks up to their growth limits
equally when leftover remains (§ 11.7). The constructors (GridTrack::fr
for 1fr, GridTrack::minmax for minmax(min, max, fr),
GridTrack::length for fixed) cover the common authoring forms. Pure
given definite base sizes; content-based sizing (min-content/max-content/
auto) and multi-track spanning items stay in Vixen's formatting-context pass
where they compose against real text-shaping (the caller folds each spanning
item's contribution into the track base before calling).
Pure-logic foundation landed for CSS Writing Modes + logical properties (Phase 4 prep).
The writing-mode / direction → block + inline axis + the logical →
physical side mapping the box model, the logical insets, the logical-size →
width/height swap, and the flex/grid main-axis selection resolve against.
#![forbid(unsafe_code)], Rust-unit-tested.
vixen-engine::writing_modes— CSS Writing Modes 3 § 3 + CSS Logical Properties 1.WritingModeis the five § 3.1 values (horizontal-tb/vertical-rl/vertical-lr+ the CSS WM 4sideways-rl/sideways-lr);Directionis the § 2.1ltr/rtlinline-base direction.Flowbundles the pair and projects the derived geometry:Flow::block_axis/Flow::inline_axis(which physical axis each logical axis runs along) +Flow::block_start/Flow::block_end/Flow::inline_start/Flow::inline_end→PhysicalSide(the § 7 side mapping table, with thesideways-*reusing thevertical-*axis mapping per § 3.1).LogicalSize::to_physicalswapsinline/block→width/heightfor vertical modes;LogicalInsets::to_physicalresolves the four logical edges to(top, right, bottom, left);LogicalRect::to_physicalresolves a layout-produced logical rect to a physical(x, y, w, h)rect given the containing block (the rtl / vertical-rl inline-start flip from the right/bottom edge folded in). Theunicode-bidialgorithm + thetext-orientationglyph rotation stay in the text-shaping / paint path; this module is the pure axis + side mapping.
Pure-logic foundation landed for CSS Multi-column resolution (Phase 4 prep).
The column-width / column-count / column-gap § 3.4 resolution the
layout layer's column-row distribution reduces to. #![forbid(unsafe_code)],
Rust-unit-tested.
vixen-engine::multicol— CSS Multi-column Layout 1 § 3.ColumnWidth(autoor px) +ColumnCount(autoor ≥ 1) + theColumnSpec(column-width, column-count, gap)triple.ColumnSpec::resolveruns the § 3.4 pseudo-algorithm end-to-end: the four branches (both auto ⇒ single column; count set + width auto ⇒ even distribution; width set + count auto ⇒⌊(avail+gap)/(width+gap)⌋count; both set ⇒min(count, fit)+ the § 3.4 (11)–(12) single-column-authored-wider- than-available clamp), with a finalmax(0, width)guard so a too-large count never produces a negative column.ResolvedColumns::column_xis thei * (column_width + gap)stride the box model feeds off;ResolvedColumns::total_width+ResolvedColumns::overflowsreport the row geometry (the gaps-alone-overflow case). Thecolumn-gap: normal→1emlength resolution, the § 8column-fill: balanceheight balancing, thecolumn-rulepaint, andcolumn-span: allstay in Vixen's formatting-context / paint path (they compose against real text metrics).
Pure-logic foundation landed for CSS Scroll Snap (Phase 4 prep).
The § 5 snap-position computation + the scroll-snap-type axis/strictness
model the scroll container's snap targeting reduces to.
#![forbid(unsafe_code)], Rust-unit-tested.
vixen-engine::scroll_snap— CSS Scroll Snap 1 § 5.ScrollSnapType(noneor(axis, strictness); axisx/y/block/inline/both, strictnessproximity/mandatory, parsed in either order per the § 5.1 grammar) +SnapAlign(none/start/end/center, the 1–2 value(block, inline)form) +SnapStop(normal/always).compute_axisis the § 5 snap position for one axis: thestart ⇒ O,end ⇒ O + A − S,center ⇒ O + A/2 − S/2formula clamped to[0, max(0, overflow − S)].compute_snapproduces the(x, y)pair (the block/inline → x/y mapping via the writing-mode flow flag);should_snapis the strictness policy (mandatory always; proximity iff within a threshold). The scrollable-overflow computation, the scroll animation, thescroll-padding/scroll-margininsets, and the content-change resnap (§ 5.4) stay in the layout/input layers.
Phase 5 — Paint: WebRender + EGL surfaceless (≈ 2 weeks)
Make the engine produce pixels via a single WebRender paint path bound
to two GlContext implementations.
Steps:
-
vixen-engine/src/paint.rs: singleDisplayListtype + a WebRenderRendererthat consumes a&dyn GlContext(trait defined invixen-api, see ADR-006). One paint path; the twoGlContextimplementations are the only thing that varies between GUI and headless. -
GlAreaSurface(invixen-shell): implementsGlContextaroundgtk4::GLArea. Per the GTK4 idiom, GL work happens inside therendersignal callback, where GTK has already made thegdk::GLContextcurrent;proc_addressdispatches through Gdk's GL loader. This is the GUI path. -
SurfacelessSurface(invixen-headless): implementsGlContextviaEGL_MESA_platform_surfaceless(orEGL_KHR_surfaceless+ pbuffer fallback). Renders into an FBO;glReadPixelsextracts RGBA. This is the headless/CI path. -
Display-list builder enforces the invariants from
SPEC.md: z-index sorting, clip stacking (content clipped, borders not), opacity group multiplication, visibility skip-paint, background clip/origin/attachment.Invariant enforcement landed (pure slice).
vixen-engine::display_listimplements all eightSPEC.md"Display-list invariants" as auditable, individually-tested pure functions (z_tier,effective_opacity,background_paint_rect, …) plus aDisplayListBuilder::buildthat emits the pruned, z-sortedPaintCommandstream. The WebRenderRenderer(this step, next slice) consumes that stream; the invariant logic is done and Rust-unit-tested.Vertical display-list slice landed.
Page::display_listnow turns the Phase 4 layout fragments into the singleDisplayListBuildercommand stream: viewport background first, then fragment-backed backgrounds/text commands, exposed throughvixen-headless --dump-display-list.--paint-statsnow aggregates command counts and painted area from that same stream. This is not a renderer or CPU paint fallback; WebRender consumes the samePaintCommandstream once the GL surfaces land. -
vixen-shell/src/engine_factory.rs: creates thegtk4::GLArea, wraps it asGlAreaSurface(the shell'sGlContextimpl), and returns it as the content widget alongside the tab'sEngineWorker. The worker's engine renders to the screen via thatGlContext.Surface scaffolding landed.
vixen-shell::surface::GlAreaSurface(behindgtk-shell) andvixen_headless::surface::SurfacelessSurfacenow implementvixen_api::GlContext. Headless construction still fails closed withunsupported.screenshotuntil EGL context creation, WebRender command submission,glReadPixels, and PNG encoding land; no CPU fallback or second paint path was introduced. -
CI: verify
LIBGL_ALWAYS_SOFTWARE=1produces working screenshots viallvmpipeso headless runs anywhere.
Pure-logic foundation landed for radial + conic gradients (Phase 5 prep).
The CSS Images 4 § 4.2.3 + § 4.3.3 colour-sampling siblings of gradient,
completing the three gradient families the paint path samples against. All
three #![forbid(unsafe_code)], Rust-unit-tested, reusing the
crate::gradient::resolve_stop_positions + linear-sRGB interpolation the
linear-gradient surface already owns.
vixen-engine::radial_gradient— CSS Images 4 § 4.2.3radial-gradient.RadialShape(Circle/Ellipse) +RadialSize(the four § 4.2.4 keywordsclosest-side/farthest-side/closest-corner/farthest-corner- the explicit
Length/LengthPairforms, withfarthest-cornerthe spec default).compute_radiusis the § 4.2.4 radius-resolution step for one of the four keyword forms against a known(width, height)reference box centred at(cx, cy), returning(rx, ry)so circle + ellipse share the call site (theclosest-corner/farthest-cornerellipse cases keep the closest-side/farthest-siderx/ryratio and scale to the corner per the § 4.2.4 corner-scaling rule).project_to_tis the per-pixel(dx, dy)→tdistance projection (Euclidean for circle, ellipse-norm for ellipse).RadialGradient::sampleis the colour at a projectedt(with therepeating-radial-gradient()wrap via the sharedsample_resolvedhelper). The<position>centre and the<geometry-box>reference-box resolution stay in the layout/paint layer; this module receives(cx, cy, width, height)already resolved.
- the explicit
vixen-engine::conic_gradient— CSS Images 4 § 4.3.3conic-gradient.ConicGradientcarries the stop list + thefrom <angle>start angle (radians) + therepeatingflag.project_angle_to_tis the per-pixel(dx, dy)→t ∈ [0, 1)projection (CSS-clockwise from 12 o'clock, in turns — one full revolution =1.0);add_from_anglefolds in thefromangle and reduces modulo 1.0.ConicGradient::sampleis the colour at a projectedt(with therepeating-conic-gradient()wrap). The<angle>grammar + the<position>centre stay in the cascade / layout layer.
Pure-logic foundation landed for CSS Geometry Interfaces (Phase 5/6 prep).
The DOMPoint / DOMRect / DOMQuad / DOMMatrix value family the
geometry-bearing host hooks reduce to. #![forbid(unsafe_code)],
Rust-unit-tested, complementing crate::transform (which owns the 2D
subset of the matrix surface).
vixen-engine::geometry— CSS Geometry Interfaces L1.DOMPointis the 2D/3D/homogeneous(x, y, z, w)point (§ 2; the perspective divide normaliseswto1when projecting).DOMRectis the(x, y, width, height)rectangle (§ 3) with the derivedtop/right/bottom/leftaccessors + the negative-dimensionDOMRect::normalizedflip + thecontains_point/intersects/unionpredicatesgetBoundingClientRect()+IntersectionObserverconsult.DOMQuadis the four-corner quadrilateral (§ 4) with thefrom_rectconstructor + theDOMQuad::boundsaxis-aligned bounding rectangle (§ 4.4).DOMMatrixis the § 6 4×4 homogeneous matrix (the 2Dmatrix(a,b,c,d,e,f)subset folds into the upper-left 2×3 + the[0 0 1 0]/[0 0 0 1]bottom rows) with every § 6.3 transform (translate/scale/scale_non_uniform/rotate/rotate_axis_angle/skew_x/skew_y/multiply/flip_x/flip_y/inverse) + the § 6.4DOMMatrix::transform_pointhomogeneous-coordinate projection + theis_2dpredicate + theto_4x4_column_majorround-trip. Matrix decomposition / interpolation (the CSS Transforms 2 § 16 pipeline the animation interpolation layer reduces to) and the fulltransformproperty parser land with the 3D WebRender plumbing; this module is the arithmetic those slices reduce to.- The runtime DOM host now exposes the first geometry host seam:
Element.getBoundingClientRect()returns the Page layout box as a DOMRect projection (x/y/width/height/left/top/right/bottom),getClientRects().lengthreports whether layout produced a box, client/offset/scroll metrics read the same rect,getBoxQuads()projects aDOMQuadfrom that box, and Range rectangles reuse the same page-backed geometry seam. It also projects the read-only Geometry Interfaces value constructors (DOMPoint,DOMRect.fromRect(),DOMQuad.fromRect()/getBounds(), andDOMMatrixtransform/transformPoint()smoke) until real JS host wrappers replace the string projection.
Gate: just run shows a real web page in the window.
vixen-headless --screenshot out.png --url fixtures/css/border-rendering.html
produces a PNG matching the GUI's render within 1 % pixel diff on 5
fixtures (both renders going through the same WebRender paint path).
Paint-geometry pure-logic foundations landed (Phase 5 prep).
vixen-engine::transform— CSS Transforms 1 § 13 2D affine algebra:translate/scale/rotate/skew/matrix,multiplycomposition (post-multiply ⇒ rightmost-applied-first, matching Firefox/Servo),apply_point/apply_rect(AABB),determinant/inverse, plus aparse_transformlist parser for the--computed-styleprojection. Consumesvixen-engine::angleso the full angle unit grammar is shared.vixen-engine::border_radius— CSS Backgrounds 3 § 5.5 corner shaping: the eight authored radii → four shaped corners with the proportional scale-down when adjacent radii overflow a side. Pure given px radii + px sizes; the cascade resolves percentages first.vixen-engine::gradient— CSS Images 4 § 4.5 linear-gradient colour sampling: stop-position normalisation (first/last defaults, even auto-distribution between positioned anchors, monotonicity fix-up, unit- interval clamp), linear-sRGB interpolation between stops (viacrate::color::interpolate), and therepeating-linear-gradient()wrap that tiles the colour function. Angle / direction → gradient-line geometry stays in the paint path.vixen-engine::box_shadow— CSS Backgrounds 3 § 7.2box-shadowgeometry: the<shadow>#grammar parser (offset / blur / spread / colour /inset, the paren-respecting colour-function tokeniser, negative-blur clamping) + the per-shadow paint-rect arithmetic (outer_paint_rectfor display-list culling;inset_clip_rectfor the inset "hole" with the spec's spread-sign-flip + blur-shrinks-hole rule). Pure given px values; the cascade resolves percentages /emfirst.vixen-engine::background_position— CSS Backgrounds 3 § 3.6 + § 4.2<position>resolution: the four-value grammar (1/2/3/4 forms, keyword / length / percentage mix), the keyword-axis swap rule (top right≡right top), and the § 4.2 formula(container − image) * fraction + offset. Pure given px sizes; the cascade resolves thebackground-origin-selected container size first.vixen-engine::stacking_context— CSS 2.1 § 9.9.1 + CSS Positioned Layout 3 § 6 + CSS Compositing 1 § 3 stacking-context formation predicate + the seven-layer § App. E.2.1 paint-order classification (classify_descendantslots each descendant into one ofContextBackgroundAndBorders/NegativeZChildren/InFlowBlockLevel/NonPositionedFloats/InFlowInlineLevel/PositionedZeroZ/PositiveZChildren, in bottom-to-top paint order). Composes withdisplay_list::z_tierfor the coarse z-bucketing and gives the paint pass the fine-grained in-flow layering the CSS 2.1 appendix specifies.
All six #![forbid(unsafe_code)], Rust-unit-tested, ready for WebRender to
consume once the display-list builder feeds them in.
Paint compositing pure-logic foundations landed (Phase 5 prep).
The pixel-mixing family the paint path's mix-blend-mode / filter /
border-image surfaces reduce to. All three #![forbid(unsafe_code)],
Rust-unit-tested, consuming vixen-engine::color's linear-sRGB arithmetic.
vixen-engine::blend— CSS Compositing 1 § 5 + § 10: the 13 Porter-Duff compositing operators (blend::CompositingOperatorwith the § 5.1 general formula + per-operator Fa/Fb factors) and the 16 § 10 blend modes (blend::BlendMode—normal+ 11 separable § 10.1 + 4 non-separable § 10.2, with theSetLum/SetSat/ClipColorhelpers).blend::compositeevaluates one operator;blend::blendapplies one mode to a pixel;blend::composite_blendruns the § 5.2 combined pipeline (isolation blend against the backdrop, then the Porter-Duff operator) thatmix-blend-modeactually performs. All arithmetic is in linear sRGB viablend::LinColor(reusingcolor::Color::to_linear_f32).vixen-engine::filter— CSS Filter Effects 1 § 5: the<filter-function- list>grammar + the per-pixel colour-matrix family.filter::FilterListparses a chain (tolerant of parenthesised-argument whitespace); the 10 § 5 functions (blur/brightness/contrast/drop-shadow/grayscale/hue-rotate/invert/opacity/saturate/sepia) carry their § 5 default-argument rules. The per-pixel family folds into onefilter::ColorMatrix(SVGfeColorMatrix-shaped 4×5) viafilter::compose_color_matrixso the paint path runs a single matrix multiply per pixel;blur/drop-shadowkeep their geometry for the paint path's spatial pass (drop-shadowreusesbox_shadow::BoxShadow).vixen-engine::border_image— CSS Backgrounds 3 § 6: the four longhands (border-image-slice/-width/-outset/-repeat) with full 1–4 TRBL expansion + parse, the 3×3 nine-region carving (border_image::source_regions/border_image::destination_regions), and theborder-image-repeattiling primitive (border_image::tile_edge—stretch/repeat/round/space, with theroundinteger-count rescale and thespaceeven-gap distribution).
Pure-logic foundation landed for clip-path + mask (Phase 5 prep).
The masking family the paint path's per-pixel clip + the masked-element
alpha/luminance sampling reduce to. Both #![forbid(unsafe_code)],
Rust-unit-tested, consuming crate::border_radius + crate::blend.
vixen-engine::clip_path— CSS Masking 1 § 5clip-pathbasic shapes.ClipPathis the typed family (ClipPath::Inset/ClipPath::Circle/ClipPath::Ellipse/ClipPath::Polygon/ClipPath::None);Coordis theat <position>coordinate (px / percent / keyword) withCoord::resolveagainst a reference box;GeometryBoxis the<geometry-box>reference selector.parse_clip_pathparses the four basic shapes (case-insensitive function name, parenthesised args, theinset(… round <radius>)form reusesBorderRadius, thepolygon(<fill-rule>, …)form carriesFillRule::NonZero/FillRule::EvenOdd).ClipPath::containsis the point-in-shape test the paint path calls per pixel — the inset corner rounding via quarter-ellipse containment, the polygon winding rules (non-zero + even-odd, the SVG § 8.4 ray-crossing algorithm). Thepath()SVG-path form is deferred (the four geometric shapes cover the common HTML surface).vixen-engine::mask— CSS Masking 1 § 6maskshorthand per-layer model.MaskMode(alpha/luminance/match-source),MaskRepeat(the 6 repeat styles,repeat-x/repeat-ycollapsed),MaskBox(the sharedmask-clip+mask-originkeyword set,no-clipclip-only), andMaskLayer(one layer's resolved longhands).parse_masksplits comma-separated layers (paren-aware, so a gradient's commas don't split a layer), fills the per-longhand slots in any order, recognises the<position> / <size>slash form, and applies the "first unrecognised token is the image source" rule. The mask-image fetch + the per-pixel sampling is the paint path.
Pure-logic foundation landed for the Web Animations timing model (Phase 5 prep).
The § 5 timing-model pipeline the CSS transition / animation drivers +
the Animation / KeyframeEffect host hooks reduce to.
#![forbid(unsafe_code)], Rust-unit-tested, consuming crate::easing.
vixen-engine::animation— Web Animations § 5.EffectTimingcarries the § 5.4 timing properties (delay/end_delay/fill/iteration_start/iterations/duration/direction);Fillis thenone/forwards/backwards/bothfill mode;PlaybackDirectionis thenormal/reverse/alternate/alternate-reversedirection.active_duration+end_timeare the § 5.3 derived times;phaseis the § 5.5before/active/afterclassification;simple_iteration_progress+current_iterationare the § 5.5 iteration progress + index (the after-phaseiterations = 0and integer-boundaryprogress = 1rules folded in);directed_progressis the § 5.6 direction-aware progress;apply_easingis the § 5.7 transformed progress (consumescrate::easing::Easing);compute_timingties the pipeline together into aComputedTimingwith the fill-mode before/after resolution (backwards/both ⇒ the iteration-0 start in before; forwards/both ⇒ the end state in after; elseNone). The keyframe value interpolation + the animation-frame scheduling + theautoduration resolution stay in the paint / event-loop layer (this module produces theprogressthey sample at).
Phase 6 — Host bindings (≈ 2 weeks)
Register the DOM/Event/Storage/Network host hooks the modern web needs. Priority order:
- DOM Core:
document,Node,Element,HTMLElement, attribute accessors,querySelector*,getElementsByTagName,classList,dataset. - Events:
Event,EventTarget,addEventListener,removeEventListener, dispatch, capture/target/bubble, focus/click/ input/submit/change,composedPath(), composed event dispatch order perdocs/SPEC.md. - Forms:
HTMLInputElement,HTMLFormElement,HTMLSelectElement,HTMLTextAreaElement,HTMLButtonElement,ValidityState(11 flags perdocs/SPEC.md),checkValidity,reportValidity,setCustomValidity, form submission algorithm. - Storage:
localStorage,sessionStorageagainstvixen-store, per-origin partitioning. - Network:
fetch,XMLHttpRequest,Request/Response,Headers,URL,URLSearchParams,TextEncoder/TextDecoder.
Each family lands with its WPT fixtures passing before moving on.
Pure-logic foundation landed for Events + Forms + Storage (Phase 6 prep).
vixen-engine::event_path—composedPath()(shadow-boundary aware via thecomposedflag) and the focus-transition orderingfocusout → focusin → blur → focus(bubbling flags per SPEC). The host-hook layer invokes these; the ordering is done and unit-tested.vixen-engine::date_units— the date/time canonical-unit parser (forms.rs"lives indate_unitsuntil a proper parser lands" → landed):date/time/week/month/datetime-local→DateTimeUnit, sostepMismatchis now testable end-to-end over real input strings.vixen-engine::storage_key— Web Storage key/value validation (non-empty key, no NUL bytes, ≤MAX_KEY_LEN/MAX_VALUE_LEN) + the(origin, kind)StoragePartitionkey thevixen-storeredb tables partition under, plus the per-partitionStorageQuota(5 MiB / 8 192 entries) the host hooks reportQuotaExceededErroragainst.Page::evaluate_dom_expressionnow projects the read-only document/navigator state shape (readyState,compatMode, visibility,documentURI/baseURI, focus/active element, viewport/window/screen state, language/userAgent). The runtimeStoragehost object now crosses explicitdeno_coreops for in-memorylocalStorage/sessionStoragemutation through the same storage-key validation and quota boundary the persistent host object will use.navigator.permissions.query(), Notification permission reads, and StorageManager persisted-state checks now cross an explicit permission op that reads persistedvixen-store::PermissionRecorddecisions by origin, returningprompt/defaultfor unknown decisions and rejecting unsupported names;navigator.storage.estimate()reports bounded local-storage usage.JsRuntimenow owns a persistent realm, so sequential evals retain globals, storage, and promise/event-loop state until the caller switches between non-page/page realms or navigates to a new page snapshot. The runtimefetch()MVP now crosses an explicit op intovixen-net, returns real HTTP(S)Responsestatus/headers/text body, and rejects private/reserved hosts through the shared URL policy before I/O. The DOM query seam now also covers core node/ancestry properties (nodeName/nodeType,isConnected,ownerDocument, andElement.closest()) before full Node / Element JS host wrappers replace the string projection.vixen-engine::script::JsRuntime::evaluate_with_pagenow installs the firstdeno_coredocumentsnapshot host-object seam for focused evals:document.title,document.body.textContent, simplequerySelector/getElementByIdelement properties and attributes, andquerySelectorAll().length, plus read-onlyDOMTokenList(classList/relList/sandbox) andDOMStringMap(dataset) property reads. The remaining broad DOM smoke surface still fails closed throughPage::evaluate_dom_expressionuntil each family moves behind real wrappers.Page::evaluate_dom_expressionnow projects constructor smoke forEvent/CustomEventand elementdispatchEvent(new Event(...))so event-object and EventTarget wiring has fixture coverage before listener queues and full capture / bubble dispatch land.vixen-engine::form_submission— the three WHATWG HTML § 4.10.21 form- submission encoders (application/x-www-form-urlencoded,multipart/form-data,text/plain) plus theFormEntry/FormEntryValuedata model +FormEnctypeselector. The URL-encoder uses the URL Standard's space→++ uppercase-hex percent-encoding; the multipart encoder handles RFC 7578 § 4.2Content-Dispositionquoting +filename+Content-Typeper part, with CRLF discipline; the boundary generator is RFC 2046-capped.Page::evaluate_dom_expressionnow reuses the same form entry-list builder for a read-onlyFormData(form)smoke seam (get/getAll/has, iterator first-entry shape, plus filename/type/size). Runtime/CDP submit actions now call the same Page-backed entry-list by stable node id, so idless forms, successful submitter entries, and submitterformaction/formmethod/formenctypeoverrides share the navigation path. Runtime form reset restores default value/checked/selected state and honors cancelableresetevents; mutableFormDataremains a narrow in-realm helper until full file/body plumbing lands.vixen-engine::dataset— WHATWG HTML § 3.2.6.9data-*attribute ↔ dataset property-name bidirectional mapping (deserialise, serialise, collect), with the anti-collision rule (-followed by uppercase ⇒ not exposed).Page::evaluate_dom_expressionand the transitionaldocumentsnapshot now use that same dataset mapper for the read-only smoke surface (element.dataset.fooBar/ bracket access), proven byfixtures/dom/dataset.htmljs-evalchecks while the WPT adapter continues to use the Page projection.Page::evaluate_dom_expressionalso projectsValidityStateflags,willValidate, andcheckValidity()/reportValidity()from the pure forms module for fixture smoke coverage until the Phase 6 form host objects land.Page::evaluate_dom_expressionprojectsElement.innerHTML/outerHTMLgetters throughvixen-engine::html_serialize, proving the HTML serialisation host-object seam with WPTjs-evalchecks before mutation setters and Trusted Types enforcement land.Page::evaluate_dom_expressionalso reflects simple security-relevant element properties (HTMLMetaElement.content/.charset) so CSP/referrer meta fixtures cover the DOM host seam before Phase 7 enforcement consumes those declarations.fixtures/forms/validation.html— exercises every form pseudo-classstyle_domresolves today (:checked/:disabled/:enabled/:required/:optional/:read-only/:read-write) plus the Page-backed validity eval seam; wired intofixtures/manifest.json.fixtures/dom/dataset.html— exercises the canonicaldata-foo-bar→fooBarsurface the host-hook layer will reflect; wired intofixtures/manifest.json.fixtures/forms/submission.html— fixes the form-DOM input shape the three encoders andFormDataprojection walk; wired intofixtures/manifest.json.
Pure-logic foundation landed for the DOMTokenList surface (Phase 6 prep).
vixen-engine::class_list— WHATWG HTML § 4.6.4DOMTokenList+ the § 2.7.3 "ordered set of unique space-separated tokens" parser + validator (empty ⇒SyntaxError; ASCII-whitespace-bearing ⇒InvalidCharacterError). The full mutating surface (add/remove/togglewith theforceparameter /replacewith the drop-old-if-new-already-present edge case /contains/item/iter/serialize) with the spec's atomic validate-then-mutate rule (any invalid token in a multi-tokenadd/removeaborts the whole call without partial mutation). The optionalSupportedTokensset is the surface<link>.relList.supports(token)consults (the onlyDOMTokenListwith a supported-tokens set per WHATWG § 4.6.5).fixtures/dom/class-list.html— exercises the canonical classList patterns the host-hook layer reflects (duplicate-token collapse, whitespace-run collapse, the case-sensitiveFoo/foo/FOOdistinction, the multi-value<link rel>form) and now asserts the read-onlyclassList/relListeval seam; headless/CDP focused evals run through the current JS runtime seam while the WPT adapter continues to use the Page projection; wired intofixtures/manifest.json.fixtures/security/sandbox.htmlnow also asserts the Page-backediframe.sandboxDOMTokenList projection (length/contains/item), with focused headless/CDP evals now backed by runtimeDOMTokenListsnapshots, before real framed-document sandbox enforcement consumes the same tokens.
Pure-logic foundation landed for Network host hooks (Phase 6 prep).
vixen-engine::url_search_params— WHATWG URL StandardURLSearchParams(§ 5.2 parse + § 5.3 serialize) plus the full mutating surface (get/getAll/has/has_pair/append/set/delete/delete_pair/sort/entries/keys/values) thenew URLSearchParams()JS host hook reflects. The parser handles leading-?stripping,+→SPACE, percent-decode with U+FFFD on ill-formed UTF-8, and empty-tuple dropping; the serializer shares theapplication/x-www-form-urlencodedbyte set withform_submission::encode_urlencoded(kept separate because the specs are).Page::evaluate_dom_expressionnow exposes a read-onlyURL.canParse()/new URL()/URLSearchParamssmoke seam (href/origin/components /toString()/searchParams, record-list constructor, two-argumenthas, and first iterator entry/key/value shape) fromwhatwg_url+url_search_params, proven byfixtures/network/url-parsing.html.vixen-engine::mime— WHATWG MIME Sniffing § 2.1MimeType::parse+ § 2.2serialize+ theessence()accessor. Tolerant whitespace + case handling, quoted-string parameter values (RFC 9110 § 3.2.6 backslash-pair escaping), first-occurrence-wins on duplicate parameter names. Every network layer (Content-Type),fetch()/XHR(.type/overrideMimeType), and<object>/<embed>plugin negotiation consults this one parser.vixen-engine::text_codec— WHATWG Encoding API (TextEncoder+TextDecoder).encode_intoreports UTF-16-code-unitread+ bytewrittenwithout splitting a scalar value; the Page seam and the current runtime host-constructor pilot both parse theTextDecoderconstructor label/options dictionary (fatal,ignoreBOM);decodedoes the BOM sniff (ignoreBOMopt-out), thefatal-flag UTF-8 validation, the WHATWG § 4.6 one-U+FFFD-per-maximal-subpart replacement (viafrom_utf8_lossy, which agrees with the WHATWG count), and the § 7.1CRLF/lone-CR→LFline-break normalisation. v1 ships UTF-8 only; unknown labels fail closed.- The same fixture set now asserts the Page-backed
TextEncoder/TextDecoder,atob/btoa, andDOMParser.parseFromString(..., "text/html")smoke seams (encoding, encoded byte length,encodeIntoread/written,TextDecoderlabel/options, decode, base64 round-trip, parsed document query) whilevixen-headless --eval/ CDPRuntime.evaluatenow exercise op-backed runtimeTextEncoder/TextDecoderconstructors plus focuseddocument/Element/ read-onlyDOMTokenList/DOMStringMapsnapshot extension objects backed by the same Page data.script::webidlnow renders the first generated browser interface/prototype substrate for the runtime-visible DOM/CSSOM/geometry subset; DOM and CSSOM bootstraps adopt those generated prototypes instead of hand-rolling constructor shape. The DOM snapshot data now crosses thedeno_coreop boundary viaop_vixen_dom_snapshot; selector lookup andElement.matches()now use finer-grained DOM ops, and element record data is loaded throughop_vixen_dom_element_snapshot. Element text/attribute reads plus read-only DOMTokenList/dataset data now use focused DOM ops, and elementgetBoundingClientRect()/getClientRects()/getBoxQuads()geometry reads plus client/offset/scroll metrics now cross a focused DOM rect op. FocusedCSS.supports,getComputedStyle, and CSSStyleSheet/CSSRule smoke evals now use the op-backedscript::cssomextension, leaving imported full WebIDL manifests, Geometry Interface value constructors/forms/events/history/storage/fetch as the next host-object replacement targets.
Pure-logic foundation landed for the fetch host-hook data model (Phase 6 prep).
The Headers object, Blob/File metadata, read-only Request/Response
state, static Response.error() / Response.redirect(), and
AbortController/AbortSignal primitives are the sync data surfaces the
fetch() / XMLHttpRequest / streaming host hooks reduce to. The pure modules
remain #![forbid(unsafe_code)], Rust-unit-tested.
vixen-engine::headers— Fetch § 3.2.2Headersobject data model:validate_header_name(RFC 9110 § 5.5token+ lowercasing) +validate_header_value(OWS trim, NUL/CRLF rejection, code-point-≤ U+00FFgating); the § 3.2.2 forbidden predicatesis_forbidden_request_header(the exact 21-name list + theproxy-/sec-prefix rules the Request constructor strips) +is_forbidden_response_header_name(set-cookie/set-cookie2); the § 3.2.1.2 CORS-safelist predicateis_cors_safelisted_request_header(theAccept/Accept-Language/Content-Language/Content-Type(+Range) family with the value-byte cap, the CORS-unsafe-byte gate, and the MIME-essence +Rangegrammar checks); and the normalisedHeadersstore (append/set/get/getAll/delete/has + comma-combine on read + byte-order + insertion-order iteration).vixen-engine::abort— DOM § 8.1AbortController/AbortSignal: theaborted+reasonvalue model (default reason ="AbortError"DOMException),AbortController::abort(idempotent, first-reason-wins),abort_any(§ 8.1.3.2AbortSignal.any()snapshot — aborted iff any input is, taking the first-aborted input's reason; reactive propagation is the host-hook event-loop layer's job), andTimeoutSignal(§ 8.1.3.2AbortSignal.timeout(ms)request record with the zero-delay-aborts- synchronously rule).Page::evaluate_dom_expressionnow projects read-onlyHeaders,Blob/File,Request,Response,AbortController/AbortSignal, andURLPatternsmoke seams from these pure modules (get/has, iterator shape, forbidden-header filtering in Request/Response init, byte-size/type/name/ method/status/header state,Response.json(), timeout/any snapshots, pathname test/exec groups) while full streaming/upload/XHR routing host objects land.vixen-engine::script::webapinow exposes a minimalfetch(input, init)host function. It normalizes throughRequest, validates viavixen-net::validate_http_url, routes HTTP(S) throughvixen-net::Network, and resolves toResponsewith status, headers, URL, redirect flag, and text body; policy/network failures reject the promise asTypeErrorand emit stable fetch failure events for CDPNetwork.loadingFaileddiagnostics.
Pure-logic foundation landed for the Performance API + viewport adaptation (Phase 6 prep).
The performance.now() monotonic-clock + <meta name=viewport> primitives
the timing host hooks and the mobile layout layer reduce to. Both
#![forbid(unsafe_code)], Rust-unit-tested.
vixen-engine::high_res_time— High Resolution Time § 4:DOMHighResTimeStamp(f64ms), the per-globalTimeOrigin(ms since Unix epoch thatperformance.now()is relative to), the § 4.4MonotonicClock(non-decreasing across calls + clamped to≥ 0), the § 4.4coarseneffective-time-value coarsening (floor to100µsunless cross-origin isolated), and theperformance.now()→ Unix-epoch conversion (timeOrigin + now) the legacyPerformanceTimingsurface reduces to.Page::evaluate_dom_expressionnow projects Performance API smoke checks fortypeof performance.now(), non-negativeperformance.now(), and monotonictimeOrigin + nowshape through this pure clock model while the real per-global timer host object lands.vixen-engine::viewport_meta— WHATWG HTML § 9.3<meta name="viewport">contentparser: the comma-separated<name>=<value>declaration set (width/heightdevice-keyword or CSS-px number,initial-scale/minimum-scale/maximum-scaleclamped to[0.1, 10],user-scalableyes/no,viewport-fitauto/contain/cover). Names ASCII-case-insensitive, values use the lenient leading-numeric-prefix extraction, unknown properties ignored. The CSS Device Adaptation 1 § 10 defaulting (width=980, &c.) stays in the layout layer; this module captures the authored declaration set.
Pure-logic foundation landed for URLPattern (Phase 6 prep).
vixen-engine::url_pattern— URLPattern API § 2 pathname pattern compile + match: the routing primitive client-side routers, service-workerFetchEventrouting, and thenew URLPattern()host hook reduce to.URLPattern::compileparses the pathname-grammar subset (literal segments,:namenamed captures with the[A-Za-z_][A-Za-z0-9_]*name rule,*rest-of-path wildcard) with duplicate-name detection + the wildcard-must-be-trailing rule;URLPattern::match_pathnameis a full-match (segment-based, empty-segment-collapsing so/posts≡/posts/,:namecaptures one non-empty segment,*captures the rest joined by/). Theprotocol/hostname/port/search/hashcomponents- full-regex custom params (
:name(\\d+)) land with the host hook; the named/*subset covers real routing.
- full-regex custom params (
- The Page eval seam exposes that pathname subset through
new URLPattern({ pathname })test()/exec().pathname.groupssmoke checks.
Pure-logic foundation landed for HTML attribute microsyntaxes + data:/srcset URLs (Phase 6 prep).
vixen-engine::microsyntax— the WHATWG HTML § 2.4 "common parser idioms" every attribute-value reflection reduces to:parse_signed_integer(§ 2.4.4) andparse_non_negative_integer(§ 2.4.3) with saturating overflow socolspan/rowspan/tabindex/cols/maxlengthnever panic;parse_float(§ 2.4.5) — the lenient leading-numeric-prefix extractor ("100px"→100.0,"3e999"→+∞) that<input type=number>and thevalue sanitization algorithmbuild on;parse_dimension_value(§ 2.4.6) — the legacy<td width>/<img width>surface producing either a pixel length or a percentage; andparse_list_of_integersfor<area coords>. Every HTML attribute-value parser here is deliberately lenient (leading whitespace skipped, trailing content ignored for the float surface) per the spec's documented browser contract; the stricter value-sanitisation layers a trailing-garbage check on top of these primitives.vixen-engine::srcset— WHATWG HTML § 4.8.4.6 "Parsing a srcset attribute": the comma-separated image-candidate-string splitter + the § 4.8.4.7Nw/Nxdescriptor validator (Descriptor::Width/Density). Candidates carrying ≥ 3 whitespace-separated tokens (a URL can't hold two descriptors) and candidates with an unparseable descriptor are dropped per spec; survivors keep document order (the § 4.8.4.8 selection algorithm prefers the first match on ties). The responsive-image selection step itself (composing candidates with the viewport DPR +<img sizes>) lands with the resource-fetch layer in Phase 1/6.vixen-engine::data_url— RFC 2397data:URL parsing: the case-insensitive scheme check, the;base64flag (final-parameter form), the mediatype defaulting rules (omitted ⇒text/plain;charset=US-ASCII; parameters-only ⇒text/plain+ authored parameters), and the payload decode (standard-alphabet base64 with ASCII-whitespace skipping + missing- padding tolerance, or RFC 3986 § 2.1 percent-decode). The Fetch standard does not MIME-sniffdata:URLs, so the declared mediatype is exposed verbatim. Base64 decoding uses the vettedbase64crate (pure-Rust,unsafe-free), shared byvixen-engine(data URLs) andvixen-net(CSP hash sources); the percent decoder is hand-rolled.fixtures/dom/srcset.html— exercises every<img srcset>/<source srcset>authoring form the parser handles (width descriptors, density descriptors, the bare-URL form, the<picture>/<source>art-direction combination); wired intofixtures/manifest.json.
Pure-logic foundation landed for responsive-image selection (Phase 6 prep).
The srcset parser left the § 4.8.4.8 selection step itself as a TODO; the
family is now complete end-to-end.
vixen-engine::media_query— CSS Media Queries 4 condition evaluation: a recursive-descent parser for the<media-condition>tree (§ 3) over parenthesised<media-feature>s (§ 4) withand/or/notcombinators, the<media-type>prefix (screen/print/allwithnot/only), and themin-/max-prefix decode into aRangeconstraint (min-width≡width >=).MediaQuery::matchesevaluates against aViewport(CSS-px width/height, DPR, derived orientation, output context (screen/print), colour depth, primary hover/pointer, aggregateany-hover/any-pointer,prefers-color-scheme,prefers-reduced-motion). The § 4 features implemented:width/height/aspect-ratio/orientation/resolution/color/hover/pointer/any-hover/any-pointer/prefers-color-scheme/prefers-reduced-motion, with the § 4.3 boolean form ((hover),(color)) and the<general-enclosed>fail-closed rule (unknown ⇒false).vixen-engine::source_size— WHATWG HTML § 4.8.4.7 "Parsing a sizes attribute": the<source-size-list>splitter + per-entry validator. The final comma-separated entry is the unconditional default (§ 4.8.4.8: the last entry always provides a fallback when reached); a non-last entry without a media-condition is a parse error and the whole list falls back to the spec's100vwdefault.resolve_px(&Viewport)walks the entries in document order and returns the first match's length in CSS px.vixen-engine::responsive_select— WHATWG HTML § 4.8.4.8 "Selecting an image source": composes a parsedsrcsetwith a resolved source size + the viewport DPR. Computes per-candidate pixel density (width ÷ source-size forNw, thexvalue for density, implicit1xfor bare), rejects mixed width/density lists (§ 4.8.4.6 parse error), keeps candidates withdensity ≥ DPR(falling back to all if that empties the list), and picks the smallest surviving density (ties → document order). Theselect_sourcehelper walks the<picture>/<source media>art-direction list: the first<source>whose media query matches the viewport wins, else the<img>srcset selects.fixtures/dom/sizes.html— exercises every<img sizes>/<source media>authoring form (mobile-first + three-tier conditional lists, the bare-length default, em-based sizes, the<picture>art-direction surface with min/max-width, orientation, output-context, and aggregate input-device media queries); wired intofixtures/manifest.json.Page::evaluate_dom_expressionnow projects the read-only<img>.currentSrcsmoke surface for plainsrcset/sizesimages fromresponsive_select, plus Page-backedHTMLImageElementalt/dimension/loading/decoding/complete/decode reflection and amatchMedia().matches/.mediaseam frommedia_query, proving selected-image URL reflection and MediaQueryList shape until the full image resource fetch path lands.fixtures/dom/media.htmlcovers the adjacent inert media-control seam:HTMLMediaElement/ audio / video identity/constants, booleans, timing/volume state,canPlayType(), and promise-shapedplay()without claiming codec/decode support.fixtures/dom/resources.htmlcovers resource-element reflection forlink/style/script/sourceattributes, stylesheet ownership, andHTMLScriptElement.supports()while parser-discovered script execution remains a separate host-runtime slice.fixtures/dom/dialog-details.htmlcovers details/dialog open-state reflection, includingshow()/showModal()/close()/requestClose()state updates and the close-event automation hook.fixtures/dom/reflected-misc.htmlwidens the reflected-attribute host-object seam across list, quote/time/edit, image-map, embedded-content, and table-cell attributes that automation suites commonly probe before deeper layout/resource semantics exist.fixtures/dom/progress-meter.htmlcovers progress/meter numeric host state (value/min/max/low/high/optimum/position) plus label associations for status-control automation probes.fixtures/dom/canvas.htmladds an inert Canvas 2D context seam: default canvas dimensions, cachedgetContext('2d'), no-op drawing calls,measureText(),createImageData(), and deterministictoDataURL()smoke until the real raster surface lands.fixtures/forms/reflected-attrs.htmlwidens form-associated host reflection: form metadata, submitter override attributes, inputvalueAsNumberplusstepUp()/stepDown(), textareasetRangeText()/textLength, and custom validity message smoke.fixtures/dom/table-collections.htmladds read-only table structure smoke forcaption,tHead,tFoot,tBodies,rows,cells, and row/cell indexes, giving automation a table traversal seam before mutation APIs land.fixtures/dom/html-element-attrs.htmlcovers HTMLElement interaction/global reflection (tabIndex, access keys, drag/spellcheck/translate, virtual-keyboard hints, and popover state) used by higher-level locator/keyboard automation.fixtures/dom/text-tracks.htmlcovers inert media text-track state:HTMLTrackElementreflected attributes, stabletrackobjects,TextTrack, andTextTrackListlookup from media elements.fixtures/dom/offscreen-canvas.htmlcovers adjacent inert canvas adjunct APIs:ImageData,OffscreenCanvas2D context identity, blob/bitmap promises, and no-opPath2Dconstruction until real raster/bitmap transfer lands.fixtures/dom/shadow-root.htmlcovers the first Shadow DOM host-object shape:attachShadow(),ShadowRoot/DocumentFragmentidentity, host/mode fields, empty fragment queries, anddocument.createDocumentFragment()before composed-tree distribution/layout lands.fixtures/dom/template-slot.htmlcovers the adjacent web-component host shape:HTMLTemplateElement.contentas aDocumentFragmentplus slotname,assignedNodes(), andassignedElements()methods before slot distribution is wired into the composed tree.fixtures/dom/construction-serialization.htmlcovers DOM construction and serialization helpers (createElementNS(), text nodes, fragments, andXMLSerializer.serializeToString()) for framework feature-detection probes.fixtures/dom/platform-apis.htmlcovers browser-platform host probes that now run in thedeno_corepage realm: securecrypto.getRandomValues()/randomUUID(), async Clipboard text /ClipboardItem, first-callbackIntersectionObserver/ResizeObservergeometry, and fail-closedWebSocketclose diagnostics until real socket transport is wired.
Pure-logic foundation landed for CSS value-resolution + easing (Phase 3/6 prep).
The calculation + timing-function primitives the cascade (calc() reduction,
var() substitution, custom-property resolution) and the transition/animation
drivers (animation-timing-function) reduce to.
vixen-engine::calc— CSS Values 4 § 10calc()/min()/max()/clamp()arithmetic tree + evaluator. A recursive-descent parser produces aCalcNodeAST (Number/Length/Percent/Add/Sub/Mul/Div/ the § 10.1Min/Max/Clampmath functions);evaluateruns the § 10.7 "argument resolution" pass with full dimension type-checking (+/-require homogeneous operands;*requires a number operand;/requires a number divisor; violations are hard errors). Lengths and percentages mix in the classiccalc(50% + 10px)form, resolving to(px, percent)against aLengthContext. Operator precedence (*//over+/-) and nested parenthesised grouping enforced; bare expressions (nocalc()wrapper) parse too, so the--computed-styleprojection re-resolves the unwrapped form.vixen-engine::easing— CSS Easing 1 § 2-4: the timing-function family that maps an input progress (0..1) to an output progress.Easing::parsecovers the keyword aliases (linear/ease/ease-in/ease-out/ease-in-out/step-start/step-end) and the function forms (cubic-bezier(),steps(),linear());Easing::evaluateprojects cubic-bezier control points via Newton-Raphson (8 iterations) with a bisection fallback so it converges on every valid curve (incl. overshoot spring curves where the y-coordinates exceed[0, 1]), implements the § 4.1 step jump-position rules (jump-start/jump-end/jump-none/jump-bothwith thejump-none-requires-n ≥ 2validation), and piecewise-linearly interpolates thelinear()multi-stop function (explicit percentage positions + the § 3.1 implicit even-distribution rule).
Pure-logic foundation landed for CSS generated content (Phase 5/6 prep).
The counter-scope + marker-text primitives the content property
(counter()/counters()), list-style-type, and the ::marker box reduce
to. Both #![forbid(unsafe_code)], Rust-unit-tested.
vixen-engine::list_marker— CSS Lists 3 § 6.1<list-style-type>marker text: the predefined counter-style family (disc/circle/squarebullet glyphs,decimal/decimal-leading-zeronumeric, thelower-alpha/upper-alpha(+lower-latin/upper-latinaliases) bijective base-26 alphabetic,lower-roman/upper-romanadditive,lower-greekover the 24-letter alphabet,none).ListStyleType::renderis thevalue → textprojection per the § 6.1 algorithm table; the § 6.1 fallback rule (out-of-range additive/alphabetic values fall back todecimal, the default fallback) is enforced so a counter value never fails to produce a marker. Aliases normalise to the canonical name at parse so the round-trip is canonical.vixen-engine::counter— CSS2 § 12.4 counter scoping (reset/increment/set, with the per-kind default value —0for reset/set,1for increment) + CSS Lists 3 § 5counter()/counters()resolution.parse_counter_opstokenises thecounter-*declaration value (ASCII-whitespace-separated<custom-ident>optionally followed by one<integer>, thenoneno-op, saturating integer overflow, the--fooCSS-variable reservation rejected);resolve_counterreads the innermost in-scope value (orNone→ empty marker per § 5);resolve_countersjoins every in-scope value outermost→innermost with the delimiter string ("1.1","1.3.2"). The DOM traversal that pushes/pops scopes + applies the ops in document order stays in the Phase 4 layout layer; this module is the pure resolution primitive given the already-walked scope stack, and composes withlist_markerviarender_counter.
Pure-logic foundation landed for structured clone + MessagePort (Phase 6 prep).
The serialisation + entangled-port model postMessage(),
new MessageChannel(), worker postMessage(), BroadcastChannel, and
IndexedDB / history.pushState() reduce to. Both
#![forbid(unsafe_code)], Rust-unit-tested, composing with the cross-origin-
isolation gate (coep::is_cross_origin_isolated) for SharedArrayBuffer
exposure.
vixen-engine::structured_clone— HTML § 2.7.5 structured clone algorithm.StructuredCloneValueis the type-tagged tree of serialisable values (primitives,Date,Array,Object,Map,Set,ArrayBuffer,MessagePort,Errorwith theErrorKindsubclass family, and thePlatformObjectslot reserved forFile/Blob/ImageData&c.).clonedeep-clones the tree honouring the transfer list: every transferred handle must be reachable (DataCloneError::UnreachableTransferable), the list may not carry duplicates (DataCloneError::DuplicateTransferable), a detached buffer is rejected (DataCloneError::DetachedTransferable), and aSharedArrayBufferclone (not transfer) requires a cross-origin- isolated context (DataCloneError::SharedBufferRequiresIsolation— the gateis_cross_origin_isolatedfeeds).detach_transferredflips the transferredArrayBuffers to detached in the source tree;SharedArrayBuffers stay shared.is_cloneableis the partial-check a host hook calls before walking (so aDataCloneErrorsurfaces before any transfer side-effect). Shared-reference identity preservation (the spec's "memory" map) lives at the host hook where real JS object identities exist; this is the faithful tree-clone for tree inputs.vixen-engine::message_port— HTML § 9.5MessagePort/MessageChannel.MessagePortis one end of an entangled pair (thePortIdhandle appears inStructuredCloneValue::MessagePortand the transfer list);MessageChannel::newconstructs the pair.MessagePort::post_messageruns the § 9.5.4 steps: structured-clone the value (honouring the transfer list), and return the clone + the partner id + the transferred ports in aPostOutcome(the host hook routes the enqueue to the partner — the two ports may live in different compartments / workers).MessagePort::enqueue/MessagePort::drainare the receiver-side inbox + the event-loop hand-off;start()/close()carry the § 9.5.3 / § 9.5.5 lifecycle (a detached port dropspostMessageand drains nothing).Page::evaluate_dom_expressionnow projects a smallstructuredClone()smoke seam for primitive strings, arrays, shallow objects, Date, Map, Set, and Error name/message shape through the same clone function thatpostMessage()/ history state will call. RuntimeMessageChannelandBroadcastChannelsmoke now dispatchMessageEventthrough the same EventTarget/WebIDL host layer while the pure Rustmessage_portmodel remains the cross-compartment transfer primitive.
Pure-logic foundation landed for Range + Selection (Phase 6 prep).
The boundary-point model the Range / Selection host hooks + the
editing-command surface (document.execCommand, beforeinput dispatch)
reduce to. #![forbid(unsafe_code)], Rust-unit-tested.
vixen-engine::range— DOM § 5.2Range+ § 5.4Selection.NodeRefis an opaque DOM-node handle carrying aDocumentOrderindex (the pre-order DFS position the caller assigns) so two boundaries compare in document order by pure arithmetic.Boundaryis the(node, offset)pair (child index for elements, UTF-16 index for text nodes);Boundary::compareis the § 5.2 relative position (Ordering::Before/Ordering::Equal/Ordering::After).Rangecarries the(start, end)pair withRange::newre-ordering to the § 5.2start ≤ endinvariant,Range::is_collapsed+Range::collapse+Range::contains_boundary+Range::intersect.Selectioncarries theRangelist + the anchor/focus (direction-aware) +add_range/collapse_to/extend_to/remove_all_ranges+ theSelectionDirection(Forward/Backward/None— the focus-before-anchor "backward" selection state). The live tree mutation (surroundContents/insertNode/extractContents— the § 5.3 algorithms) is the host hook; this module is the pure boundary model.Page::evaluate_dom_expressionnow projects the read-only initial Range / Selection smoke seam (document.createRange().collapsed/ offsets and emptygetSelection()accessors) through this pure model while the live DOM mutation and selection host objects remain the Phase 6 swap-in.
Pure-logic foundation landed for session history + pushState (Phase 6 prep).
The HTML § 7.1 session-history entry-stack + the history.pushState /
replaceState / back / forward / go surface the History host hook
- the navigation layer reduce to.
#![forbid(unsafe_code)], Rust-unit-tested.
vixen-engine::history— HTML § 7.1.ScrollRestorationis theauto/manualhistory.scrollRestorationmode;HistoryEntryis one session-history entry (URL string + opaque structured-clonestateblob- the
scrollRestorationmode + the optional title).SessionHistoryis the entry stack + the current-entry cursor with the § 7.1 surface:push(truncates the forward branch per the § 7.1 "remove all entries after the current one" rule, appends, advances the cursor),replace(swaps the current entry, length unchanged),back/forward/go(delta)(cursor movement with out-of-range ⇒ no-op),length/index/url/state/scroll_restoration, and thewith_entriesescape hatch for the host hook that restores a persisted session history. The document load/unload for a traversal (the § 7.5 "traverse the history" algorithm), the same-origin URL check forpushState/replaceState, and the structured-clone serialisation of thestatevalue stay in the navigation layer / host hook (the host hook serialises viacrate::structured_clonebefore callingpushState).
- the
Page::evaluate_dom_expressionnow projects the read-only initial History smoke seam (history.length,history.state,history.scrollRestoration) fromSessionHistory::new(HistoryEntry::navigation(_))before mutablepushState/traversal host objects land.
Pure-logic foundation landed for MutationObserver (Phase 6 prep).
The DOM § 4.3 mutation-queue + the § 4.3.1 match predicate the
MutationObserver host hook + the microtask-delivery step reduce to.
#![forbid(unsafe_code)], Rust-unit-tested.
vixen-engine::mutation_observer— DOM § 4.3.MutationTypeis the threechildList/attributes/characterDatarecord types;MutationRecordis one record (target + added/removed nodes + siblings forchildList+ attribute name/namespace +oldValue);MutationObserverInitis the § 4.3.1observe()options (childList/attributes/attributeFilter/attributeOldValue/characterData/characterDataOldValue/subtree).Relation(Target/Descendant) +should_observeis the § 4.3.1 match predicate (the options vs the mutation type + the target/subtree relation + the attribute filter).MutationObservercarries the record queue + the registrations +observe(re-observing replaces per § 4.3.1, invalid options rejected) /disconnect(clears registrations, keeps pending records) /takeRecords/drain_for_delivery(the microtask-checkpoint batch). The live-DOM-tree relation classification + the microtask checkpoint scheduling + the callback invocation stay in the host hook / event-loop layer.Page::evaluate_dom_expressionnow projects the initial MutationObserver lifecycle smoke seam (takeRecords().length,disconnect()) through this pure queue model while live DOM mutation delivery remains in the host hook.
Pure-logic foundation landed for TreeWalker + NodeIterator (Phase 6 prep).
The DOM § 6 filtered traversal model the two NodeFilter-based iterators
reduce to. #![forbid(unsafe_code)], Rust-unit-tested, over a Tree
trait the host hook implements on the real DOM.
vixen-engine::traversal— DOM § 6.NodeType(the DOMnodeTypecodes) +WhatToShow(the § 6.1whatToShowbitmask,SHOW_*constants +SHOW_ALL) +FilterResult(FILTER_ACCEPT/FILTER_REJECT/FILTER_SKIP) + theNodeFiltertrait (the JS callback the host hook implements) + theTreetrait (the host hook's tree access).TreeWalkeris the § 6.2 rooted stateful walker with the seven methods (parent_node/first_child/last_child/next_sibling/previous_sibling/next_node/previous_node);FILTER_REJECTskips the rejected node's subtree,FILTER_SKIPtraverses into it.NodeIteratoris the § 6.3 flat preorder iterator (next_node/previous_node) whereREJECT==SKIP(the flat cursor has no subtree state), plus theadjust_for_removalstep the host hook consults when a node is removed from the tree (the reference moves to the removed subtree's previous sibling's last descendant, else the parent). The real-DOM tree walk + the JSNodeFiltercallback invocation stay in the host hook.Page::evaluate_dom_expressionnow projects the whatToShow-only element traversal smoke seam fordocument.createTreeWalker()anddocument.createNodeIterator()by adapting the parsed document to the traversal module'sTreetrait.
Pure-logic foundation landed for the WHATWG URL parser (Phase 6 prep).
The URL Standard § 4 parse + serialize + relative-resolution model the
fetch / navigation / new URL() host hooks consult. #![forbid(unsafe_code)],
Rust-unit-tested.
vixen-engine::whatwg_url— WHATWG URL Standard.Urlcarries the parsed components (scheme / username / password / host / port / path / query / fragment);is_special_scheme+default_portencode the § 3.1 special-scheme family (http/https/ws/wss/file).parseparses an absolute URL;parse_with_baseis the § 4.6 relative-resolution parser (absolute-path / relative-segment merge / query-only / fragment-only / scheme-relative against a baseUrl).Url::serializeis the § 4.1 canonical serialiser (the default port omitted, IPv6 re-wrapped in[...], the opaque-path no-slash form for non-special schemes);Url::originis the § 4.5(scheme, host, port)tuple the fetch / storage layers partition on.percent_encode+ theEncodeSets (C0 control / fragment / query / path / userinfo) cover the § 4.2 percent-encoding family. IDNA, full IPv6, and the opaque-path long tail are the deferred slices (non-ASCII hosts fail closed; the IPv6 literal is captured verbatim). The module is namedwhatwg_url(noturl) so it doesn't shadow the externurlcrate the rest of the engine consumes.
Pure-logic foundation landed for HTML fragment serialisation (Phase 6 prep).
The DOM → HTML string pipeline the Element.innerHTML getter, outerHTML,
document.write, DOMParser round-trip, and XMLHttpRequest.responseText
(HTML documents) host hooks read from. #![forbid(unsafe_code)],
Rust-unit-tested, operating over the markup5ever_rcdom::Handle the parse
side (crate::doc) already owns.
vixen-engine::html_serialize— WHATWG HTML § 13.2.9 "Serializing HTML fragments".serialize_childrenis theElement.innerHTMLgetter (the § 13.2.9 fragment serializer over a node's children);serialize_nodeis theElement.outerHTMLgetter (one node + descendants).escape_text(§ 13.2.9 step 8:&→&,<→<,>→>, NBSP → ) andescape_attribute(§ 13.2.9 step 5:&,", NBSP) are the escape rules exposed standalone for the editing-command surface.Scriptingis the scripting-flag toggle (thenoscriptelement is raw-text when scripting is enabled, the production case; normal-text otherwise, theDOMParser/ print case). The void-element table (area/base/br/col/embed/hr/img/input/link/meta/param/source/track/wbr) + the raw-text table (script/style/xmp/iframe/noembed/noframes/plaintext+ conditionalnoscript) are the § 13.2.9 step 3 classification. The pre-serialisation tree mutation for theinnerHTMLsetter (the parse side) and the foreign-content (SVG/MathML) CDATA escapes stay in the parse layer.
Gate: fixtures/dom/, fixtures/events/, fixtures/forms/,
fixtures/storage/, fixtures/network/ all pass.
Phase 7 — Security hardening (≈ 1 week)
Wire every trust boundary from docs/ARCHITECTURE.md.
Steps:
- CSP enforcement at
script.rs::evaluate(script-src / unsafe-inline / nonce / hash) and at fetch (per-directive URL matching). - Cookie validation already done in Phase 1; confirm document-side
boundary (
document.cookiecannot set HttpOnly). - URL policy re-applied at every fetch, including JS-initiated fetch / XHR.
- Origin isolation confirmed across storage, scripts, cookies.
- Permissions API behaves per spec.
just auditpasses.- Fuzz targets:
url_policy,csp::parse,html5everparse, the cookie parser. Each runs 1 M iterations without panic.
Pure-logic foundation landed (Phase 7 prep).
vixen-net::referrer_policy— Fetch § 3.4Referrer-Policyparser (last-known directive wins) + § 4.3.7resolve_referrercovering every policy branch (downgrade suppression, same-origin gating, origin-only, strict-origin-when-cross-origin default) + theis_potentially_trustworthytest the downgrade rules reduce to. The network layer attaches the resolvedRefereronce wired.vixen-net::strict_transport_security— RFC 6795 § 6.1 HSTS header parser (case-insensitive directives, tolerant whitespace, header ignored without validmax-age,max-age=0cache-deletion signal) + § 8.2HstsEntry::matches(exact host or, withincludeSubDomains, a dot-prefixed subdomain — the superdomain rule is one-way).vixen-net::cors— Fetch § 3.2.1Access-Control-*response-header parser (case-insensitive names, lowercased + de-duplicated lists, repeated origin header first-wins), § 4.1.5cors_check(wildcard + credentials forbidden, specific-origin string equality,null-origin echo), and § 4.1.6cors_filtered_headers(safelist of 7 response headers + named exposes, withSet-Cookie/Set-Cookie2always stripped). The script→fetch host hook consults this at every cross-origin response.vixen-net::mixed_content— W3C Mixed Content L1 § 3 verdict (NotMixed/Block/Upgrade) the fetch layer applies at every subresource fetch out of a secure context. [ResourceType] collapses the fetch destination to the three modal categories (active=block, passive=upgrade, navigation=allow);block-all-mixed-contentCSP overrides upgrades. Reusesreferrer_policy::is_potentially_trustworthyfor the request-URL secure-transport test.fixtures/security/cors-headers.html— exercises the HTML surface (crossorigin,integrity,nonce) the host-hook layer dispatches on when constructing the cross-origin fetch; wired intofixtures/manifest.json.fixtures/network/mixed-content.html— exercises every mixed-content surface (http:// scripts/stylesheets/iframe/object vs. images/audio/video vs. top-level navigation, plus https:// counterparts); wired intofixtures/manifest.json.
Pure-logic foundation landed for <iframe sandbox> (Phase 7 prep).
vixen-net::sandboxing— WHATWG HTML § 4.8.5 sandbox-flag parser (the fullallow-*keyword set: forms / modals / orientation-lock / pointer-lock / popups / popups-to-escape-sandbox / presentation / same-origin / scripts / top-navigation + the user-activation + custom-protocols variants / downloads / storage-access / unsafe-downloads). Tokenised on ASCII whitespace, case-insensitive, unknown flags ignored, empty value ⇒ most-restrictive. The derived security predicates the script/navigation/storage layers consult:implies_unique_origin(the § 4.8.5 opaque-origin rule), andis_dangerous_scripts_plus_same_origin(the famous "if bothallow-scriptsandallow-same-originare present, the sandbox is escapable" warning the spec mandates).fixtures/security/sandbox.html— exercises everysandboxvariant the parser handles (empty / scripts-only / scripts+same-origin dangerous combination / top-nav family / popups family / mixed legacy flags / unknown-token tolerance / case-insensitivity); wired intofixtures/manifest.json.
Pure-logic foundation landed for Sec-Fetch-* + Permissions Policy (Phase 7 prep).
vixen-net::sec_fetch— Fetch § 3.1Sec-Fetch-*request-metadata parsing:SecFetchSite/SecFetchMode/SecFetchDest/SecFetchUsertyped enums (case-sensitive token parse, fail-closed to [Default] on unknown values) + a bundledSecFetchHeaders::parseover a(name, value)iterator (case-insensitive names, last-wins combine). The § 3.2.4classify_siteclassifier resolves the embedder↔target relationship (same-origin/same-site/cross-site/none) the fetch layer attaches and that servers consult for the § 3.2 Cross-Origin gates; thesame-siteregistrable-domain comparison uses the last-two-labels heuristic (documented limitation; the PSL lands when the cookiedomainmatcher needs it too).SecFetchDest::is_navigation/is_embedpredicate the § 4.4 navigation and § 3.2 COEP checks.vixen-net::permissions_policy— Permissions Policy 1 § 3.3Permissions-Policyresponse-header parser + the § 5.2<iframe allow>attribute parser. TheAllowlistenum covers every § 3.3 source-list form (Everyone */Self_ self/Src src/Origins(list)/None ()-deny-all);PermissionsPolicy::allowsis the § 4 evaluation the host hooks consult before exposingnavigator.geolocation/camera/ &c. (features not in the policy default to embedder-only per § 3.3). The structured-field parser is paren/quote-aware (handlesgeolocation=(self "https://partner.test")and the iframe shorthandcamera 'self'), tolerant of whitespace, and drops malformed items per the spec's "parse error ⇒ item dropped" rule.fixtures/security/permissions-policy.html— exercises every<iframe allow>authoring form (bare feature names, theself/srckeywords, explicit origin lists, the empty()deny-all, the camera/geolocation/ microphone/fullscreen/autoplay family); wired intofixtures/manifest.json.
Pure-logic foundation landed for the WebSocket protocol boundary (Phase 6/7 prep).
vixen-net::websocket— RFC 6455 pure-logic boundary:compute_accept(§ 4.2.2Sec-WebSocket-Accept=base64(SHA1(key + GUID)), via thesha1crate — already transitively present),validate_client_handshake(§ 4.1 the server-sideUpgrade/Connection/Sec-WebSocket-Version: 13/16-byte-key enforcement) +validate_server_response(§ 4.2.2 the client-side101+ Accept-matches-sent-key check),parse_frame_header(§ 5.2 the 2–14-byte frame decoder — FIN/RSV/opcode/mask/length, with the § 5.2 reserved-RSV/opcode rejection + the non-canonical-length rule + the § 5.5 control-frame≤ 125bytes + FIN-set invariants),apply_mask(§ 5.3 the XOR demask) +validate_close_code(§ 7.4 the status-code range + reserved- band rule). The framed TCP+TLS transport + theWebSocketJS host hook sit on top;permessage-deflateis deferred.
Pure-logic foundation landed for the cross-origin isolation gate (Phase 7 prep).
The COOP + COEP response-header pair that, together, make a browsing context
"cross-origin isolated" — the gate the high-resolution timers
(vixen_engine::high_res_time::coarsen), SharedArrayBuffer exposure,
and the other Spectre-hardened APIs consult. Both #![forbid(unsafe_code)],
Rust-unit-tested.
vixen-net::coop— HTML § 7.8Cross-Origin-Opener-Policyparser. The three § 7.8.4 policy values (coop::Coop—unsafe-nonedefault /same-origin-allow-popups/same-origin) via the § 7.8.1 structured-header item parse (case-insensitive token, unknown ⇒UnsafeNonefail-closed,report-toparameter captured).coop::Coop::isolates_openeris the § 7.8.4 opener-isolation predicate the navigation layer consults before reusing a browsing-context group.vixen-net::coep— Fetch § 3.2Cross-Origin-Embedder-Policyparser. The three § 3.2 policy values (coep::Coep—unsafe-nonedefault /require-corp/credentialless) via the structured-header item parse.coep::is_cross_origin_isolatedis the HTML § 7.2 combined gate:trueiff the COOP issame-originand the COEP isrequire-corporcredentialless. This is the booleanMonotonicClock::now'scross_origin_isolatedparameter receives, removing the100µscoarsening floor when the context is fully hardened.
Pure-logic foundation landed for Subresource Integrity + X-Content-Type-Options (Phase 7 prep).
The two response-header boundaries the fetch layer consults before
executing a subresource — the tampering-resistance surface (SRI) + the
MIME-confusion surface (nosniff). Both #![forbid(unsafe_code)],
Rust-unit-tested.
vixen-net::integrity— W3C SRI § 3.2.2<script integrity>/<link integrity>metadata parse + § 3.3.4 verify.HashAlgorithmis the three SRI-mandated algorithms (sha256/sha384/sha512); SHA-1/MD5 are collision-broken and dropped at parse time per spec.parse_integritysplits ASCII-whitespace-separated<algo>-<base64>entries (+ the optional?<options>tail, parsed but not enforced in v1).verifycomputes each hash over the raw response body via the vettedsha2crate + a constant-time compare (a timing oracle can't recover the digest); any match passes (the spec's "best candidate" rule). TheIntegrityOutcome(NoMetadata/Verified/Mismatch/NoKnownAlgorithms) drives the fetch layer's block.vixen-net::nosniff— Fetch § 2X-Content-Type-Options: nosniffenforcement.is_nosniffis the case-insensitive token parse (the parameterised historical form is rejected);is_javascript_mimeis the Fetch § 3.7 16-entry JavaScript-MIME-type predicate;Destinationcollapses the § 3.1.7 request destination to the two nosniff-relevant categories (Script/Style/Other);enforceblocks aScriptdestination whose MIME is not a JavaScript MIME type and aStyledestination whose MIME is nottext/css, returning theNosniffOutcome(Allow/BlockScript/BlockStyle) the fetch layer surfaces as a network error. Other destinations are unaffected (the spec intentionally limitsnosniff's scope).
Pure-logic foundation landed for Cross-Origin-Resource-Policy (Phase 7 prep).
The Fetch § 4.5.3 CORP header + the combined COEP + CORP gate the fetch
layer consults before applying a no-cors subresource response into a
COEP-hardened document. #![forbid(unsafe_code)], Rust-unit-tested,
reusing crate::coep::Coep + crate::origin::Origin.
vixen-net::corp— Fetch § 4.5.3.Corpis thesame-origin/same-site/cross-originvalue (parse_corpcase-insensitive,Nonefor an absent / unparseable header).is_same_siteis the § 4.5.3 same-site predicate (same scheme + matching registrable domain; the last-two-labels eTLD+1 heuristic the PSL refines later);check_corpis the § 4.5.3 check (Allow/Block, opaque origins fail closed).coep_corp_gateis the combined gate:unsafe-none⇒ allow; CORS ⇒ allow (the alternative opt-in);require-corp⇒ same-origin allow, cross-origin no-CORP block, cross-origin-with-CORPcheck_corp;credentialless⇒ same-origin allow, cross-originAllowWithoutCredentials. The CORS check itself + the COEP parse stay incrate::cors/crate::coep.
Pure-logic foundation landed for Trusted Types (Phase 7 prep).
The W3C Trusted Types trusted-types + require-trusted-types-for CSP
directive boundary the DOM injection-sink host hooks (.innerHTML,
eval(), document.write(), script.src = …, &c.) consult before
accepting a string. #![forbid(unsafe_code)], Rust-unit-tested.
vixen-net::trusted_types— W3C TT.TrustedTypeKindis the threeTrustedHTML/TrustedScript/TrustedScriptURLvalue kinds;AllowedNamesis thetrusted-typesdirective's policy-name set (None/Explicit(list)/Wildcard);TrustedTypesPolicyNamescarries the set + theallow-duplicatesflag;RequireForis therequire-trusted-types-for 'script'flag (the only sink-group in v1, covering every TT sink).parse_trusted_types+parse_require_trusted_types_forparse the two directives;policy_creation_allowedis the § 3.2.3createPolicy(name)gate (the allowed-name match + the duplicate-name block);evaluate_sinkis the § 3.3.5 injection-sink decision (a Trusted* value ⇒Allow; a string at a TT-requiring sink ⇒ApplyDefaultPolicyif adefaultpolicy exists elseBlock; a string at a non-TT sink ⇒Allow). The JSTrustedTypePolicyfactory + thecreateHTML/createScript/createScriptURLsanitisers + the violation-reporting surface stay in the host hook.
Gate: Every security test in vixen-net and vixen-engine green.
just audit passes. Fuzz targets stable.
Phase 8 — Headless CDP + tooling polish (≈ 1 week)
Implement the full headless tool surface.
Steps:
- Implement CDP server (tokio + tokio-tungstenite) in
vixen-headless. Command handlers call intovixen-enginevia theEngineInspectortrait. - Implement every CLI flag from
docs/SPEC.md"Headless CLI surface". Stable error codes preserved exactly. - Implement
--memory-stats,--paint-stats,--incremental,--list-fonts,--cdp. (Note:--gpuis omitted per ADR-003 — every render path is GPU-backed.) --cdpresponds to:Browser.getVersion,Target.createTarget,Target.attachToTarget,Page.navigate,Page.loadEventFired,Runtime.evaluate.
Gate: Every CLI flag works. CDP responds to required methods.
Phase 9 — Release hardening (≈ 1 week)
Final tock before v1.0.
Steps:
- Module size audit: no non-test module > 1,000 lines. Decompose where needed.
- Dead-code removal pass:
cargo machete, fix every clippy warning, audit#[allow(dead_code)]annotations. - Performance baselines: establish criterion baselines for
benches/{parse,style,layout,render}as each lands (Phase 3+). Future releases gate on no > 10 % regression vs the most recent release. - Binary size measurement:
just size-fp. Confirm targets perdocs/ACCEPTANCE.md. - WPT target profile from
docs/COMPAT.mdis green. Migrate remaining end-to-end CSS+DOM assertions out of Rust tests where an HTML fixture can cover the behavior. - Update
docs/COMPAT.mdwith measured pass counts, known gaps, and the next-release WPT expansion plan. - Write user-facing release notes.
Gate: every release gate in docs/ACCEPTANCE.md green. Tag v1.0.0.
Total: ~16–22 weeks of focused work.
Binary size strategy
Concrete levers, in priority order:
- Re-measure with the ADR-014
deno_coreruntime. V8 packaging changed the pre-migration size assumptions; current release promises must come from measured binaries. [profile.release]is already optimal (seedocs/ARCHITECTURE.md).- Feature-gate aggressively: CDP, devtools UI, keychain integration. Each behind a feature. Default build includes none of them.
- System Cairo/Pango/HarfBuzz/fontconfig from the GNOME SDK. WebRender uses the system GL stack; glyph rasterisation goes through fontconfig + freetype (system) into WebRender's own atlas.
- One paint path, not N. ADR-003/ADR-006 enforce this: no
tiny-skia, nofontdue, no parallel CPU rasterizer, noPaintBackendtrait. - Per-release measurement in
docs/ACCEPTANCE.md.
Targets:
| Binary | Target |
|---|---|
vixen (GUI) | TBD after deno_core/V8 measurement |
vixen-headless | TBD after deno_core/V8 measurement |
Testing strategy
WPT-first. Every CSS/DOM/Layout/Paint feature is tested via a WPT
fixture in fixtures/, not a Rust unit test. Rust tests cover only pure
logic (CSS length arithmetic, URL parsing, cookie validation, CSP
parsing, redb storage round-trip).
WPT-style check types in vixen-wpt (per docs/SPEC.md): DOM/style
assertions, layout-box coordinate assertions, display-list-contains paint
dump assertions, visual hashes, and ref-equivalent rendered-page comparisons
against reference HTML fixtures.
Snapshot tests against Firefox reference. A fixtures/reftest-baseline/
directory contains reference renderings. Each visual WPT fixture
compares against the baseline with a perceptual hash and 1 % pixel-diff
tolerance. Failures dump a side-by-side diff to target/reftest-diff/.
Performance regression. benches/{parse,style,layout,render}
criterion benches run on every release. Gate: no > 10 % regression vs
previous release.
Risk register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Stylo integration harder than estimated | Medium | High | Time-box Phase 3 to 2 weeks; if traversal conformance blocks, narrow v1.0 CSS scope and document gaps in docs/COMPAT.md. |
deno_core / V8 packaging size and cache churn | Medium | High | Keep JsRuntime seam stable, measure release binaries with V8, and document cache/pinning in guidance. |
| EGL surfaceless unavailable on some CI runners | Low | Medium | LIBGL_ALWAYS_SOFTWARE=1 + Mesa llvmpipe covers every Linux runner. |
gtk4::GLArea context sharing with WebRender | Medium | High | Validate in Phase 5 first week. Fallback: render to FBO, blit to GLArea with a tex. |
| Vixen-owned layout takes longer than planned | High | High | Keep Phase 4 vertical through Page; ship only the WPT-profiled v1 subset and document gaps in docs/COMPAT.md (ADR-013). |
| JS host-extension churn | Medium | Medium | Keep the public JsRuntime/JsValue seam stable while converting bootstrap surfaces to deno_core ops/resources; cite .tmp/ref/deno/core/, .tmp/ref/deno/runtime/, and .tmp/ref/deno/ext/. |
| Real-world pages regress vs Servo/Firefox | Low | Medium | Upstream issues; report and work around. Document in docs/COMPAT.md. |
| WPT migration backlog grows during build | Medium | Medium | Per-phase gate: each phase deletes Rust tests at the rate it adds WPT fixtures. |
Relm4 breaking change in Factory/Worker API | Low | Medium | Pin Relm4 version per release; consult .tmp/ref/relm4/ on upgrades. |
Per-phase gate summary
| Phase | Gate |
|---|---|
| 0 — Scaffolding | just gate-phase0 passes |
| 1 — Net + store crown jewels | just gate-phase1 passes |
| 2 — JS runtime | just gate-phase2 passes; runtime is deno_core per ADR-014 |
| 3 — HTML + Stylo | WPT CSS fixtures pass; cascade output correct |
| 4 — Layout | 20+ visual-hash fixtures match reference |
| 5 — Paint | just run shows a page; headless PNG within 1 % of GUI on 5 fixtures |
| 6 — Host bindings | fixtures/{dom,events,forms,storage,network}/ all pass |
| 7 — Security | just audit clean; all security tests green; fuzz stable |
| 8 — Headless CDP | Every CLI flag works; CDP responds to required methods |
| 9 — Release | All docs/ACCEPTANCE.md gates green; tag v1.0.0 |
Executable gates and evidence
This file is intentionally not a second roadmap. Product order and future
milestones live in ROADMAP.md; historical phase instructions live
in PLAN.md; measured compatibility lives in
COMPAT.md. This file answers only: “which checked-in command proves
which layer today?”
Gate index
| Command | Current evidence |
|---|---|
just gate-alpha | formatting, clippy, host workspace checks, generated WebIDL/runtime seams, BrowserCore ownership tests, BrowserCore-backed committed fixture runner, and stable crate-boundary allowlist |
just gate-architecture | leaf-crate dependency rules for vixen-api, vixen-net, vixen-store, and vixen-wpt; frontend direct-composition debt remains documented until ADR-017 migration |
just gate-smoke | reviewer baseline: formatting, clippy, host checks, and all host-runnable tests |
just gate-push | hk pre-push integration point: alpha, phase-6 runtime, smoke, and diff checks |
just gate-webidl | generated WebIDL constructor/prototype coverage plus headless/CDP runtime-host integration |
just gate-phase0 | workspace/API DTO and trait-shape foundation |
just gate-phase1 | network/store tests, audit, and security fuzz targets |
just gate-phase2 | deno_core runtime and headless eval seam |
just gate-phase3 | HTML/selector/cascade behavior and CSS fixture profile |
just gate-phase4 | Vixen layout-tree/line/fragment behavior and layout fixtures |
just gate-phase5 | display-list/WebRender screenshot and visual fixture path |
just gate-phase6 | engine host-family tests, WebIDL, headless runtime, and CDP runtime integration |
just gate-alpha6-cdp | external Playwright/CDP smoke over BrowserCore targets, including lifecycle, DOM/input, network, permissions, tracing, and stable errors |
just test-browser-core | ADR-017 production owner/thread/typed-generation proof with two independent contexts, shared profile localStorage, isolated runtime/sessionStorage/history, asynchronous source loading, stop/supersede late-completion rejection, bounded event lag, headless adapter coverage, and GTK-free multi-context shell routing |
just compat-report | current BrowserCore-backed committed fixture/profile counts and per-source/category output |
just fuzz-security | URL, CSP, cookie, and HTML parser fuzz targets at the configured run count |
just audit | cargo audit plus cargo deny check |
just flatpak-build | supported GNOME SDK/Flatpak GUI build path |
just size-fp | measured Flatpak GUI and release headless artifact sizes; measurement only until baselines become accepted budgets |
Evidence rules
- Run the cheapest focused crate test while editing, then the relevant gate above.
- A pure unit test proves an algorithm. A browser claim also needs a shared-core integration path, fixture/profile, external automation smoke, or GUI smoke.
- Fixture behavior changes update
COMPAT.mdfromjust compat-report; do not hand-invent counts. - ADR-017 frontend ownership migration is enforced by
gate-architecture; subsequent lifecycle work adds cancellation/partition/live-document evidence without restoring direct frontend composition. - GTK changes use
just flatpak-buildwhen host development packages are absent. - Size/performance thresholds become gates only after a representative baseline, environment, and comparison method are committed.
Current measured anchors
- Compatibility baseline: 269 fixtures / 2,015 checks / 100% passing as of
2026-07-10.
COMPAT.mdis authoritative. - External automation contract:
CDP_PLAYWRIGHT_SMOKE.md. - Browser ownership/cancellation vertical:
just test-browser-core(engine, headless, and GTK-free shell adapters through the production command/event handle). - Release requirements:
ACCEPTANCE.md.
When a gate and its description diverge, fix this table in the same change as the recipe. Do not copy already-landed feature inventories back into the roadmap.
Vixen acceptance criteria
Release is "done" when every gate below passes. Per-capability criteria
are expressed as fixture passes plus specific invariants; this document
does not re-list the delegated web-platform features or the Vixen-owned
layout subset (see SPEC.md and COMPAT.md for the
actual contracts).
Alpha is defined separately in PROJECT_DIRECTION.md:
architecture frozen and validated, with API surface still allowed to move.
Hard gates (release-blocking for v1.0)
-
crates/Rust LOC ≤ 20 k -
crates/uniqueCargo.lockdependencies ≤ 220 -
rg -e 'boa_engine|boa_runtime|taffy|tiny-skia|fontdue' Cargo.lockreturns nothing -
One display list, one paint path, two
GlContextimpls (per ADR-003 / ADR-006) — no CPU rasterizer, no fallback painter, noPaintBackendtrait -
No
sandbox.rs, noprocess_pool.rs, noipc/(per ADR-004) -
No WebKit dependency, no
engine-webkitfeature (per ADR-002) -
GUI renders a real web page to the screen via WebRender (manual
smoke on
fixtures/realworld/shows visible content — no static placeholders) -
vixen-headlessreproduces every flag inSPEC.md"Headless CLI surface" with stable error codes preserved -
WPT target profile in
docs/COMPAT.mdis green; measured pass counts are published for every supported category -
GUI/headless artifact sizes are published from
just size-fpand meet the accepted baseline/regression policy in §"Binary size gates" below -
docs/COMPAT.mdpublished with honest capability matrix -
just auditpasses (cargo audit+cargo deny check) -
just checkpasses -
hk hooks are installed or
hk run pre-push --checkpasses from a clean checkout - No non-test module > 1,000 lines
- All fuzz targets stable at 1 M iterations
Per-capability acceptance
Each capability is "done" when its fixture set passes. Where
SPEC.md pins a specific invariant, it's called out explicitly.
CSS cascade
Done when every fixture in fixtures/css/ passes.
Selectors
Done when every selector fixture passes plus the dedicated
selector-corpus fixture set (covering :has(), :is(), :where(),
the user-action and form pseudo-classes, link history tracking).
DOM
Done when every fixture in fixtures/dom/ passes, and the
composed event dispatch invariants from SPEC.md hold (enforced by a
dedicated fixtures/events/focus-order.html).
Layout
Done when the Vixen-owned Rust layout engine (ADR-013) passes the v1 WPT
target profile in docs/COMPAT.md: normal-flow block layout, inline line
boxes, margin/border/padding/box sizing, positioned descendants,
overflow/scroll containers, and useful flex/grid coverage. Nested-container
coordinates must be correct without any post-pass fixup. A realworld fixture
set (fixtures/realworld/) renders without obvious breakage.
Documented gaps allowed in docs/COMPAT.md: tables, floats, full vertical
writing, fragmentation/pagination, and advanced intrinsic sizing.
Paint
Done when:
- GUI path renders to
gtk4::GLAreavia WebRender (manual smoke) - Headless path uses EGL surfaceless (per ADR-009) and produces pixel-diff ≤ 1 % vs GUI on 5 reference fixtures — both renders go through the same WebRender paint path, so this is essentially a surface-binding correctness check
- Headless works on CI with
LIBGL_ALWAYS_SOFTWARE=1+ Mesallvmpipe(verified) - Display-list invariants from
SPEC.mdenforced by the display-list builder (z-index stacking, clip stacking, opacity groups, visibility skip-paint, background clip/origin/attachment)
JavaScript
Done when:
vixen-headless --url fixtures/dom/basic.html --eval 'document.title'returns the document title- The
deno_core/V8-backed embedded runtime passes the JS smoke/test262 subset selected for release - Every fixture in
fixtures/dom/,fixtures/forms/,fixtures/network/,fixtures/storage/passes - Form-validation edge cases from
SPEC.mdenforced exactly (email format, URL format, step arithmetic)
Networking
Done when every test in vixen-net passes, including the
Vixen-specific configurations from SPEC.md:
- URL policy blocklist (including the precise CGNAT check — see mandatory regression test below)
- Cookie defaults (Lax default SameSite, 512-entry FIFO cap, HttpOnly document-side rejection, safe-method Lax cross-site sending)
- CSP enforcement at script-exec / fetch / plugin-content boundaries
- Permissions API and origin isolation
Mandatory regression test for the CGNAT check:
#![allow(unused)] fn main() { assert!(is_private_host(&"100.64.0.1".parse::<Ipv4Addr>().unwrap().into())); assert!(!is_private_host(&"100.128.0.1".parse::<Ipv4Addr>().unwrap().into())); }
Storage
Done when the redb schema round-trips cookies, fetch-cache,
history, and sessions per vixen-store tests, and per-origin
partitioning is preserved.
Headless CLI
Done when every flag in SPEC.md "Headless CLI surface" works,
the stable error codes are returned exactly, and the CDP server
responds to every required method. The --gpu flag is removed (every
render path is GPU-backed per ADR-003); scripts depending on it should
drop the flag.
WPT harness
Done when vixen-wpt:
- Runs the full
fixtures/manifest.json - Runs pinned external WPT profiles without vendoring their upstream HTML into the repo
- Every check type in
SPEC.mdpasses its existing assertions - The new
ref-equivalentcheck works against at least 3 fixtures - Reports pass count/rate overall, per category, per source, and per source×category
- Separates local Vixen fixtures from imported upstream WPT fixtures so release notes can state exactly what was measured
Shell
Done when manual smoke passes:
- New / close / duplicate tab, reopen closed tab
- Address entry, paste-and-go
- Reload / stop, back / forward
- HTTPS / HTTP / local / failure status feedback
- Find bar
- Zoom
- Preferences, shortcuts, about windows
- Tab status diagnostics for load / TLS / download / permission events
- Engine actually renders page content to the visible window
Binary size gates
just size-fp builds and measures the real Flatpak GUI artifact and the stripped
release headless binary. The active deno_core/V8 + GTK + WebRender dependency
graph does not yet have an accepted reproducible baseline, so the command is
measurement-only rather than enforcing invented limits.
Before beta, publish the environment, artifact paths, GUI/headless byte counts, and install-size method here. Then adopt explicit warning/failure regression thresholds against that baseline. A release is blocked if its measured artifacts exceed those accepted thresholds without a documented product tradeoff.
Phase gates summary
Restated from PLAN.md as the per-phase acceptance check.
| Phase | Gate |
|---|---|
| 0 — Scaffolding | just gate-phase0 passes |
| 1 — Net + store crown jewels | just gate-phase1 passes |
| 2 — JS runtime | just gate-phase2 (vixen-headless --url <file> --eval '1+2' returns 3); runtime is deno_core per ADR-014 |
| 3 — HTML + Stylo | just gate-phase3; then WPT CSS fixtures pass with cascade output correct |
| 4 — Vixen-owned layout | just gate-phase4; then the v1 WPT layout target profile in docs/COMPAT.md is green |
| 5 — Paint | just gate-phase5; then just run shows a page and headless PNG diff ≤ 1 % |
| 6 — Host bindings | just gate-phase6; then fixtures/{dom,events,forms,storage,network}/ all pass |
| 7 — Security | just audit clean; all security tests green; fuzz stable |
| 8 — Headless CDP | Every CLI flag works; CDP responds to required methods |
| 9 — Release | just gate-smoke and all gates above green; tag v1.0.0 |
A phase is not done until its gate passes and the tock discipline (dead-code removal, ≤ 1 kLOC modules, references cited) has been observed.
Post-v1.0 scope
Deferred per DECISIONS.md ADR-007 / ADR-008 and other
implicit non-goals:
- WebKit fallback (rejected, ADR-002)
- Runtime engine switching (rejected, ADR-002)
- macOS / Windows native builds (rejected for v1.0, ADR-007)
- WebGPU (v1.1, via
wgpu) - Media playback (v1.1, via GStreamer)
- Full writing modes / vertical text (v1.1)
- Tables, floats, advanced intrinsic sizing (v1.1/v1.2, WPT-prioritized)
- Page fragmentation / pagination (v1.2)
- Service workers (v1.2)
- WebRTC (not planned)
Byte-for-byte Firefox rendering match is not the contract — behavioural parity on the WPT subset that matters for real sites is.
Vixen compatibility target
This is the honest v1.0 target matrix. It is not a claim of full Firefox or full WPT compatibility. Vixen delegates CSS cascade/selectors, HTML parsing, JS execution, and paint where credible upstream Rust/Firefox-family components exist; layout is Vixen-owned Rust code per ADR-013 and therefore WPT-gated by feature.
Current measured committed fixture baseline
As of 2026-07-10, fixtures/manifest.json contains 70 local fixtures plus
199 imported smoke fixtures:
| Category | Fixtures |
|---|---|
| css | 17 |
| css-cascade/css-values | 50 |
| dom | 25 |
| dom-core | 50 |
| events | 1 |
| flexbox | 5 |
| forms | 28 |
| grid | 5 |
| layout | 9 |
| layout block/inline/position | 6 |
| network | 2 |
| paint | 4 |
| paint/ref-equivalent | 8 |
| security | 9 |
| selectors | 50 |
| Total | 269 |
Total manifest checks: 2015.
Current check mix:
| Check type | Count |
|---|---|
selector-count | 398 |
selectors-exact | 223 |
title | 268 |
js-eval | 587 |
computed-style | 170 |
element-attribute | 132 |
layout-box | 104 |
body-contains | 68 |
visual-hash | 25 |
no-critical-diagnostics | 21 |
ref-equivalent | 11 |
display-list-contains | 3 |
dom-nodes-range | 1 |
min-nodes | 1 |
selector-match | 3 |
This local fixture set is release-blocking and must remain 100 % green.
The layout category currently includes normal-flow, inline-flow, positioned,
flex row/column, grid, overflow coordinate/paint, and fragment-backed text paint
fixtures with layout-box and display-list-contains assertions. The paint
category includes three local ref-equivalent smoke fixtures against the stable
display-list render projection. The harness now reports overall, per-category,
and local/imported source×category pass rates. Its adapter now creates production
BrowserCore contexts, so fixture snapshots/selectors/styles/evaluation/reference
rendering/pixel capture share typed document/runtime generations and persistent
per-context V8 realms rather than constructing harness-owned Pages or runtimes.
Imported upstream WPT
layout/paint coverage is still tracked separately below. Imported selector smoke
has reached the
50-fixture target,
including focused :has() child/descendant/adjacent-sibling/general-sibling and
selector-list smoke plus attribute operators/flags, class/id matching,
structural and typed structural pseudos, link/form/read-write/autofill/defined
pseudos, negation/list pseudos, grouping de-duplication, and document-order
coverage. Local CSS computed-style coverage now includes the Milestone 1
advanced cascade seam: @media, @supports, @layer, inherited custom
properties, var() fallback, and CSS-wide keyword projection through Page.
Imported css-cascade/css-values smoke has reached the
50-fixture target, including specificity/source order, important and inline
precedence, combinator/attribute-operator matching, structural/link/form pseudo
selectors in cascade, :is()/:where()/:not()/:has() selectors,
selector-list splitting, custom properties, declaration recovery, comments,
math/color/gradient/transform/shorthand values, and quoted/nested/function
declaration values. Imported DOM-core smoke has reached the 50-fixture target,
including query/getElementById/querySelectorAll, document/root/body access,
tag/class/wildcard collections, attributes, reflected host properties, text
aggregation, parent/child/sibling traversal, null relation checks, document URL,
forms collection length, matches(), logical selectors, and :has()-backed
matching. Imported forms smoke has reached the 25-fixture target across
reflected/default form/control properties, labels, radio/checkbox/select states,
textarea text, form tree traversal, repeated names, and :has() form selectors.
Local Phase 6 fixtures now also assert runtime/Page-backed js-eval projections for
getComputedStyle(), document/navigator state (documentURI/baseURI,
focus, and active-element shape included), op-backed in-memory Web Storage
mutation with key/value validation and quota errors,
Event/CustomEvent/dispatchEvent() smoke, the pinned
focusout → focusin → blur → focus transition with relatedTarget,
Page-owned active-element restore, CSSOM CSS.supports() /
document.styleSheets plus CSSStyleRule / CSSStyleDeclaration read-only shape,
viewport/window state, DOMRect geometry via getBoundingClientRect() /
getClientRects(), client/offset/scroll metrics, getBoxQuads(), Range
rectangles, Geometry
Interfaces value constructors (DOMPoint/DOMRect/DOMQuad/DOMMatrix), DOM
ancestry/core-node projections (closest(), nodeName/nodeType,
ownerDocument), anchor URL decomposition/reflection, DOMParser, atob/btoa, classList/
relList/sandbox, dataset, ValidityState/checkValidity(), FormData
entry-list and iterator projection plus runtime/CDP form submission by page node
id with successful submitter overrides, runtime form reset/default-state restore,
meta/content reflection,
innerHTML/outerHTML,
URL.canParse(), data: URL parsing, new URL()/URLSearchParams constructor and iterator seams,
TextEncoder/TextDecoder (encodeInto and constructor options included),
<img>.currentSrc plus image alt/dimension/loading/decode reflection, inert
media element state (HTMLMediaElement/audio/video constants included),
resource element reflection (link/style/script/source), single-range
Range/Selection state with Page-owned element-boundary restore, direction,
point queries, same-container clone/extract/delete/insert/surround operations,
and selectionchange delivery, read-only history accessors,
details/dialog open-state reflection,
miscellaneous HTML reflected attributes for lists, quotes, embedded content, and
table cells,
progress/meter numeric state,
inert Canvas 2D context smoke,
form-associated reflected attributes and editing helpers,
read-only table collections/indexes,
HTMLElement interaction/global reflected attributes,
text track / track-element state,
inert OffscreenCanvas/ImageData/ImageBitmap/Path2D APIs,
minimal ShadowRoot/DocumentFragment smoke,
template content and slot assignment shape,
DOM construction/serialization helpers,
structuredClone, CDP Runtime.awaitPromise over stored promise handles,
MutationObserver lifecycle, TreeWalker/NodeIterator traversal, Headers
iteration, Blob/File, read-only Request/Response state with forbidden
header filtering, Response.error() / Response.redirect() / Response.json(),
op-backed fetch() HTTP(S) status/header/body reads plus URL-policy/private-host
rejection with CDP Network.loadingFailed diagnostics, credential-correct CORS,
bounded origin/target/credentials-partitioned preflight caching (including
effective CDP extra headers), strongest-algorithm Request SRI verification before
exposure/cache insertion, AbortSignal,
URLPattern, CDP lifecycle opt-in (init/commit/DOMContentLoaded/load),
Performance timing shape, matchMedia(), Permissions API query state,
Notification permission state, and StorageManager estimate/persisted state backed
by profile/storage records before the remaining host-object swap; Encoding API constructors,
Web Storage mutation, focused fetch() success/blocking checks, sequential
global/storage persistence across Runtime.evaluate, focused document/Element
snapshot host-object evals, and read-only DOMTokenList/DOMStringMap property
reads are also exercised directly through the persistent deno_core runtime seam.
Runtime platform smoke now additionally covers secure crypto.getRandomValues() /
randomUUID(), async Clipboard text and ClipboardItem shape, MessageEvent,
MessageChannel, BroadcastChannel, first-callback IntersectionObserver /
ResizeObserver geometry, and a fail-closed WebSocket close path. Imported
smoke fixtures now also seed
block/inline/position layout, flexbox, grid, and display-list ref-equivalent
paint; imported layout smoke covers auto margins, border-box sizing, inline
flow, flex reverse/gaps, and grid minmax()/fractional row/gap cases. Imported
paint smoke now covers currentcolor, overflow clipping, positioned boxes,
flex/grid backgrounds, and nested background/text display-list equivalence.
Current automation smoke baseline
The external Playwright smoke covers connect/target/page/runtime/DOM/input/
network/dialog/screenshot/history/content/script/style/binding paths plus
browser-context permission grant/reset, bounded Chromium JSON tracing through
CDP IO streams, idle stop-loading behavior, and stable protocol errors. CDP
permission overrides are exact-origin or wildcard scoped and do not mutate
persisted user decisions. Trace records contain method/timing/session/success
metadata only, not expressions, request headers, form values, or page text.
CDP targets now map to independent BrowserCore contexts/runtimes and share only
profile-scoped state. BrowserCore source navigation is asynchronous,
generation-checked, and directly cancellable; deterministic stop/supersede tests
force late completions and prove no stale document/cookie commit. CDP currently
waits synchronously for each matching terminal event, so active Page.stopLoading
cannot yet race a Page.navigate on the same protocol connection. There is still
no HTTP download manager or Playwright context-tracing archive implementation.
Current desktop shell smoke baseline
The GTK/libadwaita shell is not a WPT surface, but alpha daily-smoke builds now
route one app-level worker and all tab ids through BrowserCore. Profile-session
load/save and explicit clear-data selections use browser commands; empty or
unavailable profiles fall back to the configured start page and records remain
bounded by the profile store. Native gtk-shell checks may be host-package
blocked; use the supported Flatpak build path for GUI verification.
WPT target profile
Full upstream WPT is too broad to summarize honestly with one percentage at
v1.0. The release contract is a curated, imported WPT profile with measured
pass counts by category. Small, Vixen-minimized upstream-derived smoke fixtures
may live beside local fixtures and remain recorded in fixtures/manifest.json.
Larger upstream slices should use committed WPT profile JSON plus an ignored,
pinned upstream checkout (for example .tmp/wpt/) so review diffs contain only
the selected paths/checks/provenance, not vendored WPT source files. Both paths
feed the same vixen-wpt check types and reporting.
| Area | v1.0 target | Expected achievability | Notes |
|---|---|---|---|
| HTML parsing/tree construction | Broad smoke subset green | High | html5ever carries parser behavior; Vixen must preserve node ids/tree shape. |
| Selectors | Modern selector subset green | High | Backed by Stylo/selectors; include combinators, attributes, :is, :where, :has, form/link pseudos. |
| CSS cascade/computed values | Author stylesheet + inline subset green | High after full Stylo slice | Compact cascade is temporary; full Stylo should unlock wider WPT coverage. |
| CSS layout: block/inline | v1 visual/ref subset green | Medium | Vixen-owned layout; start with normal flow, margin/border/padding, inline line boxes. |
| CSS layout: flex/grid | Useful common-case subset green | Medium | Pure helpers exist; full WPT edge coverage is post-v1. |
| CSS layout: tables/floats/fragmentation | Not v1 release-blocking | Low for v1 | Document as unsupported/partial until implemented. |
| DOM Core | Traversal, attributes, token lists, ranges, mutation observer subset green | Medium | Vixen-owned Web APIs over deno_core host extensions after the ADR-014 migration. |
| Events/forms/history/storage | Selected behavioral subset green | Medium | Gate by fixtures from SPEC invariants and imported WPT cases. |
| JS language | Use V8/deno_core language coverage, not WPT percentage | High for language | Web API exposure remains Vixen-owned and separately gated. |
| Paint/ref tests | Display-list + WebRender visual subset green | Medium | One paint path; correctness depends on layout fragments and WebRender mapping. |
| Media/WebGPU/WebRTC/service workers | Out of scope for v1 | Not targeted | Deferred by ADRs / acceptance post-v1 scope. |
Release-blocking WPT goals
For v1.0, Vixen should be able to claim:
- 100 % pass on local
fixtures/manifest.json. - Green imported WPT smoke profile for parser, selectors, cascade, DOM core, forms, and the v1 layout subset.
- Measured pass counts published here for every imported category.
- No global full-WPT percentage claim until the harness imports and runs a representative upstream WPT checkout.
Initial import targets before v1.0:
| Imported WPT area | Minimum useful target |
|---|---|
| selectors/css-scoping/css-nesting selector behavior | 50 fixtures |
| css-cascade / css-values computed-value behavior | 50 fixtures |
| dom/nodes + traversal + ranges | 50 fixtures |
| html/semantics/forms basics | 25 fixtures |
| css/css-display + css-box + css-position normal-flow layout | 40 fixtures |
| css-flexbox common cases | 25 fixtures |
| css-grid common cases | 25 fixtures |
| paint/ref-equivalent smoke | 20 fixtures |
These are minimum profile sizes, not final compatibility claims. The measured
pass table below must be filled from vixen-wpt output as the fixtures land.
| Imported WPT area | Fixtures run | Checks run | Passed | Pass rate | Notes |
|---|---|---|---|---|---|
| selectors | 50 | 232 | 232 | 100.0% | Target reached: :has() child/descendant/adjacent-sibling/general-sibling and selector-list smoke, attribute operators/flags, class/id matching, structural and typed structural pseudos, link/form/read-write/autofill/defined pseudos, negation/list pseudos, grouping de-duplication, and document-order coverage. |
| css-cascade/css-values | 50 | 250 | 250 | 100.0% | Target reached: specificity/source order, importance/inline, combinator/attribute operator matching, structural/link/form pseudo cascade, functional pseudo specificity, selector-list splitting, custom properties, declaration recovery, comments, math/color/gradient/transform/shorthand values, and quoted/nested/function declaration values. |
| dom-core | 50 | 250 | 250 | 100.0% | Target reached: query/getElementById/querySelectorAll, document/root/body access, tag/class/wildcard collections, attributes, reflected host properties, text aggregation, parent/child/sibling traversal, null relation checks, document URL, forms collection length, matches(), logical selectors, and :has()-backed matching. |
| forms | 25 | 134 | 134 | 100.0% | Required/optional/disabled/checked controls, labels/buttons/form attributes, reflected/default input/form/select/option properties, textarea text, tree traversal, repeated names, and :has() form selectors. |
| layout block/inline/position | 6 | 30 | 30 | 100.0% | Block flow, margin/padding/border, auto margins, border-box sizing, inline flow, and relative/absolute positioned smoke. |
| flexbox | 5 | 25 | 25 | 100.0% | Row/column grow-basis, gap/padding, and reverse-axis smoke. |
| grid | 5 | 26 | 26 | 100.0% | Fixed, fractional, minmax(), row/column gap, and fixed-height fractional-row smoke. |
| paint/ref-equivalent | 8 | 24 | 24 | 100.0% | Display-list reference-equivalent background/text, currentcolor, overflow clipping, positioned, flex/grid, and nested-background smoke. |
Known v1.0 layout gaps
Expected unsupported or partial areas unless promoted by WPT/real-site evidence:
- table layout
- floats and float avoidance
- full vertical writing modes / vertical text shaping
- page fragmentation / pagination / print layout
- advanced intrinsic sizing cycles (
min-content/max-contentedge cases) - complete absolute/fixed/sticky interaction matrix
- full SVG layout integration
Each gap should fail closed where possible, emit diagnostics when visible to users/tests, and receive a WPT fixture before being marked supported.
Vixen architecture
This document describes Vixen's implemented subsystem boundaries, the target
browser ownership model, data flows, and migration constraints. Product scope is
defined in PROJECT_DIRECTION.md; delivery order is in
ROADMAP.md; accepted tradeoffs are in
DECISIONS.md.
Status language
Architecture documents can accidentally make planned integration sound landed. This document uses three explicit states:
- Implemented: present in production code and exercised by a checked-in path.
- Transitional: present, but ownership or duplication must change before alpha.
- Target: the required end state; not a claim that it exists today.
Vixen now has a production BrowserCore behind the browser-scoped vixen-api
command/event seam. It owns one profile Store/network/cookie service, one
DOM/V8 owner thread, typed context/document/runtime/navigation generations,
asynchronous source loading, and bounded ordered events. Shell, headless, CDP,
and WPT are adapters over that owner. This completes the A1 ownership migration,
not the broader alpha compatibility exit gate.
Crates and responsibilities
| Crate | Implemented responsibility | Target boundary |
|---|---|---|
vixen-api | Browser-scoped typed lifecycle ids, command/event/error/handle contracts, transitional Engine/delegate/inspector traits, diagnostics, profile configuration, graphics-context trait, DTOs | GUI/protocol-neutral command, event, id, snapshot, and factory contracts; no implementation dependencies |
vixen-net | HTTP client primitives and URL/cookie/CSP/CORS/referrer/mixed-content/permissions/security policy | Pure network and policy leaf; no DOM, runtime, GTK, or profile orchestration |
vixen-store | Bounded redb profile tables and clear-data operations | Persistence leaf using opaque partition/id keys; no network or UI policy |
vixen-engine | Initial production BrowserCore/thread/profile/context lifecycle, HTML, DOM/Page, Stylo integration, V8 host runtime, forms/history, Vixen layout, display list, WebRender integration | Sole owner of browser/profile/context/document/navigation/resource lifecycle |
vixen-shell | Relm4/libadwaita chrome, GLArea surface, one app-level engine worker, BrowserCore context/session routing | Thin GUI adapter and host-service provider; no independent loader/history/profile model |
vixen-headless | BrowserCore-backed CLI, CDP target/session adapter, interaction adapter, EGL surfaceless surface | Thin CLI/CDP adapter and composition root over the browser core |
vixen-wpt | Fixture/profile manifest, runner, reports, checks, visual evidence | Engine-consumer test adapter; no engine internals or alternate semantics |
The thin root vixen binary becomes the GUI composition root; today it only
delegates to vixen-shell. data/ and build-aux/ contain application metadata
and Flatpak packaging; fixtures/ contains the hermetic compatibility suite and
external-profile descriptors.
Dependency direction
Stable target
GUI root ───────┬──► vixen-shell ──────────────► vixen-api
└──► vixen-engine ─────────────► vixen-api
├──────────────────────► vixen-net
└──────────────────────► vixen-store
vixen-headless ───────► vixen-api + vixen-engine
(CLI/CDP composition root; dev-dep on vixen-wpt)
vixen-wpt ────────────► vixen-api
Rules:
vixen-api,vixen-net, andvixen-storeare leaves with no dependencies on other Vixen implementation crates.vixen-wptmay depend only onvixen-apiamong Vixen crates.vixen-engineis the only crate that combines network, persistence, DOM, runtime, layout, and paint behavior.- Composition roots may construct
vixen-engine, but adapters use its browser core rather than directly combiningPage,Network,Store, orJsRuntime. - GTK/Relm4 types stay in
vixen-shell; EGL/CLI/CDP types stay invixen-headless; neither leaks into engine state.
Current migration status
vixen-shelldepends only onvixen-apiandvixen-engineamong Vixen crates. One app-level worker owns oneShellBrowser; tabs retain typed ids and immutable presentation snapshots only.vixen-headlessdepends only onvixen-apiandvixen-enginein production. CLI and CDP targets route through BrowserCore and do not ownPage,JsRuntime, cookies, network, or session history.vixen-api::Engineremains a transitional tab-shaped trait with only a test implementation. Production paths use the browser-scopedBrowserHandleseam.
just gate-architecture now enforces these frontend boundaries in addition to
the stable leaf-crate rules. Remaining migration debt is inside the document
implementation: compatibility projections and synchronous parser/script work
must converge on the live document lifecycle.
The committed/external WPT adapter is no longer an exception: it creates
BrowserCore contexts and uses generation-checked snapshot, selector, style,
diagnostic, evaluation, display-list, reference-render, and paint-snapshot
queries. The 269-fixture manifest therefore exercises the production owner while
vixen-wpt itself remains engine-independent.
The headless CLI --eval, screenshot, selector, textual DOM/layout/paint
projections, interaction summaries, and URL-only paths also create one
ephemeral-profile BrowserCore context and wait for the matching typed navigation
terminal event. Evaluation, inspection, hit testing, focus/form projections, and
paint snapshots are generation checked; EGL/PNG and JSON formatting remain
adapter-owned presentation work.
CDP maps every target to a BrowserCore context, keeps only bounded protocol presentation/session/remote-handle state, and retains events by target while waiting. The GTK shell uses the same context/history/paint and profile-session commands through one app-level worker. Host-runnable multi-context tests cover both adapters; the Flatpak build remains the supported GTK SDK proof.
Authoritative ownership model (target)
BrowserCore (one per open profile)
├── ProfileServices
│ ├── Store / schema / bounded writes / clear-data coordinator
│ ├── Network client, cookie jar, cache, HSTS, proxy/cert configuration
│ ├── Permission decisions and prompt broker
│ ├── Download manager
│ └── Linux host services (paths, fonts, portals, GPU diagnostics)
├── BrowsingContextRegistry
│ └── BrowsingContext (one per top-level tab; frames form a child tree)
│ ├── SessionHistory
│ ├── NavigationController + active NavigationId/cancellation
│ ├── active DocumentState
│ │ ├── DOM + style data + invalidation
│ │ ├── JsRuntime realms/resources/event loop
│ │ ├── layout tree + scroll/hit-test/selection state
│ │ └── display list + renderer-facing state
│ └── viewport, input, dialog, and context-scoped storage state
└── EventHub / diagnostics / inspector routing
Ownership invariants
- A profile is opened once by
BrowserCore. Cookies, cache, localStorage, permissions, HSTS, download history, and durable settings are profile-owned. - Session history, sessionStorage, viewport/input, active navigation, runtime realms, and document state are browsing-context owned.
- DOM, style, layout, paint, and runtime-visible page state identify the same
committed
DocumentId. A navigation cannot partially replace one layer. - Every asynchronous result carries the ids/generation it was created for. Results for a closed context, cancelled navigation, or replaced document are discarded before mutation or success notification.
- Frontends own presentation and transport only. The shell may own widgets and CDP may own sockets/session routing, but neither owns browser truth.
Stable ids should distinguish at least profile, context/tab, frame, navigation, document, request, runtime context, remote object, and download. Use typed ids in Rust even when protocol adapters serialize strings or integers.
Threading and execution
deno_core::JsRuntime is !Send + !Sync, and the current DOM is Rc-backed.
Moving individual pages among arbitrary worker threads would add synchronization
without solving lifecycle ownership.
The execution model is one browser-core owner thread per open profile/process, plus bounded external workers for sendable I/O and host work:
- all DOM, V8, history, navigation-commit, style/layout invalidation, and context-registry mutation runs there;
- network and blocking host operations may run externally, but return typed messages carrying context/navigation/request generations;
- the GTK main thread owns widgets and GLArea callback integration;
- CDP sockets and CLI orchestration may use Tokio tasks, but dispatch browser commands to the core and consume ordered events;
- renderer interaction observes document/display-list generations and cannot commit browser state from a stale frame.
The implemented core confines every Page, V8 isolate, history mutation,
document commit, and context-registry mutation to its named owner thread.
rusty_v8 enters isolates for their lifetime, so context/runtime generations are
retained in a bounded 512-slot arena and destroyed in reverse construction order;
commands temporarily enter older isolates through one localized V8 boundary.
Main-document source reads run on a bounded two-worker Tokio runtime. Each task owns only sendable network/input data and an isolated cookie snapshot; completion returns a typed context/navigation message and a cookie delta. Stop, supersede, context close, and shutdown abort the task and invalidate its generation. The owner checks the generation before applying cookies, parsing, writing profile history, replacing the document/runtime, or emitting success. Parsing, runtime construction, and page-script/resource execution are still synchronous owner- thread work and need later cooperative cancellation/checkpoints.
ADR-010's Relm4 component model remains useful, but its one-engine-worker-per-tab ownership is superseded by ADR-017. The shell should have one browser adapter (or factory-injected browser handle), not one independent engine state machine per tab.
Command and event seam
The implemented dependency-free BrowserHandle, BrowserCommand, and
BrowserEvent contracts establish typed routing for context/navigation/document/
request/runtime/download generations. The current Engine trait remains a
transitional shell-facing API. It is too
tab-shaped to own a shared profile and too callback-shaped to represent multiple
concurrent navigations safely. Evolve it or replace it with a browser-scoped seam
whose concepts are:
- Commands: create/close/activate context; navigate/reload/stop/traverse; evaluate; dispatch input; query/snapshot; set viewport/emulation; answer a permission/dialog; start/cancel a download; clear profile data.
- Events: context/document created/destroyed; navigation requested/started/ redirected/committed/cancelled/failed; DOMContentLoaded/load; URL/title/history/ progress changed; request/response/failure; console/exception; dialog/ permission/download; invalidation/frame-ready; diagnostic/profile-write error.
- Queries/snapshots: explicitly versioned, bounded views. Mutable behavior remains commands, not shared references into engine internals.
Every command and event names the relevant context and generation. Ordering is defined on the engine thread; adapters may translate but not reorder lifecycle within a context. Stable diagnostics and protocol errors are product contracts.
Do not add a generic engine-selection abstraction. Vixen still has one engine and one JS runtime. The seam isolates product frontends and thread ownership, not alternate implementations.
Navigation and document commit
Target main-document flow:
frontend/page intent
→ BrowserCommand::Navigate(context, intent)
→ assign NavigationId; cancel/supersede prior provisional work
→ normalize URL + navigation/sandbox/permission policy
→ profile loader: HSTS/cookies/cache/referrer/request metadata
→ network request and redirect loop (policy on every hop)
→ response security checks and content classification
→ provisional DocumentState
→ atomic commit: URL/origin/history/document/runtime generation
→ parse + parser scripts + discovered subresources
→ style/layout/display-list updates
→ DOMContentLoaded → load → settled diagnostics
Before commit, failure normally preserves the current document. After commit, failure belongs to the new document/error-page lifecycle. Redirects keep one navigation lineage but distinct request ids. Same-document history changes keep the document id and update URL/history/scroll state through the same controller.
Implemented stop() invalidates the active generation and aborts source
transport/body reads. Forced late completions are rejected before cookie,
profile, history, document, runtime, or event mutation. The target extends the
same cancellation through parser/resource/runtime jobs and pending lifecycle
work; those owner-thread phases are not yet cooperatively interruptible.
Document, runtime, and Web APIs
Page is the implemented facade over parsed DOM, computed/style data, focused
layout, display-list, diagnostics, form/history state, and runtime snapshots. It
becomes the document-state implementation behind BrowserCore; it is not itself
a profile/browser lifecycle coordinator.
JS uses deno_core directly:
- generated WebIDL describes interface/prototype shape;
- pure immutable/value APIs may be JS bootstrap code;
- stateful page/network/storage/security APIs cross narrow Rust ops/resources;
- validation and permission checks occur at the JS → Rust boundary and again at lower trust boundaries where necessary;
- resources carry document/context generations so navigation teardown revokes stale handles;
- parser scripts, modules, tasks, and microtasks join the document lifecycle.
Page::evaluate_dom_expression and snapshot host objects are compatibility
bridges. Do not widen them. Move supported behavior to the live DOM/runtime and
delete each replaced projection so there is one answer for GUI, script, CDP, and
WPT.
API surface alone is not support. Inert media/canvas/web-component objects may
help automation probes, but COMPAT.md must classify them as shape-only until
their observable subsystem behavior exists.
See RUNTIME_WEB_PLATFORM.md for host-module rules.
Style, layout, paint, and inspection
Implemented rendering path:
html5ever DOM
→ Stylo-compatible document/selector and computed-style integration
→ Vixen layout tree and focused formatting algorithms
→ layout fragments
→ one Vixen display list
→ one WebRender paint path
→ GTK GLArea or headless EGL surfaceless GlContext
The path is intentionally singular, but its current formatting and text metrics are narrow. The target keeps the same ownership shape while adding full Stylo computed values, font discovery/shaping/fallback, images/replaced elements, common formatting contexts, scroll/hit-test state, compositing, animation, and incremental invalidation.
Rules:
- DOM/style mutation marks explicit dirty state; layout and paint consume it by document generation.
- No post-pass geometry fixup may hide incorrect authoritative layout data.
- GUI, headless screenshot, visual fixtures, hit testing, geometry APIs, and CDP inspect the same fragments/display list.
- Inspection may request a bounded style/layout update or return a stable error. It must tolerate stale state and cannot maintain a second DOM/layout tree.
GlContextabstracts host surface binding only. There is no second paint backend or CPU renderer.
Vixen-owned layout follows ADR-013: data-oriented arenas, stable ids, explicit invalidation, cached intrinsic values, and formatting-context passes, with Ladybird as an architecture reference and WPT/ref tests as behavioral evidence.
Resource loading, network, and policy
vixen-net owns pure transport/policy primitives. BrowserCore's profile loader
combines them with document and profile context. One loader must serve main
documents, scripts, styles, images, fonts, fetch/XHR, frames, and downloads.
For every request:
- derive source origin/partition, destination, credentials, referrer, CSP, sandbox, and permission context from authoritative state;
- validate URL/method/headers/body and private-network policy;
- apply HSTS, cookies, cache, redirect, mixed-content, CORS, and request metadata policy in a defined order;
- stream transport with request id, limits, progress, and cancellation;
- apply response CORS/CORP/COEP/nosniff/integrity/content policy;
- only then expose, execute, decode, persist, cache, or create a download.
Policy failure, transport/TLS failure, protocol failure, decode failure, unsupported behavior, and cancellation have distinct stable diagnostics. CDP and shell translate the same underlying event; they do not infer failures from frontend-specific state.
Profile and storage
One Store is opened per profile. The implemented schema includes bounded
records for:
profile.redb
cookies
fetch-cache
history
session
web-storage
downloads
permissions
hsts
downloads/
reports/
The filename and XDG/app-ID paths are selected by the composition/host service;
partition keys are produced by the engine/network layer and remain opaque to
vixen-store.
Before adding a durable table, define:
- engine owner and partition key;
- record and total-table limits plus eviction behavior;
- transaction/failure/recovery semantics;
- clear-data category and session-restore interaction;
- private/ephemeral profile behavior; and
- observability without leaking sensitive content.
Downloads, favicons/icons, settings, credentials/autofill, and future IndexedDB/ Cache Storage require purpose-built bounded schemas, not generic JSON dumping.
Linux host services
Modern-Linux compatibility is an engine input, not shell trivia. Small host services provide:
- certificate roots and custom CA configuration;
- proxy/environment policy;
- fontconfig discovery, fallback, and web-font cache paths;
- XDG data/cache/config/download directories scoped by app id;
- Flatpak portals for file access, downloads, permissions, and external opens;
- GL/EGL/driver capability diagnostics; and
- safe file/download destination validation.
The current GTK-free vixen-shell::profile path/session helpers are useful but
transitional. Path discovery may remain platform code; profile state ownership
moves into BrowserCore. All host failures produce structured diagnostics usable
by GUI error pages, headless output, CDP, and smoke reports.
Trust boundaries and limits
Web content and protocol clients are untrusted. Validate as close as possible to entry, then preserve typed validated data internally.
| Boundary | Owner | Required behavior |
|---|---|---|
| CLI/CDP/GUI command → core | adapter + vixen-api DTO validation | Validate ids/options/sizes; reject unknown/stale targets with stable errors |
| navigation/resource request | browser loader + vixen-net | URL/private-network/header/body/policy checks on initial request and redirects |
| HTTP response → page/profile | browser loader | CORS/security/integrity/content checks before exposure, execution, decode, cache, or persistence |
| JS → Rust op/resource | runtime host module | WebIDL conversion, size/permission/origin checks, document-generation validation |
| DOM mutation → render state | document lifecycle | Node/document validity, bounded growth, explicit invalidation, no stale commit |
| profile write/read | profile service + vixen-store | Partitioned normalized records, bounds, transactional failure diagnostics |
| file/portal/download | Linux host service + download manager | Approved roots/handles, safe names, no ambient arbitrary write/open |
| inspector/snapshot | engine inspector | Bounded output; explicit update or stable stale-state error; no alternate model |
Content-controlled queues and data need explicit caps: redirects, headers/body, DOM nodes/depth, parser/script work, runtime handles, events/microtasks, decoded images/fonts/media, cache/profile records, downloads, traces, console/diagnostic buffers, snapshots, and protocol output. On limit breach, fail deterministically without exposing partially accepted unsafe state.
Diagnostics and observability
Observability is a product contract, not debug residue:
- lifecycle events name context/navigation/document/request ids;
- stable error codes separate policy, transport, protocol, unsupported, cancellation, stale-state, resource-limit, renderer/runtime reset, and profile failure;
- traces and logs are bounded and privacy-minimal by default;
- shell, headless, CDP, WPT, and real-site reports translate the same engine events;
- no adapter may require page text, JS expressions, credentials, form values, or full headers in a default trace.
Verification and reduction architecture
Evidence layers share production paths:
- leaf-unit tests for pure policy/data/formatting algorithms;
- engine integration tests for ownership, lifecycle, generations, and profile partitioning;
- committed local fixtures for focused regressions;
- pinned imported WPT profiles with source×category reports;
- GUI/headless visual comparisons and external Playwright/CDP smokes;
- controlled real-site/Linux-host corridor reports; and
- fuzz, audit, performance, memory, size, restart, and recovery gates.
Classify a real-site failure as navigation/network/security, DOM/runtime, style/layout/paint, storage/profile/download, media/accessibility, shell/platform, automation/inspection, or reliability/performance. Reduce it to the lowest layer that reproduces the production path. If it cannot yet be reduced, retain exact commands, platform, artifacts, and classification rather than a vague issue.
Build profile
The release profile remains:
[profile.release]
strip = true
lto = "thin"
codegen-units = 1
panic = "abort"
lto = "fat" is a measurement experiment, not the default. just size-fp
measures the real Flatpak GUI artifact and release headless binary. Hard budgets
must be based on published reproducible baselines for the active
deno_core/V8/GTK/WebRender dependency graph.
Vixen specification
Vixen's contract. What this document captures:
- Vixen-specific surfaces (CLI, error codes, WPT check types, diagnostics shape).
- Vixen-specific configuration of upstream behaviour (URL policy blocklist, cookie defaults, CSP enforcement points).
- Behavioural invariants that must be reproduced exactly because they're easy to get subtly wrong (event dispatch order, paint rules, form-validation edge cases).
What this document deliberately does not capture:
- Restatement of web-platform specs. Vixen delegates spec-heavy behavior where
that improves correctness and size: Stylo/
selectorsfor CSS,html5everfor HTML,deno_core/V8 for JS execution and host packaging, and WebRender for paint (seeDECISIONS.mdADR-001 / ADR-011 / ADR-014). Layout is Vixen-owned Rust code per ADR-013, with Ladybird used as the architecture reference. Behavioural parity is measured by the WPT profile documented indocs/COMPAT.md; if a behaviour isn't called out below, follow the latest stable spec and document deviations indocs/COMPAT.md.
Headless CLI surface
The vixen-headless binary exposes this flag set. Flags and stable
error codes are a public contract — automation depends on them.
vixen-headless --url <URL> [options]
--url <URL> Load a URL (required).
--screenshot <file.png> Save a PNG screenshot.
--viewport <WxH> Viewport size (default 800x600).
--extract-text Print visible text content.
--extract-selector <css> Print JSON snapshots for matching elements.
--eval <js> Execute JS, print result.
--dump-dom Dump the DOM tree.
--dump-layout-tree Dump the Vixen layout tree.
--dump-display-list Dump paint commands.
--dump-lines Dump inline layout lines.
--click-at <X,Y> Dispatch a MouseEvent at coordinates.
--focus <id> Focus an element by id.
--submit-form <id> Submit a form by id.
--paint-stats Print paint statistics.
--incremental Two-frame incremental repaint demo (with --screenshot + --eval).
--cdp Start CDP WebSocket server on 127.0.0.1.
--cdp-port <N> CDP port (default 9222, with --cdp).
--list-fonts List system fonts and exit.
--memory-stats Print memory statistics.
--gpu is removed: every render path uses WebRender against a GPU
context (GLArea for GUI, EGL surfaceless for headless). Headless
without a GPU device fails closed with unsupported.screenshot.
Stable error codes (returned exactly as written):
| Code | When |
|---|---|
unsupported.screenshot | Screenshot requested without offscreen renderer available |
invalid-selector | Malformed --extract-selector input |
CDP methods required at v1.0:
Browser.getVersionTarget.createTarget,Target.attachToTarget,Target.getTargetsPage.enable,Page.navigate,Page.reload,Page.stopLoading,Page.loadEventFired,Page.getFrameTree,Page.getResourceTree,Page.getResourceContent,Page.getLayoutMetrics,Page.getNavigationHistory,Page.navigateToHistoryEntry,Page.resetNavigationHistory,Page.setBypassCSP, andPage.captureScreenshot(PNG)Runtime.enable,Runtime.evaluate,Runtime.awaitPromise,Runtime.getProperties,Runtime.consoleAPICalled, andRuntime.exceptionThrownNetwork.enable, top-levelNetwork.*navigation notifications, and the Playwright network-toggle methods (setCacheDisabled,setBypassServiceWorker,setExtraHTTPHeaders; extra headers apply to runtimefetch()requests, cache-disabled bypasses runtimefetch()cache reads/writes)DOM.getDocument,DOM.querySelector,DOM.querySelectorAll,DOM.describeNode,DOM.resolveNode,DOM.getContentQuads,DOM.getBoxModel,DOM.getAttributes,DOM.getOuterHTML,DOM.setAttributeValue, andDOM.removeAttributePerformance.getMetrics,Security.getSecurityStateInput.dispatchMouseEvent(mouse move/press/release over the current full viewport),Input.dispatchKeyEvent, andInput.insertText
WPT harness — check types
The WPT harness asserts document state against fixture manifests. The committed
fixtures/manifest.json remains the hermetic release-blocking smoke suite.
Larger upstream slices may instead be described by small JSON WPT profiles and
run against an ignored checkout such as .tmp/wpt/ via just wpt-profile fixtures/wpt-profiles/<profile>.json .tmp/wpt. The check types below are the
public contract for fixture/profile authors.
| Check type | Asserts |
|---|---|
title | Document <title> text |
selector-count | Number of elements matching a selector |
selectors-exact | Exact set of element ids matching a selector |
body-contains | Body text contains a substring |
js-eval | Evaluate JS, compare result to expected |
min-nodes | DOM has at least N elements |
no-critical-diagnostics | No critical EngineDiagnostic recorded |
visual-hash | Perceptual hash of rendered screenshot matches expected |
selector-match | Per-element selector match details |
computed-style | Per-element computed style value matches expected |
element-attribute | Element attribute value matches expected |
layout-box | Element border-box (x, y, w, h) matches expected |
display-list-contains | Stable display-list dump contains a substring |
dom-nodes-range | DOM node count is within [min, max] |
ref-equivalent | Rendered page matches a reference HTML fixture |
WPT target profile lives in COMPAT.md. End-to-end CSS/DOM/layout
behavior should move into fixtures when practical; Rust tests cover pure logic
(URL parsing, cookie validation, CSP parsing, layout arithmetic, redb
round-trip) and low-level invariants.
Diagnostics shape
#![allow(unused)] fn main() { pub struct EngineDiagnostic { pub category: EngineDiagnosticCategory, pub code: &'static str, // e.g. "parse-dom.budget" pub message: String, } pub enum EngineDiagnosticCategory { Network, ParseDom, ScriptRuntime, LayoutRender, StorageCache, } }
The shell surfaces diagnostics in the status row; the WPT
no-critical-diagnostics check consumes them. Codes are stable contract.
URL policy
Every network fetch passes through validate_http_url. The blocklist is
Vixen's configuration of what counts as a "public" HTTP target.
#![allow(unused)] fn main() { use std::net::{Ipv4Addr, Ipv6Addr}; use url::{Host, Url}; #[derive(Debug, Clone)] pub enum UrlPolicyError { UnsupportedScheme(String), BlockedHost { host: String }, } pub fn validate_http_url(url: &Url) -> Result<(), UrlPolicyError> { if !matches!(url.scheme(), "http" | "https") { return Err(UrlPolicyError::UnsupportedScheme(url.scheme().to_owned())); } if let Some(host) = url.host() && is_private_host(&host) { return Err(UrlPolicyError::BlockedHost { host: host.to_string() }); } Ok(()) } pub fn is_private_host(host: &Host<&str>) -> bool { match host { Host::Ipv4(ip) => is_private_ipv4(*ip), Host::Ipv6(ip) => is_private_ipv6(*ip), Host::Domain(domain) => { let lower = domain.to_lowercase(); lower == "localhost" || lower == "localhost.localdomain" || lower.ends_with(".local") || lower.ends_with(".internal") || lower.ends_with(".onion") || lower.ends_with(".arpa") || lower.ends_with(".test") || lower.ends_with(".example") || lower.ends_with(".invalid") } } } fn is_private_ipv4(ip: Ipv4Addr) -> bool { ip.is_loopback() || ip.is_private() // 10/8, 172.16/12, 192.168/16 || ip.is_link_local() // 169.254/16 || ip.is_unspecified() // 0.0.0.0 (unspecified only) || ip.is_broadcast() // 255.255.255.255 || ip.is_documentation() // 192.0.2/24, 198.51.100/24, 203.0.113/24 || is_cgnat(ip) // 100.64.0.0/10 } fn is_cgnat(ip: Ipv4Addr) -> bool { let o = ip.octets(); o[0] == 100 && (o[1] & 0xc0) == 0x40 // 100.64.0.0/10 precisely } fn is_private_ipv6(ip: Ipv6Addr) -> bool { ip.is_loopback() // ::1 || ip.is_unspecified() // :: || ip.is_unique_local() // fc00::/7 || (ip.segments()[0] & 0xffc0) == 0xfe80 // link-local fe80::/10 || ip.to_ipv4_mapped().is_some_and(is_private_ipv4) } }
Cookie defaults
Cookies follow RFC 6265 with these Vixen-specific defaults:
- Default
SameSiteisLax(matches modern browsers, not strict RFC 6265 which has no default). - Storage cap: 512 entries per jar. Eviction is FIFO by insertion order (not the RFC's full eviction algorithm). This is a deliberate simplification.
HttpOnlyrejected fromdocument.cookiebut accepted fromSet-CookieHTTP response. This is RFC-correct but called out because it's a frequent bug source.- Outgoing
Cookieheader:SameSite=Laxcookies are sent cross-site only for safe methods (GET/HEAD/OPTIONS).SameSite=Strictcookies are sent only to same-host requests.HttpOnlycookies never appear indocument.cookiereads.
Everything else (domain matching, path matching, secure-gating,
expiry handling, Max-Age semantics) follows RFC 6265 exactly.
CSP enforcement points
CSP is parsed from Content-Security-Policy headers and
<meta http-equiv="Content-Security-Policy">. Enforcement happens at
three boundaries:
- Script execution —
script-src(ordefault-srcfallback). Inline scripts blocked unless'unsafe-inline'or a matching hash/nonce is present. - Fetch —
connect-src,img-src,style-src,font-src,media-src,object-src, etc. URLs matched against source-list. - Plugin content —
<embed>,<object>allowed only ifobject-srcpermits.
Source-list grammar follows the CSP spec exactly ('self', 'none',
'unsafe-inline', 'unsafe-eval', host/scheme sources, nonces,
hashes).
Form validation edge cases
These are pinned down because they're easy to get subtly wrong.
Email format (typeMismatch for type="email"):
- Exactly one
@. - Non-empty local-part.
- Domain contains at least one
..
URL format (typeMismatch for type="url"):
- Valid scheme (letters followed by
:). ://separator after the scheme.- Non-empty host.
Step arithmetic (stepMismatch):
- Step base =
minif present, else the type-specific default base. - Default step per type: number/range = 1; date = 1 day; time = 60 s; week = 1 week; month = 1 month; datetime-local = 60 s.
- Valid when
(value - step_base)is within float tolerance of an integer multiple ofstep. - Date/time values use integer arithmetic on canonical units:
date→ days since epoch,time→ seconds since midnight,week→ weeks since epoch,month→ months since year 0,datetime-local→ epoch seconds.
Everything else in constraint validation (valueMissing,
rangeUnderflow/rangeOverflow, tooLong/tooShort, badInput,
customError, willValidate) follows the HTML5 spec exactly.
Composed event dispatch invariants
Specific ordering invariants that must be reproduced exactly.
Focus transitions (when document._setActiveElement runs):
focusout → focusin → blur → focus
focusoutandfocusinbubble.blurandfocusdo not bubble.
composedPath() walks target → parentNode chain, returning a flat
JS array. Respects shadow DOM boundaries when composed: true on the
event.
Display-list invariants
These are Vixen's paint rules, enforced by the display-list builder before WebRender sees the commands. The same rules apply to every surface (GUI and headless) because there is exactly one paint path.
- z-index stacking — display list sorted negative → zero → positive z-index; viewport background always first; stable sort preserves document order for equal z-index.
- Clip stacking —
overflow: hiddenclips content but not borders (CSS 2.1 § 11.1.1).PushClip/PopClipbracket content, not decorations. - Opacity groups — stack-based multiplication. Parent 0.5 × child
0.5 = 0.25 effective.
opacity == 0early-exit (no draw). - Visibility —
visibility: hiddenandvisibility: collapseskip paint but keep layout space. - Background clip —
border-box(no extra clip);padding-boxandcontent-boxemitPushClip/PopCliparound background paint;textis post-v1.0. - Background attachment —
fixeduses viewport-relative positioning;scrollandlocaluse element-relative. - Background origin — positions the background image rect relative to border-box / padding-box / content-box per the property value.
- Empty clip skip — any draw command with an empty pre-intersected clip is dropped before reaching WebRender.
Runtime Web Platform strategy
Vixen exposes the browser runtime through deno_core/V8. This document defines
where Web API code should live so the runtime stays fast, small, and spec-driven.
Fixed constraints
deno_core/V8 is the only JS runtime target.- Do not add a generic JS-engine abstraction.
- Generated WebIDL substrate stays in
crates/vixen-engine/src/script/webidl.rs. - Host-family extensions adopt generated interfaces with
webidl.adoptInterface(...). - DOM, CSS, layout, network policy, and storage state remain Rust source of truth.
- Security-sensitive behavior validates near the host boundary and fails closed.
Fidelity ladder
Use this ladder when adding or reviewing an API:
- Shape — constructor/prototype exists because WebIDL requires it.
- Pure value behavior — JS-only implementation is acceptable when it has no privileged state, I/O, persistence, origin policy, or layout dependency.
- Rust op/resource backing — required for page DOM, CSSOM, layout, network, storage, history, permissions, timers, and anything security-sensitive.
- Spec/WPT correctness — useful subset covered by local or imported WPT fixtures; this is the target for committed behavior.
Shape-only APIs are temporary compatibility scaffolding. Do not keep widening shape if the MVP needs deeper behavior in an already-exposed family.
JS bootstrap vs Rust ops
Choose the fastest/smallest correct implementation:
- Keep pure value objects in JS bootstrap when doing so avoids Rust/V8 glue and does not duplicate a Rust source of truth. Examples: simple event objects, geometry value wrappers, iterator ergonomics, small serialization helpers.
- Use Rust ops/resources when behavior touches parsed page state, layout, CSS cascade, network, storage, origin/security policy, long-lived handles, or mutable browser state.
- Avoid two authoritative paths. Transitional
Page::evaluate_dom_expressionprojections must be retired as equivalentdeno_corehost objects land.
Lessons from the first host-object migrations
- One eval path beats clever fallbacks. Headless
--eval, CDPRuntime.evaluate, and WPTjs-evalshould all try the page runtime host first. Legacy Page string projections are compatibility debt, not a second product API. - Route only after behavior exists. Moving a string expression to the runtime
whitelist is safe only when the generated WebIDL placeholder has been replaced
by an implementation. If a routed expression would hit
unsupportedMember, add the host behavior first or leave it on the legacy path temporarily. - Small vertical slices are safer than broad shape. A narrow family such as document metadata, collections, or form reflections should include the op/data source, JS bootstrap member, headless routing, and CDP/WPT-visible proof in the same change.
- Rust remains authoritative for browser state. JS bootstrap may cache and compose objects inside one realm, but page mutations, layout geometry, navigation actions, storage, cookies, and fetch policy must commit back through Rust-backed ops/resources.
- Fail-closed errors are part of compatibility. Unsupported selectors, bad storage keys, private-network fetches, malformed host operations, and missing elements should produce deterministic errors instead of silently widening the smoke surface.
Legacy projection retirement rules
- Do not add new cases to
Page::evaluate_dom_expressionunless the change is a short-lived compatibility guard for deleting a larger legacy branch. - New API families start in a
script::<family>extension or in a JS-only value bootstrap adopted onto generated WebIDL prototypes. - A legacy expression may be deleted when the equivalent runtime path is covered
by a focused
vixen-engineruntime test and one user-visible seam (--eval, CDP, or WPT fixture). - Keep the fallback easy to audit: prefer removing whole helper families over growing more pattern-matching branches.
Current direction
The broad runtime surface is useful for CDP/headless compatibility, but the next runtime work should deepen correctness and remove duplicate authority:
- move backend-backed APIs from stubs/smoke shape to Rust-backed behavior,
- delete legacy Page projections as equivalent host objects land,
- import focused WPT cases for each widened API family,
- keep generated WebIDL prototype inheritance intact,
- keep CDP and headless
--evalconsuming the same runtime path.
Required proof for a host-family change
Each non-trivial host-family change should include:
- a focused runtime test in
vixen-engineorvixen-headless, - one user-visible seam check when applicable (
--eval, CDP, or WPT fixture), - a note in
COMPAT.mdonly when support level or known gaps materially change, - green
just gate-phase6before push.
Decision records
Architecture decisions for Vixen, recorded ADR-style. Each entry carries context, the decision, the alternatives considered, and the consequences.
When a future decision reverses one of these, append a new entry that supersedes it; do not edit the originals.
ADR-001: Build on Firefox-family components, not from scratch
Status: accepted
Context. Vixen's goal is Firefox-grade web compatibility at the smallest credible binary size. Every modern browser engine that reaches that grade — Firefox, Chromium, Servo, WebKit — has person-decades of work in its CSS cascade, layout, JS runtime, and paint pipeline. Building any of these from a blank slate is a multi-year undertaking that guarantees perpetual trailing-edge compatibility.
Decision. Delegate the spec-heavy subsystems to the same Mozilla crates Firefox and Servo use:
| Subsystem | Crate |
|---|---|
| HTML parsing | html5ever |
| CSS cascade | style (Stylo) |
| Selector matching | selectors |
| String interning | string_cache, servo_arc |
| JS engine | deno_core / V8 embedding |
| Layout | Servo layout_2020 crate |
| Paint | webrender + gleam + euclid |
Vixen writes only: the integration glue, the product shell, the networking/security layer, the persistence layer, and the headless tooling.
Alternatives considered.
- Build everything from scratch. Rejected: see Context. Cannot reach Firefox-grade compatibility on any realistic timeline.
- Embed Servo whole via
libservo. Rejected: ~80+ MiB binary, hundreds of transitive deps, unstable embedding API, fights Servo's own networking/storage story. The selected crates get the same compatibility at a fraction of the binary size.
Consequences.
- Vixen's web compatibility is roughly Servo's, which is roughly Firefox's. The compat ceiling is upstream; Vixen's job is the product around it.
- Vixen tracks Servo crate releases. Major upstream API changes
(e.g. Stylo
TElementtrait evolution) require integration updates, typically every 6–12 months. - Binary size grows by the volume of these crates. Runtime packaging and size
are remeasured against the active
deno_core/V8 dependency.
ADR-002: Single-engine project, no fallback engine
Status: accepted
Context. A browser project can support multiple engines behind an abstraction (e.g. WebKit + custom, switchable at compile time or runtime). This doubles the maintenance surface for no end-user win: every shell change must be validated against both engines, dependency isolation requires constant auditing, and only one engine can be the production path anyway.
Decision. Vixen has exactly one engine: the Servo-component-backed engine described in ADR-001. There is no WebKit fallback, no compile-time engine selection, no runtime engine switching.
Alternatives considered.
- WebKitGTK as production + custom engine as preview. Rejected: at that point the project is a WebKitGTK wrapper, not a browser engine project. If WebKitGTK is the goal, use GNOME Web directly.
- Compile-time engine feature flag (one binary, either engine). Rejected: adds dep-leak gates, doubles test matrix, no end-user benefit.
Consequences.
- One engine to test, one engine to ship, one engine to document.
- If the Servo-component path ever proves unworkable, a WebKit adapter
against the
Enginetrait is a small, contained addition — but it is not the v1.0 plan. - Web compatibility is Servo-grade, not WebKit-grade. The two are different; document which one Vixen targets.
ADR-003: GPU-only — no CPU paint path
Status: accepted
Context. A browser paint pipeline can have one paint path (GPU) or two (GPU + a software CPU fallback). A CPU fallback exists traditionally for two reasons: headless screenshot generation without a display, and CI without GPU access.
Decision. Vixen has exactly one paint path: WebRender against a
GPU context. The GUI uses gtk4::GLArea (EGL/GLX). Headless uses EGL
surfaceless (EGL_MESA_platform_surfaceless, with
EGL_KHR_surfaceless + pbuffer as fallback) — same WebRender, no
display server required. There is no tiny-skia, no fontdue
rasterizer, no CPU paint path.
Alternatives considered.
- CPU fallback for headless and CI. Rejected: GPU is a reasonable requirement for a GNOME Flatpak daily driver (every target device has one), EGL surfaceless covers headless/CI without a second renderer to maintain, and removing the CPU path collapses four duplicated painters into one.
- Headless Wayland compositor as the headless path. Rejected as the
default: EGL surfaceless is sufficient for screenshot/CDP pipelines
and adds no runtime deps. A headless Wayland compositor (
westonorcageon a virtual output) is supported as an opt-in fallback when full compositor semantics (pointer focus, XDG toplevel) are needed for CDP interaction tests.
Consequences.
- One renderer to test, one renderer to maintain. Display-list changes ripple to exactly one paint path.
- Headless requires a GPU device (even if virtual). CI must provide one
(Mesa software rasterizer via
llvmpipeis sufficient; most CI runners already have it). - No binary-size cost from
tiny-skia/fontdueand their font raster pipelines. WebRender has its own glyph atlas. - If a truly GPU-less environment is ever required (embedded, server), treat it as a separate v1.x target with its own paint path — not the v1.0 plan.
ADR-004: Drop the multi-process JS sandbox
Status: accepted
Context. A previous design used a process-per-origin JS sandbox (spawned binaries communicating over IPC) for isolation. The embedded JS runtime already provides in-process context isolation, and out-of-process isolation (proper OOPIF) is a separate, much larger effort.
Decision. Single-process engine. JS isolation is via runtime contexts (one
per origin once host bindings are widened). No JsSandbox, no JsSandboxPool,
no process_pool, no ipc module.
Alternatives considered.
- Keep the multi-process sandbox. Rejected: the complexity (IPC framing, pool management, origin-keyed spawn) is not justified by the security payoff for a single-user browser. Site isolation, if ever needed, is a future Servo-style OOPIF effort.
Consequences.
- ~1.5 kLOC less code.
- A single malicious page can still OOM or hang the engine process. This matches every other browser's pre-OOPIF behaviour.
- If genuine site isolation becomes a v1.x goal, design it as OOPIF against the upstream Servo pattern, not as a forked-engine-per-origin approach.
ADR-005: JS runtime packaging size gate
Status: superseded by ADR-014
Context. The old runtime-packaging decision optimized around shared/static
packaging for the previous JS engine. ADR-014 changed the active runtime to
deno_core/V8, so those package-specific details are no longer active guidance.
Decision. Re-measure release binaries with the active deno_core/V8
dependency. Do not carry forward pre-ADR-014 runtime size assumptions as release
promises.
Alternatives considered.
- Keep historical runtime-packaging guidance around for builds. Rejected: it no longer matches the dependency graph and creates false release expectations.
Consequences.
just size-fpis the source of truth for current binary-size budgets.- Distribution guidance should discuss
deno_core/V8 artifacts and cache behavior, not removed runtime dependencies.
ADR-006: One display list, one paint path, two GL surfaces
Status: accepted
Context. A previous design had four parallel paint implementations (CPU compositor, CPU renderer, "GPU renderer" that was actually CPU, text/glyph rasterizer), each duplicating the draw-command dispatch logic. Even a "two backend" design (WebRender + a software fallback) duplicates the dispatch contract.
Decision. Vixen has one DisplayList type defined in vixen-engine
and exactly one paint path (WebRender). There is no PaintBackend
trait — a single-impl trait would be dead abstraction. WebRender
consumes a small GlContext trait (defined in vixen-api, so
vixen-engine stays GTK- and EGL-free) with two implementations:
GlAreaSurface(invixen-shell) — wrapsgtk4::GLArea, used by GUI. GL work runs inside theGLArea::rendersignal, where GTK has already made thegdk::GLContextcurrent.SurfacelessSurface(invixen-headless) — wraps an EGL surfaceless context, used by headless screenshots, CDP, and CI.
Both surfaces produce the same webrender::Renderer; the only
difference is the GlContext implementation behind it.
Alternatives considered.
- One display list, two backends (WebRender + tiny-skia). Rejected per ADR-003: GPU is a reasonable requirement, EGL surfaceless covers the headless case, and avoiding a second backend removes a large maintenance surface.
- A
PaintBackendtrait with one impl. Rejected: a single-impl trait is premature abstraction and contradicts the "one paint path" goal. TheGlContexttrait (two impls) is the only seam that earns its keep. - Generate backends from a shared spec. Rejected: the display list is the spec; sharing it is sufficient.
Consequences.
- Adding a new paint command requires one change in the display-list builder and zero renderer changes (WebRender handles it).
- The display list is a stable internal API; changes ripple predictably.
- The GL↔WebRender seam is the
GlContexttrait invixen-api, not a vixen-engine type — keeping GL details out of engine internals and GTK out of vixen-engine. - CI must provide a GPU device (Mesa
llvmpipeis sufficient).
ADR-007: GNOME-only target at v1.0
Status: accepted
Context. Cross-platform browsers either limit themselves to what the upstream crates already abstract (Servo works on macOS/Windows) or carry large per-platform shims (GTK on macOS via Quartz, etc.). Vixen's product goal is a GNOME browser.
Decision. v1.0 targets Linux + GNOME 50 SDK only. Distribution via Flatpak. Other platforms are best-effort (if Servo crates happen to work, fine; no release blocker).
Alternatives considered.
- Cross-platform from day one. Rejected: dilutes focus. If a macOS or Windows port becomes a goal, design it as a v1.x effort with its own shell crate.
Consequences.
- Shell uses GTK4/libadwaita unconditionally.
- Flatpak manifest is the canonical distribution.
- macOS/Windows users have no v1.0 path. Documented as a non-goal.
ADR-008: WebGPU and media are post-v1.0
Status: accepted
Context. WebGPU and media playback are real features but require
substantial integration work (wgpu surface sharing with WebRender;
GStreamer pipeline + element wiring). Neither is on the critical path
for a useful daily browser.
Decision.
- WebGPU: not in v1.0. Land via
wgpu(which has its own WGSL compiler and pipeline model) in v1.1. - Media (
<audio>,<video>): not in v1.0. Land via GStreamer bindings in v1.1.
Alternatives considered.
- Build WebGPU/media scaffolding now, fill in backends later. Rejected: scaffolding without backends is dead code that rots and misleads users.
Consequences.
- v1.0 cannot run WebGPU demos or play videos. Documented in
docs/COMPAT.md. - v1.1 scope includes both.
ADR-009: Headless render path is EGL surfaceless, not a CPU rasterizer
Status: accepted
Context. With ADR-003 committing to GPU-only, the headless path
needs a GPU context. Two viable approaches: EGL surfaceless (a GPU
context with no display server) or a headless Wayland compositor
(weston/cage on a virtual output). A third option — a CPU
rasterizer — is rejected by ADR-003.
Decision. EGL surfaceless (EGL_MESA_platform_surfaceless, with
EGL_KHR_surfaceless + pbuffer as fallback) is the default headless
render context. WebRender renders into a framebuffer object;
glReadPixels extracts RGBA; png encodes the screenshot. No display
server is needed.
A headless Wayland compositor is supported as an opt-in via
VIXEN_HEADLESS_WAYLAND=1 for tests that need full compositor
semantics (pointer focus, XDG toplevel, real input events).
Alternatives considered.
- Headless Wayland as the only path. Rejected: requires running a compositor (extra runtime dep, slower startup, more moving parts). EGL surfaceless is simpler and covers 95% of headless use cases (screenshots, CDP screenshots, layout dump).
- CPU rasterizer (
tiny-skia). Rejected per ADR-003.
Consequences.
- Headless requires a GPU device even on CI. Mesa's
llvmpipesoftware rasterizer satisfies this; most CI runners already provide it viaLIBGL_ALWAYS_SOFTWARE=1if no hardware GPU is present. - CDP interaction tests that depend on real focus events may need the
Wayland fallback. Document this in
vixen-headless/README.md. - One render path to test across GUI and headless; bugs reproducible in either context.
ADR-010: Idiomatic Relm4 shell
Status: accepted
Context. The shell can be written in three styles against Relm4:
(1) hand-rolled GTK with Relm4 only as an app entry point, (2) Relm4
components for top-level windows but hand-rolled widget management
inside, (3) fully idiomatic Relm4 with factories, workers, components,
and relm4-components reuse.
Decision. Vixen's shell is fully idiomatic Relm4 (style 3):
- Tabs are a
FactoryVecDeque<TabModel>— dynamic add/remove via factory, no hand-rolledVec<TabState>+ ad-hoc signal handlers. - Each tab is a
Componentwith its own model/update/view, owning anEngineWorker. - Engine ownership is via
relm4::Worker— one worker per tab, on a background thread. The worker holds theBox<dyn Engine>and forwardsEngineDelegatecallbacks as messages to the tab component. The shell thread never blocks on engine work. - Address bar, find bar, status row, preferences rows are each a
Component, not hand-rolled widgets. relm4-componentsis the first stop for any standard widget (Alert,SimpleAdwComboBox,ComboRow,Dialog,Toast,LoadingButtons,consts::CSS_CLASSES). Reinventing any of these is a code-review blocker.- Workers for any non-trivial background task: history writes, screenshot encoding, CDP I/O.
Alternatives considered.
- Hand-rolled GTK, Relm4 as entry point only. Rejected: produces larger shell code, harder to reason about, doesn't benefit from upstream component maintenance.
- Partial Relm4 (windows only, hand-rolled internals). Rejected: splits the codebase across two idioms; bugs slip through the seam.
Consequences.
- Shell source is smaller and more uniform.
- Engine ↔ shell message flow is explicit:
EngineWorkeremitsEngineMsg::{UriChanged, TitleChanged, ...}consumed by the tab component'supdate. - Engine callbacks never run on the shell thread directly; no re-entrancy, no GTK mutate-from-background bugs.
- Stronger dependency on Relm4 upstream. Track
relm4releases; breaking changes (rare) require shell-side updates. .tmp/ref/relm4/examples/and.tmp/ref/relm4/relm4-components/are the primary reference for any new shell widget.
ADR-011: Stylo via the crates.io-published stylo crate
Status: accepted
Context. ADR-001 commits to Stylo (style) for the CSS cascade.
When Phase 0–2 landed, style was only available as a Servo git
dependency — a clone of https://github.com/servo/servo plus a
[patch.crates-io] table. That made the build non-reproducible from
crates.io alone and left Phase 3 marked "blocked" in docs/PLAN.md.
Since then, the Stylo team split the engine out of the Servo monorepo
into https://github.com/servo/stylo and now publish it on crates.io
as stylo (lib name style). All
subsystems Vixen needs — cascade, selector matching, rule tree,
computed values — are in that crate.
Decision. Depend on stylo = "0.18" (with the servo feature for
the non-Gecko config) directly. Do not pull a Servo git checkout, do
not patch crates.io, do not vendor the source. Implement
selectors::Element (and, for the cascade, TNode/TElement/
TDocument) over Vixen's html5ever RcDom in
crates/vixen-engine/src/style_dom.rs.
Alternatives considered.
- Hand-roll selector matching on top of
selectorsalone, defer the cascade. Rejected: doubles the selector-matching surface (Vixen's plus Stylo's), and the cascade is the actual reason we wanted Stylo in the first place. - Pin a Servo git revision of
style. Rejected: bigger dep surface (the wholeservorepo at that revision), non-reproducible from crates.io, blocks Phase 3 indefinitely. - Switch CSS engine to
taffyor another standalone cascade. Rejected per ACCEPTANCE.md hard gates (notaffy); also re-introduces the perpetual trailing-edge compatibility ADR-001 rejects.
Consequences.
- Phase 3 unblocks. The selector-matching surface (
vixen-engine:: style_dom) is live; the WPT selector fixtures pass end-to-end. - The crate ships with its lib name as
styleeven though the package isstylo; source usesuse style::…whileCargo.tomlsaysstylo = …. Documented instyle_dom.rsto head off confusion. - Dep budget: ~45 additional crates (icu, euclid, rayon, etc.). The Phase 9 dep-count gate (≤ 220) remains the release-blocking contract; this is the right trade for getting real Firefox-grade cascade.
- Future Stylo releases may shift trait shapes (
TElementetc.). Pinstylo = "0.18"and bump deliberately; track upstreamhttps://github.com/servo/stylo/releases.
ADR-012: Verify and pin the layout source before the full layout adapter
Status: accepted
Context. ADR-001 selected Servo layout_2020 for layout. The refreshed
Firefox/Servo reference pin (46e9f12a8f9b) no longer contains the historical
servo/components/layout_2020/ or servo/components/layout/ trees; the
current Servo subtree under Firefox contains Stylo and selector support only.
Continuing to cite removed paths would make implementation decisions
non-reproducible.
Decision. Keep the Phase 4 executable line-layout slice behind
vixen_engine::page::Page, but do not add a full layout dependency or cite
historical Servo layout paths until a current Rust layout source is verified
and pinned in docs/REFERENCES.md. If no maintained Servo-family layout source
is available, narrow the v1.0 layout scope explicitly in docs/COMPAT.md
rather than silently swapping to an unrelated fallback crate.
Alternatives considered.
- Keep citing the old Firefox/Servo layout paths. Rejected: those paths are absent from the current reference pin and violate the citation discipline.
- Switch immediately to an unrelated layout crate. Rejected: ADR-001's compatibility rationale still applies; a fallback would need its own ADR and acceptance impact.
Consequences.
- Phase 3 Stylo work remains unblocked:
style/selectorsare present and pinned via the current Firefox/Servo reference and the crates.iostylodependency. - Phase 4 can continue with vertical
Pagefixtures and pure layout helpers, but the full positioned-box-tree adapter has an explicit source-selection gate. - Future layout commits must cite either the new layout source pin or the
narrowed v1.0 compatibility document, not historical
layout_2020paths.
ADR-013: Vixen-owned Rust layout, Ladybird architecture reference
Status: accepted
Supersedes: ADR-001's layout_2020 layout row and ADR-012's open
source-selection gate.
Context. The refreshed Firefox/Servo reference pin no longer contains a
maintained Rust layout crate. Keeping layout blocked on historical Servo paths
would stop the vertical browser slices, while switching to a generic UI layout
crate would not implement web layout semantics. Ladybird's LibWeb layout stack
at 0de15a5dd2a9 is a current, readable browser-layout architecture:
Libraries/LibWeb/Layout/TreeBuilder.cpp centralizes DOM-to-layout-tree
construction, Libraries/LibWeb/Layout/*FormattingContext* separates block,
inline, flex, grid, and table algorithms, and Libraries/LibWeb/Painting/
keeps paint/display-list construction behind a later seam.
Decision. Vixen owns its layout engine in Rust. Stylo remains the CSS
cascade/computed-value source, deno_core remains the JS runtime, and WebRender
remains the only paint backend. The layout layer follows Ladybird's
architecture but uses Rust/data-oriented internals: stable NodeId /
LayoutNodeId handles, arenas, compact structs/enums, explicit dirty bits,
cached intrinsic sizes, and deterministic formatting-context passes.
The v1.0 layout target is not "all of CSS layout." It is the subset needed for simple real pages and the release WPT profile: normal-flow block layout, inline line boxes, basic replaced elements, margin/border/padding/box sizing, positioned descendants, overflow/scroll containers, and useful flex/grid coverage. Tables, floats, fragmentation, full vertical writing, advanced intrinsic sizing, and complete print/page layout are post-v1 unless promoted by WPT/real-site evidence.
Alternatives considered.
- Keep waiting for Servo
layout_2020. Rejected: it is absent from the current Firefox/Servo reference pin and would leave Phase 4 without a reproducible source path. - Use a generic UI layout crate. Rejected: UI-layout crates do not implement web layout semantics, cascade interactions, inline formatting, fragmentation, or WPT-compatible CSS behavior.
- Port Ladybird C++ directly. Rejected: Vixen should reuse the architecture and tests, not import C++ ownership patterns or create a transliteration that fights Rust.
Consequences.
- Vixen's compatibility claim narrows: Firefox/Servo-family cascade, selector, JS, and paint components, but Vixen-owned layout with WPT-gated coverage.
- Layout becomes a core Vixen subsystem and a multi-phase effort. The plan must
prefer small vertical slices through
Page, not large unexercised layout modules. - Every layout semantic decision cites either Ladybird layout/painting paths at
0de15a5dd2a9for architecture or Firefox/Stylo/WebRender paths at46e9f12a8f9bfor computed values and rendering contracts. docs/COMPAT.mdis release-blocking and must state the WPT profile, achieved pass rates, and known layout gaps honestly.
ADR-014: Move JS runtime to deno_core
Status: accepted
Supersedes: ADR-001's JS-engine row, ADR-004's SpiderMonkey compartment wording, and ADR-005's mozjs packaging decision.
Context. The first Phase 2 implementation used mozjs because the original
plan optimized for Firefox-family components end-to-end. The later Phase 6 work
showed that Vixen's actual risk is the Rust-side host API layer: object
registration, bootstrap JS packaging, resource/permission boundaries, testing,
and long-term maintenance of many Web API families. The deno_core crate solves
that packaging problem directly. It brings a well-maintained Rust embedding layer
for V8, explicit extension/op registration, module loading, resource tables,
structured errors, and the runtime architecture Deno uses to expose large Web API
surfaces from Rust.
deno_core does mean Vixen no longer uses a Firefox-family JS engine. That is an
acceptable trade: JS language compatibility comes from V8, Web API compatibility
remains Vixen-owned and fixture/WPT-gated, and Rust host-layer velocity matters
more for alpha progress than preserving SpiderMonkey specifically.
Decision. Migrate Vixen's JS runtime from mozjs/SpiderMonkey to
deno_core/V8 and use deno_core directly inside vixen-engine::script. Do
not introduce a generic JS-engine abstraction or a dyn JavaScriptRuntime layer:
Vixen has one JS runtime target, and deno_core already provides the embedding
API shape we want. The migration has landed behind the existing
JsRuntime/JsValue, headless --eval, and CDP Runtime.evaluate seams.
The target JS architecture is Deno-shaped:
- Host API families live in small modules under
vixen-engine::scriptor pure sibling modules, not as one ever-growingscript.rsfile. - Each family has a Rust op/resource surface, a JS bootstrap surface, and focused tests. The Rust side owns validation and stable errors; JS glue owns Web-shaped object ergonomics only.
- Registration uses a Deno-style extension list: ordered, explicit, testable,
and feature-family scoped (
encoding,dom,url,fetch,storage, etc.). - Long-lived host state should use explicit resource IDs/handles and permission
checks near the op boundary, following
deno_core/Deno resource-table and permissions patterns rather than ad-hoc globals. - Bootstrap JS is packaged as static assets or generated strings owned by the feature module, with Rust tests proving the installed surface.
Alternatives considered.
- Stay on SpiderMonkey and only mimic Deno packaging. Rejected: it keeps the
hard part — building and maintaining a browser-scale Rust host layer — while
missing the maintained
deno_coreabstractions that solve that exact problem. - Abstract over
mozjsanddeno_corebehind an internal JS-engine trait. Rejected: it would preserve two runtime mental models, hide usefuldeno_coreconcepts like extensions/resources/ops behind a leaky common denominator, and create a test matrix Vixen does not intend to support. - Keep all host glue inside
script.rs. Rejected: it does not scale past the first few host-object slices and hides feature-family boundaries. - Adopt Deno wholesale, including CLI/npm/Node compatibility. Rejected: Vixen
needs
deno_core, not the Deno product surface. Node/npm semantics are not part of the browser runtime. - Copy Firefox WebIDL binding generation immediately. Deferred: Firefox's
binding stack is authoritative for many DOM semantics, but
deno_coreis the better Rust embedding/runtime substrate for Vixen.
Consequences.
deno_coreis thevixen-engine::scriptdependency;mozjsis no longer in the active engine dependency graph.- Internal host modules may depend on
deno_coreAPIs directly. The stable seam is the Vixen product API (JsRuntime,JsValue, headless/CDP behavior), not a portable JS-engine adapter. - Binary-size gates must be remeasured for V8. The old system/static mozjs split no longer applies.
docs/REFERENCES.mdpins Deno as the primary JS runtime/host packaging reference. Firefox remains a DOM/Web API semantic reference, but not the JS engine target.- New JS host families should be reviewed for module size, bootstrap locality, explicit registration, and permission/resource boundaries.
- Existing Page string-smoke projections and bootstrap snapshot pilots should
migrate into explicit
deno_coreop/resource extensions one family at a time, while still reusing the same pure Rust modules.
ADR-015: Modern-Linux Firefox replacement, optimized for capability per byte
Status: accepted
Supersedes: ADR-007's narrow "GNOME-only" product wording. The Relm4/libadwaita/Flatpak implementation path remains accepted.
Context. The project direction is now explicit: Vixen should become a Firefox replacement for modern Linux users, with both a focused desktop browser and first-class CLI/CDP automation. The important differentiator is not a large feature buffet; it is high web capability with low binary size, low memory use, fast builds, and rapid iteration driven by useful text reports.
Decision. Optimize Vixen for maximum browser capability per byte. The desktop product targets modern Linux broadly while using the Relm4/libadwaita GUI path and Flatpak/GNOME SDK build path. The shell should stay minimal and focused, closer to Ghostty's product philosophy than a kitchen-sink browser UI. Headless CLI, CDP, and Playwright-style workflows are product surfaces, not just test harnesses.
Priority order is recorded in docs/PROJECT_DIRECTION.md: rendering/layout,
runtime DOM/Web APIs, network/security, storage/history, minimal shell,
headless/CDP, WPT/reporting, HTML integration, CLI ergonomics, then embeddable
Rust API.
Alternatives considered.
- GNOME-only browser identity. Narrowed: the implementation remains GTK/ libadwaita, but the user target is modern Linux rather than GNOME Shell only.
- Kitchen-sink browser chrome. Rejected: UI breadth competes with engine correctness, binary size, and iteration speed before alpha.
- Automation as secondary. Rejected: CLI/CDP users and text reports are part of how Vixen will iterate quickly and be useful early.
Consequences.
- Architecture choices should cite size, memory, build-speed, or correctness impact when there is a meaningful tradeoff.
- Non-Linux platforms remain best-effort.
- UI additions must justify themselves against the focused-shell goal.
docs/COMPAT.mdand WPT/profile output are product artifacts because they let humans and agents measure progress.
ADR-016: hk owns git lifecycle gates
Status: accepted
Context. The previous gate story mixed raw cargo commands, many just gate-*
recipes, manual pre-push habits, and ad-hoc agent summaries. Iteration speed is a
north-star concern, but work leaving the machine still needs consistent checks.
The project already uses mise, and hk is built by the same toolchain ecosystem
for fast git hook orchestration.
Decision. Add checked-in hk.pkl and make hk the git lifecycle enforcement
layer. just remains the project command library; hk decides when those recipes
run. Pre-commit stays quick and mostly local: formatting, merge-conflict/private
key scans, and staged diff whitespace. Long gates run only pre-push through one
recipe, just gate-push.
The standard pre-push gate is:
just gate-alpha
just gate-phase6
just gate-smoke
git diff --check
git diff --cached --check
Alternatives considered.
- Keep manual gate discipline. Rejected: too easy for long autonomous sessions to drift.
- Run all long gates pre-commit. Rejected: hurts iteration speed and produces small, slow commits.
- Replace
justwith hk commands. Rejected:justrecipes are still useful as explicit project actions and documentation anchors.
Consequences.
- Agents may commit and push automatically when hk gates pass.
- Hook setup is part of normal mise/bootstrap workflow.
- If pre-push becomes too slow or misses an important area, change
just gate-pushfirst; keep hk pointing at that stable recipe.
ADR-017: One engine-owned browser, profile, and context lifecycle
Status: accepted
Supersedes: ADR-010's one-EngineWorker-per-tab engine ownership. ADR-010's
Relm4 component/factory/worker guidance remains accepted for GUI presentation and
message transport.
Context. The first vertical slices made Page, JsRuntime, network policy,
profile tables, shell loading, headless commands, and CDP behavior executable.
They also revealed that sharing component types is not the same as sharing a
browser. There is no production impl vixen_api::Engine: the GTK shell owns a
separate navigation/history/network/cookie state machine and constructs Page
on the UI thread, while headless/CDP separately owns Page, one scripted
JsRuntime, history, target/session shape, network configuration, and automation
overrides.
Continuing to add APIs to those coordinators would make lifecycle semantics
frontend-specific. Profile sharing, independent tabs, active navigation
cancellation, stale-result rejection, downloads, frames, and renderer/runtime
recovery all need an owner above an individual Page or protocol session.
Decision. vixen-engine owns one BrowserCore per open profile. It runs on
an engine-owned thread/local executor suitable for the non-Send Rc DOM and
deno_core::JsRuntime, and owns:
- one profile service for store, cookies/cache, permissions, HSTS, downloads, clear-data policy, and host configuration;
- one registry of top-level browsing contexts and future child frames;
- context-scoped session history, sessionStorage, viewport/input state, active navigation, runtime realms, and committed document state; and
- document-scoped DOM, style/layout/paint invalidation, script resources, and inspector state.
Commands and events cross a browser-scoped vixen-api seam and carry typed
context/navigation/document/request/runtime/download ids. Asynchronous work also
carries its creation generation. Cancelling or superseding work invalidates that
generation; late results are rejected before state mutation, cache/profile side
effects, or success events.
The shell, headless CLI, CDP, and WPT harness become adapters over this core. They
may own widgets, GL/EGL surfaces, sockets, protocol session routing, and
presentation snapshots, but not alternate navigation, history, page-runtime,
permission, cookie/cache, or profile state. Composition roots may construct
vixen-engine; adapters do not directly combine its leaf subsystems.
The existing Engine trait may evolve or be replaced by browser-scoped command,
event, query, and factory contracts. Vixen still has one concrete engine; this is
not an engine-plug-in abstraction.
Alternatives considered.
- Keep one independent engine worker per tab and share only a
Store. Rejected: cookies/cache/permissions/downloads and host configuration require coordinated in-memory state, while CDP target routing and browser-wide clear-data operations still need a higher owner. - Keep shell and headless coordinators but extract more common helpers. Rejected: helpers can share algorithms but cannot define atomic commit, cancellation, event ordering, or teardown across independently owned state.
- Move the Rc DOM and V8 runtime to the GTK thread. Rejected: it couples the engine to GUI scheduling and gives headless/CDP a different execution model.
- Make every subsystem
Send + Syncand distribute it across a worker pool. Rejected for alpha: it adds locking and re-entrancy complexity without a proven need. External transport/blocking work can return generational messages to one deterministic engine executor.
Consequences.
- The next alpha work is lifecycle migration before broad API growth.
- Shell/headless direct
vixen-net/vixen-storeand direct orchestration are documented temporary exceptions.just gate-architectureprotects stable leaf boundaries now and should ban each exception once migrated. - Two tabs/targets can own independent documents/runtimes while sharing intended profile state through one service.
stop, redirects, history, form navigation, page-driven navigation, session restore, downloads, and error pages gain one event/commit model.- The browser-core executor becomes a reliability boundary. Long script/layout work needs budgets and cooperative scheduling; stronger process isolation is a later explicit architecture generation, not an accidental worker pool.
- Existing Page/network/store/runtime tests remain useful but need browser-core integration tests for ownership, partitioning, event ordering, cancellation, stale completions, and frontend parity.
Implementation status (2026-07-10). The A1 migration is complete: shell, headless, CDP, and WPT route contexts through BrowserCore, and the architecture gate forbids their former direct leaf composition. The first A2 slice runs source loads on a bounded external Tokio runtime and returns generation-tagged results; stop/supersede abort active transport and forced late completions are rejected before cookie/profile/history/document/runtime mutation. Parser, page-script, and discovered-resource jobs remain synchronous on the owner thread and require the next cooperative-cancellation slice.
Pinned reference-browser revisions
Every implementation decision that touches CSS, DOM, JS, layout, or paint semantics must cite a path in one of these trees, plus the pinned revision below. The reference trees are large; pinning prevents non-reproducible consultations ("latest main" drifts).
These pinned revisions are the canonical reference set for Vixen's implementation work, captured once so every citation points at the same tree state.
Pin table (captured 2026-07-06 from branch HEADs)
| Reference | Upstream | Pinned revision | Branch | Used for |
|---|---|---|---|---|
| Firefox | https://github.com/mozilla-firefox/firefox.git | 46e9f12a8f9b | main | CSS property definitions, DOM API behavior, JS/realm/rooting discipline, WebRender internals, WPT test selection. Also hosts the servo/ Stylo subtree (see below). |
| Servo Stylo (under Firefox tree) | vendored at firefox/servo/ @ 46e9f12a8f9b | (same as Firefox) | — | Primary CSS reference. Stylo (components/style/), selectors (components/selectors/), and supporting Servo crates. Current Firefox HEAD does not carry the old Servo script/layout crates. |
| Ladybird | https://github.com/LadybirdBrowser/ladybird.git | 0de15a5dd2a9 | master | Primary layout architecture reference. LibWeb DOM/style/layout/paint seams, TreeBuilder, formatting contexts, display-list construction. |
| GNOME Web (Epiphany) | https://gitlab.gnome.org/GNOME/epiphany.git | 21e02b9a272d | main | GTK4/libadwaita shell patterns, WebKitGTK embedding, GSettings usage, Flatpak manifest conventions. |
| Obscura | https://github.com/h4ckf0r0day/obscura.git | ca71ce3c2da9 | main | Headless CLI design, CDP server patterns, single-binary distribution. |
| Relm4 | https://github.com/Relm4/relm4.git | 1ee9b5208b8b | main | Relm4 component patterns, factory widgets, async actions. The examples/ and relm4-components/ directories are the primary value. |
| Deno / deno_core | https://github.com/denoland/deno.git | 83c50b1da61e | main | Primary JS runtime packaging reference. deno_core embedding, extension/op boundaries, bootstrap JS packaging, resource tables, permissions, and test layout. |
How to consult each
Firefox / Servo Stylo subtree (firefox/ checkout)
The Firefox checkout is large. For Vixen, use a sparse checkout containing the Rust-facing pieces we can cite directly plus the Firefox C++ seams that show API contracts:
firefox/servo/components/style/ ← Stylo. Read this for CSS cascade/computed values.
firefox/servo/components/selectors/ ← selector engine used by Stylo.
firefox/gfx/wr/webrender_api/src/ ← WebRender display-list API.
firefox/gfx/webrender_bindings/ ← Firefox ↔ WebRender transaction/builder bridge.
firefox/dom/bindings/ ← WebIDL binding and wrapping discipline.
firefox/dom/webidl/ ← DOM API surface contracts.
firefox/dom/base/ ← DOM API behavior and selector delegation.
Current Firefox HEAD (46e9f12a8f9b) does not include
servo/components/layout_2020/, servo/components/layout/, or
servo/components/script/. Do not cite those removed historical paths.
Vixen-owned layout uses Ladybird as the architecture reference per ADR-013.
When in doubt about a CSS computed value, search
firefox/servo/components/style/properties/ for the property name —
longhands, shorthands, and computed-value logic all live there.
Ladybird (ladybird/)
Use Ladybird when a question is architectural ("how do other engines seam X from Y?") rather than specification-level. Per ADR-013, Vixen's Rust layout engine follows Ladybird's layout architecture, not its C++ ownership model.
ladybird/Libraries/LibWeb/ ← DOM, CSS, layout, paint (cleanly seamed)
ladybird/Libraries/LibWeb/CSS/ ← cascade + stylesheet model
ladybird/Libraries/LibWeb/Layout/TreeBuilder.cpp ← styled DOM → layout tree seam
ladybird/Libraries/LibWeb/Layout/ ← formatting contexts
ladybird/Libraries/LibWeb/Painting/ ← display-list construction
ladybird/Libraries/LibGfx/ ← rasteriser fallback
GNOME Web (gnome-web/)
Consult for shell-side questions: how to embed a webview in libadwaita, how to structure preferences, how to write the Flatpak manifest, how to manage profile data per app-ID. This is the closest production analog to what Vixen wants to be at the shell layer.
gnome-web/src/ ← shell source
gnome-web/data/ ← gschema, metainfo, desktop
gnome-web/flatpak/ ← manifest conventions (we keep our own in build-aux/)
Obscura (obscura/)
Consult for headless tooling: CDP server implementation, CLI flag ergonomics, single-binary packaging for automation. Obscura is the design source for the headless CLI surface, which Vixen inherits verbatim.
Relm4 (relm4/)
Consult before writing any new shell widget. The examples/ directory is
curated and the relm4-components/ directory has reusable widgets
(relm4-components::alert, ::simple_adw_combo_box, etc.).
relm4/examples/ ← 45 component-pattern examples
relm4/relm4-components/ ← reusable widgets
relm4/relm4/src/ ← factory, actions, message passing
Deno (deno/)
Consult Deno for JS runtime embedding and Rust host packaging, per ADR-014.
The target crate is deno_core. Use this
tree for extension/op organization, resource-table shape, permission checks near
host boundaries, bootstrap script packaging, and feature-family test layout. Do
not cite Deno for DOM/Web API semantics over Firefox/specs; Deno is the runtime
substrate reference, while Web-facing behavior remains WPT/spec-gated.
deno/core/ ← op/extension/runtime core patterns
deno/runtime/ ← permissions, workers, bootstrap packaging
deno/ext/ ← feature-family JS/Rust extension layout
deno/cli/ ← integration tests and permission plumbing examples
Re-cloning fresh
If .tmp/ref/ is unavailable, clone each at the pinned revision:
mkdir -p .tmp/ref && cd .tmp/ref
git clone --depth 1 --filter=blob:none --sparse --branch main https://github.com/mozilla-firefox/firefox.git
git -C firefox sparse-checkout set servo gfx/wr gfx/layers/wr gfx/webrender_bindings dom/webidl dom/base dom/bindings js/public
git -C firefox checkout 46e9f12a8f9b
git clone --depth 1 --filter=blob:none --sparse --branch master https://github.com/LadybirdBrowser/ladybird.git
git -C ladybird sparse-checkout set Libraries/LibWeb Libraries/LibGfx
git -C ladybird checkout 0de15a5dd2a9
git clone --depth 1 --filter=blob:none --sparse --branch main https://gitlab.gnome.org/GNOME/epiphany.git gnome-web
git -C gnome-web sparse-checkout set src data flatpak
git -C gnome-web checkout 21e02b9a272d
git clone --depth 1 --filter=blob:none --branch main https://github.com/h4ckf0r0day/obscura.git
git -C obscura checkout ca71ce3c2da9
git clone --depth 1 --filter=blob:none --sparse --branch main https://github.com/Relm4/relm4.git
git -C relm4 sparse-checkout set examples relm4-components relm4/src
git -C relm4 checkout 1ee9b5208b8b
git clone --depth 1 --filter=blob:none --sparse --branch main https://github.com/denoland/deno.git
git -C deno sparse-checkout set core runtime ext cli
git -C deno checkout 83c50b1da61e
Disk budget depends on sparse settings. Keep the checkouts in .tmp/ref/
or another ignored workspace; avoid committing reference trees.
Citation discipline
Vixen's tick-tock rules (each phase is a tick — capability lands; the post-phase cleanup is a tock — dead-code removal, ≤ 1 kLOC modules, reference citations):
- Every implementation commit cites at least one path + commit hash from a reference tree explaining why the behaviour is correct.
- Every tock (post-phase hardening) cites at least four reference paths.
- Commit hashes are the short form of the pin above (
46e9f12a8f,0de15a5dd2, etc.), neverHEADormain. - When a reference path goes stale, refresh the affected checkout to the current branch HEAD and update this file in the same change; do not leave implementation comments pointing at historical paths that no longer exist.
Vixen development mode
This document defines dev for this repo: how to move quickly during alpha without creating long-term maintenance debt.
Project focus is defined in PROJECT_DIRECTION.md.
Autonomous commit/push policy is defined in
AUTONOMOUS_WORK.md. Git lifecycle gates are enforced by
hk via the checked-in ../hk.pkl.
Definitions
- Dev / alpha means partial browser capability is allowed when it is executable, tested, fail-closed, and honestly documented. Alpha work may be incomplete; it must not be vague, hidden, or unbounded.
- A slice is the smallest reviewable unit that makes one browser-visible
seam better: usually one
Page/headless/CDP/WPT fixture path plus the pure engine code it consumes. - A tock is a cleanup-only follow-up after capability work: delete dead shims, split modules nearing 1 kLOC, move duplicated parsing to one helper, tighten docs, and retire stale fixtures.
- Release mode is stricter than dev mode and is governed by
ACCEPTANCE.md. Do not use this document to lower release gates.
Alpha development contract
Every alpha slice should satisfy these rules:
- Visible seam first. Prefer code that reaches the engine-owned browser/
context/document path,
vixen-headless, CDP, or a committed WPT/fixture check. APageslice must preserve BrowserCore ownership and name the live document seam it advances. Pure prep is fine only when the next visible seam is named. - One trust boundary at a time. For security-sensitive paths, name the boundary, validate near it, fail closed, and surface stable error codes.
- Reuse pure modules without duplicating ownership. JS host objects, Page projections, CLI, and CDP should call the same Rust implementation, but only the browser core decides lifecycle, commit, cancellation, and persistence.
- Partial APIs must be explicit. A subset may ship in alpha if unsupported
inputs fail closed and the supported behavior is documented in
COMPAT.md. Interface shape without a backing subsystem must be labeled shape-only. - No silent architecture drift. New dependencies, crate edges, rendering
paths, process boundaries, or storage/network policy changes must be backed by
an ADR/update in
DECISIONS.mdor an explicit plan note. - Tests travel with behavior. Unit tests prove pure logic; one integration check proves the user-visible seam. If a fixture manifest assertion is the seam, keep it committed.
Gate tiers
Use the cheapest gate that matches the risk, then escalate before review or push.
| Tier | Use when | Command shape |
|---|---|---|
| Inner loop | Editing one crate/module | cargo check -p <crate> plus focused cargo test ... <name> |
| Pre-commit | A commit is being made | hk pre-commit: cargo fmt, merge-conflict/private-key scan, staged diff whitespace check |
| Alpha slice | A coherent partial capability is ready | focused tests + relevant just gate-phaseN |
| Pre-push | Work is ready to leave the machine | hk pre-push: just gate-push |
| Release | Versioned release readiness | every gate in ACCEPTANCE.md |
just gate-push is the long integration gate. Keep long gates out of the inner
loop and pre-commit path so iteration stays fast.
Current pre-push composition:
just gate-alpha
just gate-phase6
just gate-smoke
git diff --check
git diff --cached --check
Adjust just gate-push as the alpha architecture changes; hk should keep
calling that single recipe.
GTK shell / GNOME SDK blockers
The supported GTK/libadwaita build path is Podman + the flatpak-builder
container, not host-installed GNOME development packages. If a native
cargo check --features vixen-shell/gtk-shell or just shell-check fails with
missing glib-2.0, gtk4, or libadwaita pkg-config files, treat that as a
host-environment limitation, not a product blocker. Verify shell changes with:
just flatpak-update-sdk
just flatpak-build
Use native GTK development packages only for ad-hoc local work. Keep blocker notes explicit about this split so follow-up work points at the containerized Flatpak path before asking for host package installs.
Larger alpha batches
Larger batches are encouraged when they reduce handoff overhead and stay coherent. A batch is coherent if it has:
- one feature family or one host-object family,
- one primary visible seam,
- one docs/compat story,
- one verification story.
Stop and split when the next addition would introduce a second trust boundary, a second unrelated feature family, or a second independent rollback concern.
Maintainability budget
Alpha speed is acceptable only while these budgets stay visible:
- Non-test modules should stay below 1,000 lines. If a module crosses that while moving fast, create the split in the next tock before widening the feature.
- Prefer boring data flow over framework gravity: DTOs in
vixen-api, lifecycle and pipeline state in the engine-owned browser/context/document graph, and browser-facing adapters in headless/CDP/shell. - Avoid duplicate parsers/matchers. If a Page string projection and a JS host object both need behavior, extract or call the same Rust module.
- Remove obsolete string-smoke shims as host objects replace them. Do not leave two authoritative paths for the same supported expression.
- Keep
COMPAT.mdhonest: partial support is fine, overclaiming is not.
Alpha definition of done
A dev/alpha slice is done when:
- the supported subset is named,
- unsupported inputs fail closed,
- docs mention the current state and next widening step,
- focused tests and the relevant gate pass,
- hk pre-commit/pre-push gates are clean before commit/push,
- any known debt is either removed immediately or named as the next tock.
Autonomous work contract
This document exists so agents and maintainers can make progress without re-asking project-direction questions.
Decision policy
Until alpha, continue without asking unless a change would alter architecture. Architecture changes include:
- a new JS runtime target or abstraction,
- a new desktop GUI path other than Relm4/libadwaita,
- a second render/paint path,
- a new layout architecture,
- a core dependency that changes binary-size or subsystem ownership materially,
- a security-policy change that makes behavior less fail-closed.
For ordinary implementation details, choose the safest path aligned with
PROJECT_DIRECTION.md, document assumptions briefly, and keep moving.
Commit and push policy
- Automatic commits are allowed when the batch is coherent and gates pass.
- Automatic pushes are allowed when hk pre-push gates pass.
- Prefer milestone commits over tiny churn commits.
- Do not bypass hk. If hk fails, fix the issue or report the blocker.
Gate policy
- Inner loop: focused
cargo check/cargo test/just gate-phaseNas needed. - Before commit: hk pre-commit hook; it should stay quick and fix formatting.
- Before push: hk pre-push hook; long gates run here because iteration speed matters.
- Release:
ACCEPTANCE.mdgates plus measured size/compatibility reports.
The project owns hook definitions in hk.pkl. just owns command recipes; hk
owns when those recipes run in the git lifecycle.
Reporting format
Final handoff should be terse and evidence-first:
- objective completed,
- changed files,
- checks run and pass/fail status,
- commit hash and push status when applicable,
- remaining known gaps or next slice.
For large compatibility work, update COMPAT.md from actual fixture/WPT output
rather than prose guesses.
Documentation rule
Prefer ADR-style docs that explain why and point to code for how. Avoid parallel prose that must be maintained beside source unless it records product direction, architecture constraints, compatibility results, or gate policy.
CDP / Playwright smoke
Run the committed smoke with:
mise install
just cdp-playwright-smoke
The script starts vixen-headless --cdp, connects with playwright-core, and
exercises:
- Playwright's
chromium.connectOverCDP(...)handshake. Runtime.enable,Page.enable,Network.enable,Target.getTargets,Page.getFrameTree.Page.navigateto a local fixture with a button click listener, including Playwrightpage.addInitScript()execution before page scripts.Runtime.evaluate/Runtime.awaitPromisefor DOM reads and promise handles.- CDP DOM query plumbing:
DOM.getDocument,DOM.querySelector,DOM.querySelectorAll,DOM.describeNode, andDOM.resolveNode. - Top-level navigation network notifications:
Network.requestWillBeSent,Network.responseReceived, andNetwork.loadingFinished; lifecycle opt-in observesinit/commit/DOMContentLoaded/load. Input.dispatchMouseEventwithmousePressedthenmouseReleasedover the button.- The click handler mutates
textContent, attributes/classes, inline style, and a smallcreateElement/appendChild/removeChild/replaceChildrenstructural subtree; laterRuntime.evaluatecalls read those mutations back. - Observe
Runtime.consoleAPICalled, then callPage.captureScreenshot(png) and Playwright's high-levelpage.screenshot()path. - Playwright
page.setViewportSize()plus CDPPage.getLayoutMetricsviewport reporting, page-level viewport globals, andpage.emulateMedia()updates tomatchMedia()for media type/color scheme. - Exercise high-level locator geometry/click/fill APIs over Vixen's minimal
DOM.describeNode/DOM.resolveNode/DOM.getContentQuadsbacking. The smoke also coverslocator.hover()mouse lifecycle events,locator.dblclick()click/detail ordering, right-clickcontextmenu,page.mouse.wheel()wheel-event deltas,getByRole()button lookup by accessible text,getByLabel()lookup/check/select/fill through DOM label/control associations, high-level Playwright keyboard input against a clicked form control, andlocator.setInputFiles()against a file input. - Submit a form through Playwright's high-level locator click and wait for the resulting URL/title navigation.
- Traverse session history with Playwright
page.goBack()/page.goForward()and refresh withpage.reload(). - Replace document content with Playwright
page.setContent(). - Execute/apply dynamic inline scripts and styles inserted by Playwright
page.addScriptTag()/page.addStyleTag(). - Deliver exposed function calls through Playwright
page.exposeFunction(). - Create and close additional pages with Playwright
context.newPage()/page.close(). - Read object properties through Playwright
JSHandle.getProperty(). - Surface modal dialogs through Playwright's
dialogevent. - Replace and clear browser-context permission grants through Playwright
context.grantPermissions()/context.clearPermissions(), with runtimePermissionStatusreads observing the override without rewriting profile decisions. - Start/stop Chromium tracing through Playwright
browser.startTracing()/browser.stopTracing(), read the bounded JSON trace throughIO.read, and verify stablecdp.method-not-founderrors for unsupported methods.
Rust CDP tests additionally cover browser-shaped automation methods used by
Playwright/DevTools probes: idle Page.stopLoading,
Page.resetNavigationHistory,
Page.getResourceTree, Page.getResourceContent, Page.setBypassCSP as a
CDP-scoped script-CSP override for later navigations,
Network.setCacheDisabled bypassing runtime fetch() cache reads/writes,
Network.setBypassServiceWorker,
Network.setExtraHTTPHeaders propagation into runtime fetch(),
Performance.getMetrics, Security.getSecurityState, and DOM
attribute/outerHTML read-write methods, exact/wildcard permission override
scopes, detached-session rejection, stable protocol error data, and the bounded
4,096-event tracing buffer.
Current limits are intentional: one main frame per independently scripted target,
PNG screenshots only, Chromium JSON tracing only (not Playwright context trace
archives), synchronous per-request lifecycle waiting (therefore idle-only
protocol stop-loading acknowledgement), and full-viewport mouse hit testing.
BrowserCore itself has active source cancellation and stale-completion race tests;
CDP needs an asynchronous event pump before the same connection can race
Page.navigate with Page.stopLoading. Add methods only when this smoke shows a
real automation gap.
Vixen guidance
How-to guides for specific workflows. The spec/architecture/plan docs say what and why; these guides say how, step by step.
| Guide | When to read it |
|---|---|
mise.md | Activating the project-managed toolchain and using just recipes correctly. Start here for local setup. |
cargo-home.md | Why CARGO_HOME points at <workspace>/.cargo and how recipe-installed Cargo tools stay local. |
deno-core.md | deno_core/V8 embedding, host-extension shape, and cache/pinning notes. |
gnome-sdk-flatpak-builder.md | Building against the GNOME 50 SDK. The GNOME SDK is not installed on the host; it is managed inside a flatpak-builder container image. |
(Add new guides here as standalone files. Keep each guide focused on one workflow, with copy-pasteable commands that have been verified to run.)
mise + just workflow
Vixen uses two tools with separate jobs:
misepins tool versions and exports the project environment from.mise.toml(RUSTUP_TOOLCHAIN,CARGO_HOME,HK_MISE, andPATH).justowns repository actions. Add or update ajustfilerecipe instead of copyingcargo ...command lines into docs, scripts, or CI.hkowns git lifecycle hooks. The project config ishk.pkl; install hooks withjust hooks-installor through the mise postinstall hook.
The intended workflow is an activated shell where cargo, rustfmt, clippy,
rustup, cargo-binstall, hk, and just come from the versions pinned in
.mise.toml.
First setup
mise trust
mise bootstrap --yes
mise bootstrap installs pinned tools, then runs just setup, which installs
the optional Cargo tools used by just audit / just fuzz-security, installs a
nightly Rust toolchain for cargo-fuzz, and finishes with just check-all-host.
mise's postinstall hook also runs hk install --mise so git hooks execute in
the project tool environment.
For tools-only CI images, mise install is enough; run project checks through
just after activating the shell.
Daily shell setup
Activate mise once per shell, then run recipes directly:
eval "$(mise activate bash)" # bash
# eval "$(mise activate zsh)" # zsh
# mise activate fish | source # fish
just check
just test
just smoke
just hooks-install
Do not hard-code paths to Cargo, and do not wrap every build command in
mise exec. If cargo is missing, the shell is not activated or mise install
has not completed.
Common recipes
| Recipe | What it does |
|---|---|
just setup | Optional dev tools + nightly for fuzzing + check-all-host |
just hooks-install | Install/update hk hooks through hk install --mise |
just check / just check-all-host | Type-check the host-runnable workspace |
just test / just test-host | Run host-runnable tests |
just smoke / just gate-smoke | Formatting check, clippy, check, tests |
just gate-push | Long hk pre-push gate |
just audit | cargo audit and cargo deny check |
just fuzz-security | Phase 1 fuzz targets at 1 M iterations |
just flatpak-update-sdk / just flatpak-build | GNOME SDK container workflow |
Use just --list for the full recipe list.
One-shot commands
For automation that cannot keep an activated shell, prefer a single activated
subshell and still call just recipes:
bash -lc 'eval "$(mise activate bash)" && just smoke'
Avoid tool-specific invocations like mise exec rust@... -- cargo ...; Rust is
special in mise because the Rust backend delegates to rustup. In an activated
shell, mise sets RUSTUP_TOOLCHAIN and exposes Cargo through the workspace
CARGO_HOME (.cargo/bin).
Verifying the active toolchain
eval "$(mise activate bash)"
mise ls --current
command -v cargo
cargo --version
command -v just
just --version
command -v hk
hk --version
printenv CARGO_HOME
Expected properties:
cargoresolves under<workspace>/.cargo/bin.justresolves under mise's install directory.hkresolves under mise's install directory.cargo --versionmatches the Rust version pinned in.mise.toml.CARGO_HOMEis<workspace>/.cargo.
Updating versions
Update shared tool versions with mise use so .mise.toml remains the source
of truth:
mise use rust@<version>
mise use just@<version>
mise use hk@<version>
mise use cargo-binstall@<version>
Then verify in a freshly activated shell and run just smoke before committing
the version change.
Cargo home lives in the workspace
Vixen points CARGO_HOME at <workspace>/.cargo instead of the default
~/.cargo. Everything Cargo would normally write to the user's home — the
registry index, downloaded crate sources, git checkouts, cargo-binstall-ed
tooling — stays inside the workspace tree.
Why
- Workspace is the unit of trust. The registry cache, git checkouts,
and installed binaries are all inputs to the build; keeping them under
<workspace>/.cargomakes that explicit and lets the reviewer audit them alongside the source. - Reproducibility. A fresh contributor gets the same view of the dep
tree as CI; nothing depends on whatever happens to live in their
~/.cargofrom other projects. - No cross-project leakage. Vixen's
cargo-binstallpackages don't shadow globally-installed copies on the user's machine, and vice versa.
How it's wired
.mise.toml[env]exports:CARGO_HOME = "{{ config_root }}/.cargo"_.path = ["{{ config_root }}/.cargo/bin"](mise's PATH-prepend directive, so Cargo itself pluscargo-audit,cargo-deny, andcargo-fuzzinstalled bymise bootstrap/just setup-dev-toolsare runnable from an activated shell)
.gitignoreignores everything under.cargo/exceptconfig.toml, which is the project-pinned Cargo config and ships with the repo..cargo/config.tomlis checked in. It doubles as the CARGO_HOME config (Cargo reads the same physical file in both roles) — keep it limited to project-pinned settings, never cache state.
mise exports these vars to any mise-active shell. Use the workflow in
mise.md: activate once per shell, then run cargo / just
directly. Do not hard-code paths to Cargo or wrap every command in mise exec.
Verifying it took effect
mise trust
eval "$(mise activate bash)"
echo "$CARGO_HOME" # → /path/to/vixen/.cargo
command -v cargo # → /path/to/vixen/.cargo/bin/cargo
ls .cargo/ # → config.toml, plus registry/, bin/, ... after first build
cargo itself reports the resolved home:
cargo config get # honors CARGO_HOME from the env
Disk
The cache for Vixen's dep tree (Stylo + JS runtime artifacts + reqwest + …) is several
hundred MiB. It's all under .cargo/ and git-ignored, so it costs nothing
in the repo; treat it like target/. rm -rf .cargo is safe and Cargo
will repopulate on the next build.
Updating tooling installed via cargo-binstall
mise bootstrap delegates to just setup-dev-tools, which uses
cargo-binstall for cargo-audit, cargo-deny, and cargo-fuzz (falling
back to cargo install where possible). Because CARGO_HOME is
workspace-local, those binaries land in .cargo/bin/. To install or re-check
them:
eval "$(mise activate bash)"
just setup-dev-tools
or rerun mise bootstrap --yes.
Caveats
- Editor integration. rust-analyzer and IDEs spawn
cargothemselves; make sure they inherit the mise env (direnvintegration, or launch the editor from a mise-active shell). If they don't, they'll fall back to~/.cargoand re-download the registry there. Harmless, just slow. - Other projects on the host. They keep using
~/.cargoas before; Vixen's CARGO_HOME only applies inside a mise-active shell in this workspace.
deno_core runtime target
ADR-014 makes deno_core the target JS
runtime substrate for Vixen. The Phase 2 eval gate and focused Phase 6 host smoke
checks now run behind deno_core.
Migration shape
Keep the public engine seam stable:
vixen_engine::script::JsRuntime::new()JsRuntime::evaluate(...)JsRuntime::evaluate_with_page(...)JsValuevixen-headless --eval- CDP
Runtime.evaluate
The implementation underneath is a deno_core::JsRuntime; host slices should
keep moving bootstrap-only surfaces into explicit feature-family extensions. Do
not add a generic JS-engine
trait around deno_core; use deno_core APIs directly inside
vixen-engine::script. The abstraction boundary is the Vixen-facing API above,
not portability to another JS engine.
Extension layout target
Each host family should be small and explicit:
crates/vixen-engine/src/script/
runtime.rs # deno_core runtime construction + eval bridge
webidl.rs # generated interface/prototype substrate
encoding.rs # TextEncoder/TextDecoder ops + bootstrap JS
dom.rs # document/Element snapshot extension + bootstrap JS
cssom.rs # getComputedStyle/CSS.supports/styleSheets ops + bootstrap JS
url.rs # URL/URLSearchParams extension
fetch.rs # Headers/Request/Response/Blob/File extension
Current state:
runtime.rsownsdeno_core::JsRuntimeconstruction and V8 value conversion.webidl.rsrenders the first generated binding substrate from a Rust-owned WebIDL-shaped manifest. It installs browser interface constructors/prototypes plus__vixenWebidl.adoptInterface(...), so feature-family bootstraps attach concrete Vixen implementations to generated prototype chains instead of hand-rolling every constructor shape.encoding.rsregisters the first op-backed host extension; JS constructors delegate UTF-8 encode/decode work tovixen-engine::text_codecthrough ops.dom.rsregisters a page-snapshot extension for focused read-onlydocument/Element/DOMTokenList/dataset evals. Page data crosses thedeno_coreop boundary throughop_vixen_dom_snapshot; element data is loaded throughop_vixen_dom_element_snapshot; selector lookup,Element.matches(), element text/attribute reads, and read-only token/dataset surfaces delegate through focused DOM ops. Element geometry reads (getBoundingClientRect()/getClientRects()/getBoxQuads()), Range rectangles, and client/offset/scroll metrics now cross a DOM rect op and materialize Web-shaped rect/list/quad objects on generated WebIDL prototypes.cssom.rsregisters the focused read-only CSSOM extension.CSS.supports,getComputedStyle, anddocument.styleSheets/CSSRule smoke data now cross explicit CSSOM ops and attach to generated CSSOM prototypes instead of being synthesized by the headless/Page string projection.just gate-webidlis the focused regression gate for this layer: generated interface/prototype coverage,JsRuntimeeval, headless--eval, and CDPRuntime.evaluatemust stay green together.
Rules:
- Rust validates near the op boundary and returns stable
EngineErrorcodes. - JS bootstrap exposes Web-shaped objects but delegates behavior to Rust ops or shared pure modules.
- Long-lived host state uses
deno_coreresources/handles, not ad-hoc globals. - Permissions and origin policy checks stay near the operation that crosses the trust boundary.
DOM maintenance comparison
Staying on deno_core is still the lower-maintenance path for a stable browser
DOM surface. Deno's runtime is built from the same extension/op/WebIDL pattern
Vixen already uses, and Deno publishes separable extension crates for many
state-light Web APIs (deno_web, deno_fetch, deno_webstorage, etc.). Those
crates can be evaluated family-by-family to reduce Vixen-owned code for value
objects, streams, fetch plumbing, storage scaffolding, and WebIDL conversions.
Neither Deno nor Bun gives Vixen a drop-in browser document tree: Vixen still
owns Document/Node/Element/HTMLElement, selector integration, mutation
commit, layout-backed geometry, CSSOM, navigation, and origin/security policy
because those APIs must talk to Vixen's Page, Stylo/layout, vixen-net, and
vixen-store state. The best code-reduction strategy is therefore to reuse
Deno-style non-DOM host families where they fit while keeping the DOM tree
Vixen-owned.
Bun/JSC does not lower that maintenance burden today. Bun has substantial
WebCore-flavoured implementations for runtime APIs such as Blob, Request,
Response, streams, encoding, and fetch, but its Rust crates are internal to the
Bun executable: they depend on generated code, Bun-specific globals/event-loop
state, C++ WebKit/JSC shims, and Node/Bun compatibility layers. Adopting them
would replace Vixen's current op modules with a larger forked embedding surface
without providing a maintained browser DOM tree for Vixen's Page model.
Cache and size notes
Expect V8/rusty_v8 artifacts to dominate JS runtime packaging. Keep Cargo and
runtime caches inside the workspace via the existing CARGO_HOME guidance, then
remeasure just size-fp before release.
Building against the GNOME SDK via flatpak-builder containers
The GNOME SDK is not installed on the host. Vixen targets the GNOME 50
SDK (ADR-007), and the SDK — org.gnome.Sdk//50 + org.gnome.Platform//50
— is managed inside a flatpak-builder container image, not via the
host package manager. This keeps host pollution at zero and makes the build
reproducible: the SDK version is pinned by the image tag.
Verified against
ghcr.io/flathub-infra/flatpak-github-actions:gnome-50(flatpak 1.18.1, flatpak-builder 1.4.9,org.gnome.Sdk//50+org.gnome.Platform//50preinstalled, x86_64). The same workflow works in CI (this is the image Flathub's GitHub Action uses).
Why a container?
- Zero host churn. No
libgtk-4-dev/libadwaita-1-devto install, version-skew, or distro-specific packages.mise bootstrapno longer installs GNOME packages (.mise.toml). - Reproducible. The image tag is the SDK version.
gnome-50today; bump the tag to move the SDK. - Matches release builds. The production Flatpak is built with
flatpak-builderagainst this exact runtime, so dev and release go through the same SDK.
The image is purpose-built for flatpak-builder. It does not carry
cargo, rustc, or gtk4 at the container root — those live in the GNOME
SDK runtime and are consumed by flatpak-builder when it builds the app
inside the Flatpak sandbox. (For Rust, the manifest pulls the
org.freedesktop.Sdk.Extension.rust-stable SDK extension.) So the workflow
is "build a Flatpak", not "cargo-build against container-host gtk4".
Prerequisites
You need a container runtime on the host — podman (preferred, rootless)
or docker. Neither needs the GNOME SDK installed.
podman --version # or: docker --version
The recipes below live in the justfile and are run through just
(itself mise-managed — see the repo README). Container image and runtime
version are pinned as variables:
FLATPAK_BUILDER_IMAGE = "ghcr.io/flathub-infra/flatpak-github-actions:gnome-50"
GNOME_RUNTIME_VERSION = "50"
First-time setup: pull the image (= install the GNOME 50 SDK)
just flatpak-update-sdk
# equivalent to: podman pull ghcr.io/flathub-infra/flatpak-github-actions:gnome-50
The image is ~5.8 GB (it carries the GNOME 50 SDK + Platform runtimes preinstalled). Pull once; subsequent runs reuse it.
To move the SDK to a new GNOME version: bump FLATPAK_BUILDER_IMAGE
in the justfile (e.g. gnome-51), bump GNOME_RUNTIME_VERSION, update
runtime-version in the manifest (build-aux/org.vixen.Vixen.json), then
just flatpak-update-sdk.
Interactive shell in the SDK container
Drop into a shell in the container with the workspace mounted at
/workspace:
just flatpak-shell
# equivalent to:
# podman run --rm -it -v $PWD:/workspace:z -w /workspace \
# ghcr.io/flathub-infra/flatpak-github-actions:gnome-50
From inside the shell you can inspect the managed SDK:
flatpak --version # flatpak 1.18.1
flatpak-builder --version # flatpak-builder 1.4.9
flatpak list | grep gnome # org.gnome.Sdk//50 + org.gnome.Platform//50
flatpak-builder is on PATH; cargo/rustc are not at the
container root (see "Why a container?" above) — to compile the app you use
flatpak-builder, which runs the build inside the GNOME SDK sandbox.
Build the Flatpak (the GNOME-SDK-backed build path)
just flatpak-build
This runs, inside the container:
flatpak-builder --install-deps-from=flathub --disable-rofiles-fuse --force-clean --repo=build-aux/_repo \
build-aux/_build build-aux/org.vixen.Vixen.json
The recipe launches the container with podman run --privileged because this
host path nests flatpak-builder's bwrap sandbox inside rootless Podman.
--disable-rofiles-fuse avoids needing a container dbus/fuse setup. These are
container-only privileges; the repo remains mounted at /workspace.
flatpak-builder resolves the manifest, builds each module inside the
org.gnome.Sdk//50 sandbox (where gtk4, libadwaita, and the Rust extension
live), and exports the app to build-aux/_repo. The build output lands in
build-aux/_build/. For host GUI smoke, install the exported local repo with
just flatpak-install-local, then run just flatpak-run.
Status:
build-aux/org.vixen.Vixen.jsonbuilds the GTK shell vertical (cargo build --release -p vixen --features vixen-shell/gtk-shell) against the GNOME SDK. Cargo crate archives and the rusty_v8 static archive are checked Flatpak sources, so Cargo itself runs offline inside the build sandbox. The Rust SDK extension is installed from Flathub byflatpak-builder --install-deps-from.
Validating the manifest without a full build
just flatpak-shell
# inside the container:
flatpak-builder --show-deps build-aux/org.vixen.Vixen.json # resolve sources
flatpak-builder --dry-run build-aux/_build build-aux/org.vixen.Vixen.json
Where the GNOME SDK actually lives
| Layer | What | Has gtk4 / cargo? |
|---|---|---|
| Host | your distro + mise-managed rust/just | No GNOME SDK (by design) |
| Container root | Freedesktop-SDK base, flatpak, flatpak-builder, meson, ninja | No |
org.gnome.Sdk//50 runtime | gtk4, libadwaita, Pango, HarfBuzz, fontconfig, … | Yes (consumed by flatpak-builder) |
org.freedesktop.Sdk.Extension.rust-stable | cargo/rustc for the build | Pulled by the manifest |
So "managing the GNOME SDK" = managing the container image tag (which
pins the preinstalled org.gnome.Sdk//50 runtime). Updating the SDK is
just flatpak-update-sdk with a bumped tag.
Troubleshooting
flatpak-builderreports the runtime or Rust SDK extension is missing. Usejust flatpak-build; it passes--install-deps-from=flathubso flatpak-builder can install/updateorg.gnome.Sdk//50,org.gnome.Platform//50, andorg.freedesktop.Sdk.Extension.rust-stable.- Permission denied on
/workspace. The:zmount flag relabels for SELinux (Fedora). On non-SELinux hosts it's harmless. Withdocker, swappodmanfordockerin thejustfileif you prefer. bwrap: Can't mount proc on /newroot/proc. Run throughjust flatpak-build; the recipe usespodman run --privilegedfor nested flatpak-builder sandboxing.Failure spawning rofiles-fuse/ missing machine-id. Run throughjust flatpak-build; the recipe passes--disable-rofiles-fuse.- "No cargo in the container." Expected — cargo is provided to the
build via the Rust SDK extension inside the manifest, not at the
container root. Use
just flatpak-build, notcargo buildin the shell. - Host-side
cargo build --features vixen-shell/gtk-shellfails. Expected on a clean host — there is no GNOME SDK installed natively. Either use the container, or install your distro'sgtk4-devel/libadwaita-develyourself for ad-hoc native work (not the supported path).
Reference
- Image source:
flathub-infra/actions-images(generatesghcr.io/flathub-infra/flatpak-github-actions:<runtime>). - Flatpak building docs: https://docs.flatpak.org/en/latest/building.html
- Runtime/SDK concepts: Available runtimes.
- ADR-007 (GNOME-only target),
docs/ARCHITECTURE.md"Crate layout" (build-aux/),.mise.toml.