Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Vixen

CI Pages Docs License Rust Flatpak

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

Repository

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:

  1. smaller runtime/binary footprint,
  2. lower memory use,
  3. faster local builds,
  4. fewer moving parts,
  5. 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:

  1. CSS cascade, layout, and rendering — a Firefox replacement must draw real pages. Vixen owns layout; keep it WPT-driven and small.
  2. DOM/WebIDL/Web API runtime — modern pages need correct host APIs over deno_core/V8.
  3. Network/security/fetch/cookies — real browsing needs safe, fail-closed loading before breadth.
  4. Storage/history/session — required for real browsing and app-like sites.
  5. Minimal Relm4 desktop shell — focused browser UI, not a feature buffet.
  6. Headless CLI + CDP/Playwright-compatible seams — automation and text reports are product features, not test-only scaffolding.
  7. WPT/imported fixture coverage and reports — correctness driver for every item above. Treat it as cross-cutting, not optional polish.
  8. HTML parsing/serialization — essential but mostly delegated to html5ever; Vixen must preserve tree shape and integration semantics.
  9. CLI ergonomics — keep commands stable, scriptable, and useful.
  10. 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:

  1. 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.
  2. A component seam is not lifecycle integration. Sharing Page, Network, or JsRuntime types is insufficient if frontends decide independently when to create, commit, cancel, persist, or destroy them. Alpha requires a production engine-owned lifecycle.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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/just development gates, stable diagnostics, fuzz targets, and a WPT/fixture harness. The committed manifest currently measures 269 fixtures / 2,015 checks at 100%; COMPAT.md owns the detailed counts.
  • html5ever parsing, 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

  1. 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.
  2. One behavior, many adapters. GUI, headless, CDP, WPT, and page scripts must reach the same navigation, DOM, layout, network, and profile operations.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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-engine and expose it through an evolved vixen-api command/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-Send DOM 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-store use 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:

  1. Accessible browser: accessibility tree, AT-SPI integration, screen-reader smoke, keyboard/caret/selection fidelity, forced colors, reduced motion, zoom, and accessible native controls.
  2. Media platform: GStreamer-backed audio/video, common codecs, controls, captions/tracks, fullscreen/PiP, autoplay/permission policy, Media Source where justified, and WebAudio basics.
  3. Offline/application platform: IndexedDB, Cache Storage, service workers, workers/shared workers, file/blob streaming, notifications, installable apps, and offline lifecycle semantics.
  4. Communications: production WebSocket/EventSource, WebRTC and device permissions, richer streaming/compression, and WebTransport only where demand and security design justify it.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

  1. Finish A2 across redirects, parser/runtime jobs, history/reload races, and asynchronous CDP lifecycle delivery; preserve exactly one terminal outcome.
  2. Move parser-discovered scripts and supported DOM mutations onto the live document/runtime, deleting replaced snapshot/string shims.
  3. Land font discovery/shaping/fallback and image subresource decode as the first broad rendering verticals, then widen layout by failing imported ref tests.
  4. Build the HTTP download lifecycle and shell/CDP events over profile-owned downloads.
  5. 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/just gate.

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:

  1. Workspace Cargo.toml with all 7 crates as members. Root src/main.rs calls vixen_shell::run() (which is a stub for now).
  2. vixen-api populated: Engine trait, EngineDelegate (Send), EngineInspector, EngineProfile, DTOs, EngineDiagnostic shape — per docs/ARCHITECTURE.md.
  3. vixen-shell skeleton: App component with empty FactoryVecDeque<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.
  4. vixen-net, vixen-store, vixen-wpt, vixen-headless, vixen-engine all empty with pub mod placeholder; stubs.
  5. justfile adapted: check-all-host builds the workspace; test-api runs the API crate; gate-phase0 bundles the phase's executable proof.
  6. .gitignore, LICENSE (Apache 2.0), data/, build-aux/ skeleton, fixtures/ (empty), benches/ (empty).
  7. .mise.toml pins the dev toolchain (rust, just, cargo-binstall) so mise bootstrap --yes converges a fresh machine by delegating project work to just setup. The library MSRV (1.88) is in each crate's rust-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); see docs/guidance/gnome-sdk-flatpak-builder.md and mise 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:

  1. 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 of NetworkError.
  2. vixen-net/src/cookie.rs: RFC 6265 jar, every rule in docs/SPEC.md "Cookie contract". Test surface: every rejection rule, every outgoing-header rule, the 512-entry cap, FIFO eviction.
  3. vixen-net/src/url_policy.rs: blocklist per docs/SPEC.md "URL policy", including the precise CGNAT check (100.64.0.0/10, not all of 100/8).
  4. vixen-net/src/csp.rs: directive parser + enforcer per docs/SPEC.md "CSP contract". Test surface: every directive, every source-list grammar element.
  5. vixen-net/src/permissions.rs, origin.rs, fetch_types.rs, http_helpers.rs: small supporting modules.
  6. vixen-store/src/lib.rs: redb-backed persistence, per-origin partitioning, schema per docs/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:

  1. deno_core implementation landed: deno_core/V8 powers vixen-engine::script::JsRuntime and the Phase 2 eval gate.
  2. Stable Vixen seam: keep the public JsRuntime / JsValue seam so headless, CDP, and Page tests do not depend on runtime internals.
  3. Host hook registration, minimum viable: console.log, fetch (delegating to vixen-net::Network), document.title getter. Defer the full DOM/Event/Storage surface to Phase 6.
  4. Host internals follow ADR-014: package Vixen-owned Web API bindings as deno_core extensions — 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:

  1. html5ever parse into RcDom. Already a dependency. Done — see vixen-engine::doc.
  2. Selector matching via Stylo (done) — vixen-engine::style_dom implements selectors::Element over the RcDom (a precomputed ElementArena keeps the module forbid(unsafe_code)). This powers --extract-selector, the WPT selector checks, and the :valid/:invalid/:checked pseudos. Phase 3's gate (WPT CSS fixtures) now passes against the selector surface. The shared vixen-engine::page::Page facade now owns URL + parsed document state for headless and WPT; cascade/layout/paint slices extend that facade in order.
  3. Milestone 1 computed-style cascade projection (done) — Page::computed_style maps the stable selector node_id back to the element and returns a compact author/inline cascade. vixen-engine::style_cascade loads <style> blocks, matches selectors through Stylo's selector engine, applies specificity, source order, cascade layers, media/supports conditions, custom-property var() resolution, inherited custom properties, CSS-wide keywords, and author/inline !important, and keeps the WPT computed-style check vertical behind Page. Page::evaluate_dom_expression also projects small CSSOM smoke seams for getComputedStyle(document.querySelector(...)), CSS.supports(), document.styleSheets, and read-only CSSStyleRule/CSSStyleDeclaration state while full computed values and stylesheet host objects land.
  4. vixen-engine/src/style.rs (next slice): replace the compact projection with full Stylo style data: load <style> / <link rel=stylesheet> into Stylesheet list → Stylist::update_stylist → cascade via Stylo's SharedStyleContext / traversal. Expose computed_values_for(NodeId) -> Arc<ComputedValues>. Requires implementing the full TNode / TElement / TDocument traits; 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.rs for the Stylo trait definitions being implemented.
  5. CSS-wide keywords, @layer, @supports, @media, and custom properties + var() are now covered in the compact Page::computed_style projection 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/currentcolor keywords, premultiplied-alpha arithmetic, and linear-sRGB interpolation (the primitive gradients and transitions reduce to). oklch/lab/lch/color() fail closed with UnsupportedColorSpace (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. x is the historical alias for dppx (CSS Images 4 § 7.3).
  • vixen-engine::ratio — CSS Values 4 § 4.4 <ratio> (number | number / number): the numerator/denominator pair with the quotient the aspect-ratio property and the aspect-ratio / device-aspect-ratio media features reduce to. A zero denominator is the § 4.4 "infinite ratio" encoding; the single-number shorthand means N/1; the legacy Media-Queries-4 integer width/height form 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:

  1. 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.cpp and .tmp/ref/ladybird/Libraries/LibWeb/Layout/*FormattingContext*.
  2. vixen-engine/src/layout_tree.rs: convert the Stylo-computed DOM into an arena-backed layout tree with stable LayoutNodeIds, explicit dirty bits, and no cross-crate pointers.
  3. vixen-engine/src/layout.rs / formatting_context.rs: run block, inline, flex, and grid formatting-context passes over that tree and produce positioned fragments.
  4. Feed positioned fragments into the existing display-list builder; layout never owns a paint backend.
  5. 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. WritingMode is the five § 3.1 values (horizontal-tb / vertical-rl / vertical-lr + the CSS WM 4 sideways-rl / sideways-lr); Direction is the § 2.1 ltr / rtl inline-base direction. Flow bundles 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_endPhysicalSide (the § 7 side mapping table, with the sideways-* reusing the vertical-* axis mapping per § 3.1). LogicalSize::to_physical swaps inline/blockwidth/height for vertical modes; LogicalInsets::to_physical resolves the four logical edges to (top, right, bottom, left); LogicalRect::to_physical resolves 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). The unicode-bidi algorithm + the text-orientation glyph 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 (auto or px) + ColumnCount (auto or ≥ 1) + the ColumnSpec (column-width, column-count, gap) triple. ColumnSpec::resolve runs 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 final max(0, width) guard so a too-large count never produces a negative column. ResolvedColumns::column_x is the i * (column_width + gap) stride the box model feeds off; ResolvedColumns::total_width + ResolvedColumns::overflows report the row geometry (the gaps-alone-overflow case). The column-gap: normal1em length resolution, the § 8 column-fill: balance height balancing, the column-rule paint, and column-span: all stay 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 (none or (axis, strictness); axis x/y/block/inline/both, strictness proximity/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_axis is the § 5 snap position for one axis: the start ⇒ O, end ⇒ O + A − S, center ⇒ O + A/2 − S/2 formula clamped to [0, max(0, overflow − S)]. compute_snap produces the (x, y) pair (the block/inline → x/y mapping via the writing-mode flow flag); should_snap is the strictness policy (mandatory always; proximity iff within a threshold). The scrollable-overflow computation, the scroll animation, the scroll-padding/scroll-margin insets, 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:

  1. vixen-engine/src/paint.rs: single DisplayList type + a WebRender Renderer that consumes a &dyn GlContext (trait defined in vixen-api, see ADR-006). One paint path; the two GlContext implementations are the only thing that varies between GUI and headless.

  2. GlAreaSurface (in vixen-shell): implements GlContext around gtk4::GLArea. Per the GTK4 idiom, GL work happens inside the render signal callback, where GTK has already made the gdk::GLContext current; proc_address dispatches through Gdk's GL loader. This is the GUI path.

  3. SurfacelessSurface (in vixen-headless): implements GlContext via EGL_MESA_platform_surfaceless (or EGL_KHR_surfaceless + pbuffer fallback). Renders into an FBO; glReadPixels extracts RGBA. This is the headless/CI path.

  4. 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_list implements all eight SPEC.md "Display-list invariants" as auditable, individually-tested pure functions (z_tier, effective_opacity, background_paint_rect, …) plus a DisplayListBuilder::build that emits the pruned, z-sorted PaintCommand stream. The WebRender Renderer (this step, next slice) consumes that stream; the invariant logic is done and Rust-unit-tested.

    Vertical display-list slice landed. Page::display_list now turns the Phase 4 layout fragments into the single DisplayListBuilder command stream: viewport background first, then fragment-backed backgrounds/text commands, exposed through vixen-headless --dump-display-list. --paint-stats now aggregates command counts and painted area from that same stream. This is not a renderer or CPU paint fallback; WebRender consumes the same PaintCommand stream once the GL surfaces land.

  5. vixen-shell/src/engine_factory.rs: creates the gtk4::GLArea, wraps it as GlAreaSurface (the shell's GlContext impl), and returns it as the content widget alongside the tab's EngineWorker. The worker's engine renders to the screen via that GlContext.

    Surface scaffolding landed. vixen-shell::surface::GlAreaSurface (behind gtk-shell) and vixen_headless::surface::SurfacelessSurface now implement vixen_api::GlContext. Headless construction still fails closed with unsupported.screenshot until EGL context creation, WebRender command submission, glReadPixels, and PNG encoding land; no CPU fallback or second paint path was introduced.

  6. CI: verify LIBGL_ALWAYS_SOFTWARE=1 produces working screenshots via llvmpipe so 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.3 radial-gradient. RadialShape (Circle/Ellipse) + RadialSize (the four § 4.2.4 keywords closest-side/farthest-side/closest-corner/farthest-corner
    • the explicit Length/LengthPair forms, with farthest-corner the spec default). compute_radius is 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 (the closest-corner/farthest-corner ellipse cases keep the closest-side/farthest-side rx/ry ratio and scale to the corner per the § 4.2.4 corner-scaling rule). project_to_t is the per-pixel (dx, dy)t distance projection (Euclidean for circle, ellipse-norm for ellipse). RadialGradient::sample is the colour at a projected t (with the repeating-radial-gradient() wrap via the shared sample_resolved helper). 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.
  • vixen-engine::conic_gradient — CSS Images 4 § 4.3.3 conic-gradient. ConicGradient carries the stop list + the from <angle> start angle (radians) + the repeating flag. project_angle_to_t is the per-pixel (dx, dy)t ∈ [0, 1) projection (CSS-clockwise from 12 o'clock, in turns — one full revolution = 1.0); add_from_angle folds in the from angle and reduces modulo 1.0. ConicGradient::sample is the colour at a projected t (with the repeating-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. DOMPoint is the 2D/3D/homogeneous (x, y, z, w) point (§ 2; the perspective divide normalises w to 1 when projecting). DOMRect is the (x, y, width, height) rectangle (§ 3) with the derived top/right/bottom/left accessors + the negative-dimension DOMRect::normalized flip + the contains_point / intersects / union predicates getBoundingClientRect() + IntersectionObserver consult. DOMQuad is the four-corner quadrilateral (§ 4) with the from_rect constructor + the DOMQuad::bounds axis-aligned bounding rectangle (§ 4.4). DOMMatrix is the § 6 4×4 homogeneous matrix (the 2D matrix(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.4 DOMMatrix::transform_point homogeneous-coordinate projection + the is_2d predicate + the to_4x4_column_major round-trip. Matrix decomposition / interpolation (the CSS Transforms 2 § 16 pipeline the animation interpolation layer reduces to) and the full transform property 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().length reports whether layout produced a box, client/offset/scroll metrics read the same rect, getBoxQuads() projects a DOMQuad from 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(), and DOMMatrix transform/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, multiply composition (post-multiply ⇒ rightmost-applied-first, matching Firefox/Servo), apply_point/apply_rect (AABB), determinant/inverse, plus a parse_transform list parser for the --computed-style projection. Consumes vixen-engine::angle so 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 (via crate::color::interpolate), and the repeating-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.2 box-shadow geometry: 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_rect for display-list culling; inset_clip_rect for the inset "hole" with the spec's spread-sign-flip + blur-shrinks-hole rule). Pure given px values; the cascade resolves percentages / em first.
  • 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 rightright top), and the § 4.2 formula (container − image) * fraction + offset. Pure given px sizes; the cascade resolves the background-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_descendant slots each descendant into one of ContextBackgroundAndBorders / NegativeZChildren / InFlowBlockLevel / NonPositionedFloats / InFlowInlineLevel / PositionedZeroZ / PositiveZChildren, in bottom-to-top paint order). Composes with display_list::z_tier for 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::CompositingOperator with the § 5.1 general formula + per-operator Fa/Fb factors) and the 16 § 10 blend modes (blend::BlendModenormal + 11 separable § 10.1 + 4 non-separable § 10.2, with the SetLum/SetSat/ClipColor helpers). blend::composite evaluates one operator; blend::blend applies one mode to a pixel; blend::composite_blend runs the § 5.2 combined pipeline (isolation blend against the backdrop, then the Porter-Duff operator) that mix-blend-mode actually performs. All arithmetic is in linear sRGB via blend::LinColor (reusing color::Color::to_linear_f32).
  • vixen-engine::filter — CSS Filter Effects 1 § 5: the <filter-function- list> grammar + the per-pixel colour-matrix family. filter::FilterList parses 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 one filter::ColorMatrix (SVG feColorMatrix-shaped 4×5) via filter::compose_color_matrix so the paint path runs a single matrix multiply per pixel; blur/drop-shadow keep their geometry for the paint path's spatial pass (drop-shadow reuses box_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 the border-image-repeat tiling primitive (border_image::tile_edgestretch/repeat/round/space, with the round integer-count rescale and the space even-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 § 5 clip-path basic shapes. ClipPath is the typed family (ClipPath::Inset / ClipPath::Circle / ClipPath::Ellipse / ClipPath::Polygon / ClipPath::None); Coord is the at <position> coordinate (px / percent / keyword) with Coord::resolve against a reference box; GeometryBox is the <geometry-box> reference selector. parse_clip_path parses the four basic shapes (case-insensitive function name, parenthesised args, the inset(… round <radius>) form reuses BorderRadius, the polygon(<fill-rule>, …) form carries FillRule::NonZero / FillRule::EvenOdd). ClipPath::contains is 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). The path() SVG-path form is deferred (the four geometric shapes cover the common HTML surface).
  • vixen-engine::mask — CSS Masking 1 § 6 mask shorthand per-layer model. MaskMode (alpha/luminance/match-source), MaskRepeat (the 6 repeat styles, repeat-x/repeat-y collapsed), MaskBox (the shared mask-clip + mask-origin keyword set, no-clip clip-only), and MaskLayer (one layer's resolved longhands). parse_mask splits 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. EffectTiming carries the § 5.4 timing properties (delay / end_delay / fill / iteration_start / iterations / duration / direction); Fill is the none/forwards/backwards/both fill mode; PlaybackDirection is the normal/reverse/alternate/alternate-reverse direction. active_duration + end_time are the § 5.3 derived times; phase is the § 5.5 before/active/after classification; simple_iteration_progress + current_iteration are the § 5.5 iteration progress + index (the after-phase iterations = 0 and integer-boundary progress = 1 rules folded in); directed_progress is the § 5.6 direction-aware progress; apply_easing is the § 5.7 transformed progress (consumes crate::easing::Easing); compute_timing ties the pipeline together into a ComputedTiming with the fill-mode before/after resolution (backwards/both ⇒ the iteration-0 start in before; forwards/both ⇒ the end state in after; else None). The keyframe value interpolation + the animation-frame scheduling + the auto duration resolution stay in the paint / event-loop layer (this module produces the progress they sample at).

Phase 6 — Host bindings (≈ 2 weeks)

Register the DOM/Event/Storage/Network host hooks the modern web needs. Priority order:

  1. DOM Core: document, Node, Element, HTMLElement, attribute accessors, querySelector*, getElementsByTagName, classList, dataset.
  2. Events: Event, EventTarget, addEventListener, removeEventListener, dispatch, capture/target/bubble, focus/click/ input/submit/change, composedPath(), composed event dispatch order per docs/SPEC.md.
  3. Forms: HTMLInputElement, HTMLFormElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement, ValidityState (11 flags per docs/SPEC.md), checkValidity, reportValidity, setCustomValidity, form submission algorithm.
  4. Storage: localStorage, sessionStorage against vixen-store, per-origin partitioning.
  5. 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_pathcomposedPath() (shadow-boundary aware via the composed flag) and the focus-transition ordering focusout → 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 in date_units until a proper parser lands" → landed): date/time/week/month/datetime-localDateTimeUnit, so stepMismatch is 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) StoragePartition key the vixen-store redb tables partition under, plus the per-partition StorageQuota (5 MiB / 8 192 entries) the host hooks report QuotaExceededError against.
  • Page::evaluate_dom_expression now projects the read-only document/navigator state shape (readyState, compatMode, visibility, documentURI/baseURI, focus/active element, viewport/window/screen state, language/userAgent). The runtime Storage host object now crosses explicit deno_core ops for in-memory localStorage/sessionStorage mutation 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 persisted vixen-store::PermissionRecord decisions by origin, returning prompt / default for unknown decisions and rejecting unsupported names; navigator.storage.estimate() reports bounded local-storage usage. JsRuntime now 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 runtime fetch() MVP now crosses an explicit op into vixen-net, returns real HTTP(S) Response status/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, and Element.closest()) before full Node / Element JS host wrappers replace the string projection.
  • vixen-engine::script::JsRuntime::evaluate_with_page now installs the first deno_core document snapshot host-object seam for focused evals: document.title, document.body.textContent, simple querySelector/getElementById element properties and attributes, and querySelectorAll().length, plus read-only DOMTokenList (classList / relList / sandbox) and DOMStringMap (dataset) property reads. The remaining broad DOM smoke surface still fails closed through Page::evaluate_dom_expression until each family moves behind real wrappers.
  • Page::evaluate_dom_expression now projects constructor smoke for Event / CustomEvent and element dispatchEvent(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 the FormEntry / FormEntryValue data model + FormEnctype selector. The URL-encoder uses the URL Standard's space→+ + uppercase-hex percent-encoding; the multipart encoder handles RFC 7578 § 4.2 Content-Disposition quoting + filename + Content-Type per part, with CRLF discipline; the boundary generator is RFC 2046-capped.
  • Page::evaluate_dom_expression now reuses the same form entry-list builder for a read-only FormData(form) smoke seam (get/getAll/has, iterator first-entry shape, plus file name/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 submitter formaction / formmethod / formenctype overrides share the navigation path. Runtime form reset restores default value/checked/selected state and honors cancelable reset events; mutable FormData remains a narrow in-realm helper until full file/body plumbing lands.
  • vixen-engine::dataset — WHATWG HTML § 3.2.6.9 data-* attribute ↔ dataset property-name bidirectional mapping (deserialise, serialise, collect), with the anti-collision rule (- followed by uppercase ⇒ not exposed).
  • Page::evaluate_dom_expression and the transitional document snapshot now use that same dataset mapper for the read-only smoke surface (element.dataset.fooBar / bracket access), proven by fixtures/dom/dataset.html js-eval checks while the WPT adapter continues to use the Page projection.
  • Page::evaluate_dom_expression also projects ValidityState flags, willValidate, and checkValidity() / reportValidity() from the pure forms module for fixture smoke coverage until the Phase 6 form host objects land.
  • Page::evaluate_dom_expression projects Element.innerHTML / outerHTML getters through vixen-engine::html_serialize, proving the HTML serialisation host-object seam with WPT js-eval checks before mutation setters and Trusted Types enforcement land.
  • Page::evaluate_dom_expression also 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-class style_dom resolves today (:checked/:disabled/:enabled/:required/ :optional/:read-only/:read-write) plus the Page-backed validity eval seam; wired into fixtures/manifest.json.
  • fixtures/dom/dataset.html — exercises the canonical data-foo-barfooBar surface the host-hook layer will reflect; wired into fixtures/manifest.json.
  • fixtures/forms/submission.html — fixes the form-DOM input shape the three encoders and FormData projection walk; wired into fixtures/manifest.json.

Pure-logic foundation landed for the DOMTokenList surface (Phase 6 prep).

  • vixen-engine::class_list — WHATWG HTML § 4.6.4 DOMTokenList + 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 / toggle with the force parameter / replace with 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-token add/remove aborts the whole call without partial mutation). The optional SupportedTokens set is the surface <link>.relList.supports(token) consults (the only DOMTokenList with 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-sensitive Foo/foo/FOO distinction, the multi-value <link rel> form) and now asserts the read-only classList / relList eval seam; headless/CDP focused evals run through the current JS runtime seam while the WPT adapter continues to use the Page projection; wired into fixtures/manifest.json.
  • fixtures/security/sandbox.html now also asserts the Page-backed iframe.sandbox DOMTokenList projection (length/contains/item), with focused headless/CDP evals now backed by runtime DOMTokenList snapshots, 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 Standard URLSearchParams (§ 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) the new 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 the application/x-www-form-urlencoded byte set with form_submission::encode_urlencoded (kept separate because the specs are).
  • Page::evaluate_dom_expression now exposes a read-only URL.canParse() / new URL() / URLSearchParams smoke seam (href/origin/components / toString() / searchParams, record-list constructor, two-argument has, and first iterator entry/key/value shape) from whatwg_url + url_search_params, proven by fixtures/network/url-parsing.html.
  • vixen-engine::mime — WHATWG MIME Sniffing § 2.1 MimeType::parse + § 2.2 serialize + the essence() 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_into reports UTF-16-code-unit read + byte written without splitting a scalar value; the Page seam and the current runtime host-constructor pilot both parse the TextDecoder constructor label/options dictionary (fatal, ignoreBOM); decode does the BOM sniff (ignoreBOM opt-out), the fatal-flag UTF-8 validation, the WHATWG § 4.6 one-U+FFFD-per-maximal-subpart replacement (via from_utf8_lossy, which agrees with the WHATWG count), and the § 7.1 CRLF/lone-CRLF line-break normalisation. v1 ships UTF-8 only; unknown labels fail closed.
  • The same fixture set now asserts the Page-backed TextEncoder / TextDecoder, atob/btoa, and DOMParser.parseFromString(..., "text/html") smoke seams (encoding, encoded byte length, encodeInto read/written, TextDecoder label/options, decode, base64 round-trip, parsed document query) while vixen-headless --eval / CDP Runtime.evaluate now exercise op-backed runtime TextEncoder / TextDecoder constructors plus focused document / Element / read-only DOMTokenList / DOMStringMap snapshot extension objects backed by the same Page data. script::webidl now 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 the deno_core op boundary via op_vixen_dom_snapshot; selector lookup and Element.matches() now use finer-grained DOM ops, and element record data is loaded through op_vixen_dom_element_snapshot. Element text/attribute reads plus read-only DOMTokenList/dataset data now use focused DOM ops, and element getBoundingClientRect() / getClientRects() / getBoxQuads() geometry reads plus client/offset/scroll metrics now cross a focused DOM rect op. Focused CSS.supports, getComputedStyle, and CSSStyleSheet/CSSRule smoke evals now use the op-backed script::cssom extension, 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.2 Headers object data model: validate_header_name (RFC 9110 § 5.5 token + lowercasing) + validate_header_value (OWS trim, NUL/CRLF rejection, code-point-≤ U+00FF gating); the § 3.2.2 forbidden predicates is_forbidden_request_header (the exact 21-name list + the proxy-/sec- prefix rules the Request constructor strips) + is_forbidden_response_header_name (set-cookie/set-cookie2); the § 3.2.1.2 CORS-safelist predicate is_cors_safelisted_request_header (the Accept/Accept-Language/ Content-Language/Content-Type(+Range) family with the value-byte cap, the CORS-unsafe-byte gate, and the MIME-essence + Range grammar checks); and the normalised Headers store (append/set/get/getAll/delete/has + comma-combine on read + byte-order + insertion-order iteration).
  • vixen-engine::abort — DOM § 8.1 AbortController/AbortSignal: the aborted + reason value model (default reason = "AbortError" DOMException), AbortController::abort (idempotent, first-reason-wins), abort_any (§ 8.1.3.2 AbortSignal.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), and TimeoutSignal (§ 8.1.3.2 AbortSignal.timeout(ms) request record with the zero-delay-aborts- synchronously rule).
  • Page::evaluate_dom_expression now projects read-only Headers, Blob/File, Request, Response, AbortController/AbortSignal, and URLPattern smoke 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::webapi now exposes a minimal fetch(input, init) host function. It normalizes through Request, validates via vixen-net::validate_http_url, routes HTTP(S) through vixen-net::Network, and resolves to Response with status, headers, URL, redirect flag, and text body; policy/network failures reject the promise as TypeError and emit stable fetch failure events for CDP Network.loadingFailed diagnostics.

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 (f64 ms), the per-global TimeOrigin (ms since Unix epoch that performance.now() is relative to), the § 4.4 MonotonicClock (non-decreasing across calls + clamped to ≥ 0), the § 4.4 coarsen effective-time-value coarsening (floor to 100µs unless cross-origin isolated), and the performance.now() → Unix-epoch conversion (timeOrigin + now) the legacy PerformanceTiming surface reduces to.
  • Page::evaluate_dom_expression now projects Performance API smoke checks for typeof performance.now(), non-negative performance.now(), and monotonic timeOrigin + now shape 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"> content parser: the comma-separated <name>=<value> declaration set (width/height device-keyword or CSS-px number, initial-scale/ minimum-scale/maximum-scale clamped to [0.1, 10], user-scalable yes/no, viewport-fit auto/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-worker FetchEvent routing, and the new URLPattern() host hook reduce to. URLPattern::compile parses the pathname-grammar subset (literal segments, :name named 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_pathname is a full-match (segment-based, empty-segment-collapsing so /posts/posts/, :name captures one non-empty segment, * captures the rest joined by /). The protocol/hostname/port/search/hash components
    • full-regex custom params (:name(\\d+)) land with the host hook; the named/* subset covers real routing.
  • The Page eval seam exposes that pathname subset through new URLPattern({ pathname }) test() / exec().pathname.groups smoke 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) and parse_non_negative_integer (§ 2.4.3) with saturating overflow so colspan/rowspan/tabindex/cols/maxlength never panic; parse_float (§ 2.4.5) — the lenient leading-numeric-prefix extractor ("100px"100.0, "3e999"+∞) that <input type=number> and the value sanitization algorithm build on; parse_dimension_value (§ 2.4.6) — the legacy <td width> / <img width> surface producing either a pixel length or a percentage; and parse_list_of_integers for <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.7 Nw/Nx descriptor 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 2397 data: URL parsing: the case-insensitive scheme check, the ;base64 flag (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-sniff data: URLs, so the declared mediatype is exposed verbatim. Base64 decoding uses the vetted base64 crate (pure-Rust, unsafe-free), shared by vixen-engine (data URLs) and vixen-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 into fixtures/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) with and/or/not combinators, the <media-type> prefix (screen/print/all with not/only), and the min-/max- prefix decode into a Range constraint (min-widthwidth >=). MediaQuery::matches evaluates against a Viewport (CSS-px width/height, DPR, derived orientation, output context (screen/print), colour depth, primary hover/pointer, aggregate any-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's 100vw default. 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 parsed srcset with a resolved source size + the viewport DPR. Computes per-candidate pixel density (width ÷ source-size for Nw, the x value for density, implicit 1x for bare), rejects mixed width/density lists (§ 4.8.4.6 parse error), keeps candidates with density ≥ DPR (falling back to all if that empties the list), and picks the smallest surviving density (ties → document order). The select_source helper 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 into fixtures/manifest.json.
  • Page::evaluate_dom_expression now projects the read-only <img>.currentSrc smoke surface for plain srcset/sizes images from responsive_select, plus Page-backed HTMLImageElement alt/dimension/loading/decoding/complete/decode reflection and a matchMedia() .matches / .media seam from media_query, proving selected-image URL reflection and MediaQueryList shape until the full image resource fetch path lands.
  • fixtures/dom/media.html covers the adjacent inert media-control seam: HTMLMediaElement / audio / video identity/constants, booleans, timing/volume state, canPlayType(), and promise-shaped play() without claiming codec/decode support.
  • fixtures/dom/resources.html covers resource-element reflection for link / style / script / source attributes, stylesheet ownership, and HTMLScriptElement.supports() while parser-discovered script execution remains a separate host-runtime slice.
  • fixtures/dom/dialog-details.html covers details/dialog open-state reflection, including show() / showModal() / close() / requestClose() state updates and the close-event automation hook.
  • fixtures/dom/reflected-misc.html widens 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.html covers progress/meter numeric host state (value / min / max / low / high / optimum / position) plus label associations for status-control automation probes.
  • fixtures/dom/canvas.html adds an inert Canvas 2D context seam: default canvas dimensions, cached getContext('2d'), no-op drawing calls, measureText(), createImageData(), and deterministic toDataURL() smoke until the real raster surface lands.
  • fixtures/forms/reflected-attrs.html widens form-associated host reflection: form metadata, submitter override attributes, input valueAsNumber plus stepUp() / stepDown(), textarea setRangeText() / textLength, and custom validity message smoke.
  • fixtures/dom/table-collections.html adds read-only table structure smoke for caption, tHead, tFoot, tBodies, rows, cells, and row/cell indexes, giving automation a table traversal seam before mutation APIs land.
  • fixtures/dom/html-element-attrs.html covers 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.html covers inert media text-track state: HTMLTrackElement reflected attributes, stable track objects, TextTrack, and TextTrackList lookup from media elements.
  • fixtures/dom/offscreen-canvas.html covers adjacent inert canvas adjunct APIs: ImageData, OffscreenCanvas 2D context identity, blob/bitmap promises, and no-op Path2D construction until real raster/bitmap transfer lands.
  • fixtures/dom/shadow-root.html covers the first Shadow DOM host-object shape: attachShadow(), ShadowRoot / DocumentFragment identity, host/mode fields, empty fragment queries, and document.createDocumentFragment() before composed-tree distribution/layout lands.
  • fixtures/dom/template-slot.html covers the adjacent web-component host shape: HTMLTemplateElement.content as a DocumentFragment plus slot name, assignedNodes(), and assignedElements() methods before slot distribution is wired into the composed tree.
  • fixtures/dom/construction-serialization.html covers DOM construction and serialization helpers (createElementNS(), text nodes, fragments, and XMLSerializer.serializeToString()) for framework feature-detection probes.
  • fixtures/dom/platform-apis.html covers browser-platform host probes that now run in the deno_core page realm: secure crypto.getRandomValues() / randomUUID(), async Clipboard text / ClipboardItem, first-callback IntersectionObserver / ResizeObserver geometry, and fail-closed WebSocket close 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 § 10 calc() / min() / max() / clamp() arithmetic tree + evaluator. A recursive-descent parser produces a CalcNode AST (Number / Length / Percent / Add/Sub/Mul/Div / the § 10.1 Min/Max/Clamp math functions); evaluate runs 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 classic calc(50% + 10px) form, resolving to (px, percent) against a LengthContext. Operator precedence (*// over +/-) and nested parenthesised grouping enforced; bare expressions (no calc() wrapper) parse too, so the --computed-style projection 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::parse covers 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::evaluate projects 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-both with the jump-none-requires-n ≥ 2 validation), and piecewise-linearly interpolates the linear() 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/square bullet glyphs, decimal/decimal-leading-zero numeric, the lower-alpha/ upper-alpha (+ lower-latin/upper-latin aliases) bijective base-26 alphabetic, lower-roman/upper-roman additive, lower-greek over the 24-letter alphabet, none). ListStyleType::render is the value → text projection per the § 6.1 algorithm table; the § 6.1 fallback rule (out-of-range additive/alphabetic values fall back to decimal, 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 — 0 for reset/set, 1 for increment) + CSS Lists 3 § 5 counter() / counters() resolution. parse_counter_ops tokenises the counter-* declaration value (ASCII-whitespace-separated <custom-ident> optionally followed by one <integer>, the none no-op, saturating integer overflow, the --foo CSS-variable reservation rejected); resolve_counter reads the innermost in-scope value (or None → empty marker per § 5); resolve_counters joins 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 with list_marker via render_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. StructuredCloneValue is the type-tagged tree of serialisable values (primitives, Date, Array, Object, Map, Set, ArrayBuffer, MessagePort, Error with the ErrorKind subclass family, and the PlatformObject slot reserved for File/Blob/ImageData &c.). clone deep-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 a SharedArrayBuffer clone (not transfer) requires a cross-origin- isolated context (DataCloneError::SharedBufferRequiresIsolation — the gate is_cross_origin_isolated feeds). detach_transferred flips the transferred ArrayBuffers to detached in the source tree; SharedArrayBuffers stay shared. is_cloneable is the partial-check a host hook calls before walking (so a DataCloneError surfaces 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.5 MessagePort / MessageChannel. MessagePort is one end of an entangled pair (the PortId handle appears in StructuredCloneValue::MessagePort and the transfer list); MessageChannel::new constructs the pair. MessagePort::post_message runs the § 9.5.4 steps: structured-clone the value (honouring the transfer list), and return the clone + the partner id + the transferred ports in a PostOutcome (the host hook routes the enqueue to the partner — the two ports may live in different compartments / workers). MessagePort::enqueue / MessagePort::drain are the receiver-side inbox + the event-loop hand-off; start() / close() carry the § 9.5.3 / § 9.5.5 lifecycle (a detached port drops postMessage and drains nothing).
  • Page::evaluate_dom_expression now projects a small structuredClone() smoke seam for primitive strings, arrays, shallow objects, Date, Map, Set, and Error name/message shape through the same clone function that postMessage() / history state will call. Runtime MessageChannel and BroadcastChannel smoke now dispatch MessageEvent through the same EventTarget/WebIDL host layer while the pure Rust message_port model 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.2 Range + § 5.4 Selection. NodeRef is an opaque DOM-node handle carrying a DocumentOrder index (the pre-order DFS position the caller assigns) so two boundaries compare in document order by pure arithmetic. Boundary is the (node, offset) pair (child index for elements, UTF-16 index for text nodes); Boundary::compare is the § 5.2 relative position (Ordering::Before / Ordering::Equal / Ordering::After). Range carries the (start, end) pair with Range::new re-ordering to the § 5.2 start ≤ end invariant, Range::is_collapsed + Range::collapse + Range::contains_boundary + Range::intersect. Selection carries the Range list + the anchor/focus (direction-aware) + add_range / collapse_to / extend_to / remove_all_ranges + the SelectionDirection (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_expression now projects the read-only initial Range / Selection smoke seam (document.createRange().collapsed / offsets and empty getSelection() 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. ScrollRestoration is the auto/manual history.scrollRestoration mode; HistoryEntry is one session-history entry (URL string + opaque structured-clone state blob
    • the scrollRestoration mode + the optional title). SessionHistory is 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 the with_entries escape 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 for pushState/replaceState, and the structured-clone serialisation of the state value stay in the navigation layer / host hook (the host hook serialises via crate::structured_clone before calling pushState).
  • Page::evaluate_dom_expression now projects the read-only initial History smoke seam (history.length, history.state, history.scrollRestoration) from SessionHistory::new(HistoryEntry::navigation(_)) before mutable pushState/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. MutationType is the three childList/attributes/characterData record types; MutationRecord is one record (target + added/removed nodes + siblings for childList + attribute name/namespace + oldValue); MutationObserverInit is the § 4.3.1 observe() options (childList/attributes/attributeFilter/ attributeOldValue/characterData/characterDataOldValue/subtree). Relation (Target/Descendant) + should_observe is the § 4.3.1 match predicate (the options vs the mutation type + the target/subtree relation + the attribute filter). MutationObserver carries 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_expression now 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 DOM nodeType codes) + WhatToShow (the § 6.1 whatToShow bitmask, SHOW_* constants + SHOW_ALL) + FilterResult (FILTER_ACCEPT/ FILTER_REJECT/FILTER_SKIP) + the NodeFilter trait (the JS callback the host hook implements) + the Tree trait (the host hook's tree access). TreeWalker is 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_REJECT skips the rejected node's subtree, FILTER_SKIP traverses into it. NodeIterator is the § 6.3 flat preorder iterator (next_node/previous_node) where REJECT == SKIP (the flat cursor has no subtree state), plus the adjust_for_removal step 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 JS NodeFilter callback invocation stay in the host hook.
  • Page::evaluate_dom_expression now projects the whatToShow-only element traversal smoke seam for document.createTreeWalker() and document.createNodeIterator() by adapting the parsed document to the traversal module's Tree trait.

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. Url carries the parsed components (scheme / username / password / host / port / path / query / fragment); is_special_scheme + default_port encode the § 3.1 special-scheme family (http/https/ws/wss/file). parse parses an absolute URL; parse_with_base is the § 4.6 relative-resolution parser (absolute-path / relative-segment merge / query-only / fragment-only / scheme-relative against a base Url). Url::serialize is the § 4.1 canonical serialiser (the default port omitted, IPv6 re-wrapped in [...], the opaque-path no-slash form for non-special schemes); Url::origin is the § 4.5 (scheme, host, port) tuple the fetch / storage layers partition on. percent_encode + the EncodeSets (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 named whatwg_url (not url) so it doesn't shadow the extern url crate 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_children is the Element.innerHTML getter (the § 13.2.9 fragment serializer over a node's children); serialize_node is the Element.outerHTML getter (one node + descendants). escape_text (§ 13.2.9 step 8: &&amp;, <&lt;, >&gt;, NBSP → &nbsp;) and escape_attribute (§ 13.2.9 step 5: &, ", NBSP) are the escape rules exposed standalone for the editing-command surface. Scripting is the scripting-flag toggle (the noscript element is raw-text when scripting is enabled, the production case; normal-text otherwise, the DOMParser / 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 + conditional noscript) are the § 13.2.9 step 3 classification. The pre-serialisation tree mutation for the innerHTML setter (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:

  1. CSP enforcement at script.rs::evaluate (script-src / unsafe-inline / nonce / hash) and at fetch (per-directive URL matching).
  2. Cookie validation already done in Phase 1; confirm document-side boundary (document.cookie cannot set HttpOnly).
  3. URL policy re-applied at every fetch, including JS-initiated fetch / XHR.
  4. Origin isolation confirmed across storage, scripts, cookies.
  5. Permissions API behaves per spec.
  6. just audit passes.
  7. Fuzz targets: url_policy, csp::parse, html5ever parse, the cookie parser. Each runs 1 M iterations without panic.

Pure-logic foundation landed (Phase 7 prep).

  • vixen-net::referrer_policy — Fetch § 3.4 Referrer-Policy parser (last-known directive wins) + § 4.3.7 resolve_referrer covering every policy branch (downgrade suppression, same-origin gating, origin-only, strict-origin-when-cross-origin default) + the is_potentially_trustworthy test the downgrade rules reduce to. The network layer attaches the resolved Referer once wired.
  • vixen-net::strict_transport_security — RFC 6795 § 6.1 HSTS header parser (case-insensitive directives, tolerant whitespace, header ignored without valid max-age, max-age=0 cache-deletion signal) + § 8.2 HstsEntry::matches (exact host or, with includeSubDomains, a dot-prefixed subdomain — the superdomain rule is one-way).
  • vixen-net::cors — Fetch § 3.2.1 Access-Control-* response-header parser (case-insensitive names, lowercased + de-duplicated lists, repeated origin header first-wins), § 4.1.5 cors_check (wildcard + credentials forbidden, specific-origin string equality, null-origin echo), and § 4.1.6 cors_filtered_headers (safelist of 7 response headers + named exposes, with Set-Cookie/Set-Cookie2 always 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-content CSP overrides upgrades. Reuses referrer_policy::is_potentially_trustworthy for 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 into fixtures/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 into fixtures/manifest.json.

Pure-logic foundation landed for <iframe sandbox> (Phase 7 prep).

  • vixen-net::sandboxing — WHATWG HTML § 4.8.5 sandbox-flag parser (the full allow-* 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), and is_dangerous_scripts_plus_same_origin (the famous "if both allow-scripts and allow-same-origin are present, the sandbox is escapable" warning the spec mandates).
  • fixtures/security/sandbox.html — exercises every sandbox variant 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 into fixtures/manifest.json.

Pure-logic foundation landed for Sec-Fetch-* + Permissions Policy (Phase 7 prep).

  • vixen-net::sec_fetch — Fetch § 3.1 Sec-Fetch-* request-metadata parsing: SecFetchSite / SecFetchMode / SecFetchDest / SecFetchUser typed enums (case-sensitive token parse, fail-closed to [Default] on unknown values) + a bundled SecFetchHeaders::parse over a (name, value) iterator (case-insensitive names, last-wins combine). The § 3.2.4 classify_site classifier 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; the same-site registrable-domain comparison uses the last-two-labels heuristic (documented limitation; the PSL lands when the cookie domain matcher needs it too). SecFetchDest::is_navigation / is_embed predicate the § 4.4 navigation and § 3.2 COEP checks.
  • vixen-net::permissions_policy — Permissions Policy 1 § 3.3 Permissions-Policy response-header parser + the § 5.2 <iframe allow> attribute parser. The Allowlist enum covers every § 3.3 source-list form (Everyone * / Self_ self / Src src / Origins(list) / None ()-deny-all); PermissionsPolicy::allows is the § 4 evaluation the host hooks consult before exposing navigator.geolocation/camera/ &c. (features not in the policy default to embedder-only per § 3.3). The structured-field parser is paren/quote-aware (handles geolocation=(self "https://partner.test") and the iframe shorthand camera '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, the self/src keywords, explicit origin lists, the empty () deny-all, the camera/geolocation/ microphone/fullscreen/autoplay family); wired into fixtures/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.2 Sec-WebSocket-Accept = base64(SHA1(key + GUID)), via the sha1 crate — already transitively present), validate_client_handshake (§ 4.1 the server-side Upgrade/Connection/Sec-WebSocket-Version: 13/16-byte-key enforcement) + validate_server_response (§ 4.2.2 the client-side 101 + 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 ≤ 125 bytes + 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 + the WebSocket JS host hook sit on top; permessage-deflate is 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.8 Cross-Origin-Opener-Policy parser. The three § 7.8.4 policy values (coop::Coopunsafe-none default / same-origin-allow-popups / same-origin) via the § 7.8.1 structured-header item parse (case-insensitive token, unknown ⇒ UnsafeNone fail-closed, report-to parameter captured). coop::Coop::isolates_opener is the § 7.8.4 opener-isolation predicate the navigation layer consults before reusing a browsing-context group.
  • vixen-net::coep — Fetch § 3.2 Cross-Origin-Embedder-Policy parser. The three § 3.2 policy values (coep::Coepunsafe-none default / require-corp / credentialless) via the structured-header item parse. coep::is_cross_origin_isolated is the HTML § 7.2 combined gate: true iff the COOP is same-origin and the COEP is require-corp or credentialless. This is the boolean MonotonicClock::now's cross_origin_isolated parameter receives, removing the 100µs coarsening 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. HashAlgorithm is the three SRI-mandated algorithms (sha256 / sha384 / sha512); SHA-1/MD5 are collision-broken and dropped at parse time per spec. parse_integrity splits ASCII-whitespace-separated <algo>-<base64> entries (+ the optional ?<options> tail, parsed but not enforced in v1). verify computes each hash over the raw response body via the vetted sha2 crate + a constant-time compare (a timing oracle can't recover the digest); any match passes (the spec's "best candidate" rule). The IntegrityOutcome (NoMetadata / Verified / Mismatch / NoKnownAlgorithms) drives the fetch layer's block.
  • vixen-net::nosniff — Fetch § 2 X-Content-Type-Options: nosniff enforcement. is_nosniff is the case-insensitive token parse (the parameterised historical form is rejected); is_javascript_mime is the Fetch § 3.7 16-entry JavaScript-MIME-type predicate; Destination collapses the § 3.1.7 request destination to the two nosniff-relevant categories (Script / Style / Other); enforce blocks a Script destination whose MIME is not a JavaScript MIME type and a Style destination whose MIME is not text/css, returning the NosniffOutcome (Allow / BlockScript / BlockStyle) the fetch layer surfaces as a network error. Other destinations are unaffected (the spec intentionally limits nosniff'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. Corp is the same-origin / same-site / cross-origin value (parse_corp case-insensitive, None for an absent / unparseable header). is_same_site is the § 4.5.3 same-site predicate (same scheme + matching registrable domain; the last-two-labels eTLD+1 heuristic the PSL refines later); check_corp is the § 4.5.3 check (Allow / Block, opaque origins fail closed). coep_corp_gate is 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-CORP check_corp; credentialless ⇒ same-origin allow, cross-origin AllowWithoutCredentials. The CORS check itself + the COEP parse stay in crate::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. TrustedTypeKind is the three TrustedHTML/TrustedScript/TrustedScriptURL value kinds; AllowedNames is the trusted-types directive's policy-name set (None/Explicit(list)/Wildcard); TrustedTypesPolicyNames carries the set + the allow-duplicates flag; RequireFor is the require-trusted-types-for 'script' flag (the only sink-group in v1, covering every TT sink). parse_trusted_types + parse_require_trusted_types_for parse the two directives; policy_creation_allowed is the § 3.2.3 createPolicy(name) gate (the allowed-name match + the duplicate-name block); evaluate_sink is the § 3.3.5 injection-sink decision (a Trusted* value ⇒ Allow; a string at a TT-requiring sink ⇒ ApplyDefaultPolicy if a default policy exists else Block; a string at a non-TT sink ⇒ Allow). The JS TrustedTypePolicy factory + the createHTML/createScript/ createScriptURL sanitisers + 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:

  1. Implement CDP server (tokio + tokio-tungstenite) in vixen-headless. Command handlers call into vixen-engine via the EngineInspector trait.
  2. Implement every CLI flag from docs/SPEC.md "Headless CLI surface". Stable error codes preserved exactly.
  3. Implement --memory-stats, --paint-stats, --incremental, --list-fonts, --cdp. (Note: --gpu is omitted per ADR-003 — every render path is GPU-backed.)
  4. --cdp responds 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:

  1. Module size audit: no non-test module > 1,000 lines. Decompose where needed.
  2. Dead-code removal pass: cargo machete, fix every clippy warning, audit #[allow(dead_code)] annotations.
  3. 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.
  4. Binary size measurement: just size-fp. Confirm targets per docs/ACCEPTANCE.md.
  5. WPT target profile from docs/COMPAT.md is green. Migrate remaining end-to-end CSS+DOM assertions out of Rust tests where an HTML fixture can cover the behavior.
  6. Update docs/COMPAT.md with measured pass counts, known gaps, and the next-release WPT expansion plan.
  7. 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:

  1. Re-measure with the ADR-014 deno_core runtime. V8 packaging changed the pre-migration size assumptions; current release promises must come from measured binaries.
  2. [profile.release] is already optimal (see docs/ARCHITECTURE.md).
  3. Feature-gate aggressively: CDP, devtools UI, keychain integration. Each behind a feature. Default build includes none of them.
  4. 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.
  5. One paint path, not N. ADR-003/ADR-006 enforce this: no tiny-skia, no fontdue, no parallel CPU rasterizer, no PaintBackend trait.
  6. Per-release measurement in docs/ACCEPTANCE.md.

Targets:

BinaryTarget
vixen (GUI)TBD after deno_core/V8 measurement
vixen-headlessTBD 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

RiskLikelihoodImpactMitigation
Stylo integration harder than estimatedMediumHighTime-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 churnMediumHighKeep JsRuntime seam stable, measure release binaries with V8, and document cache/pinning in guidance.
EGL surfaceless unavailable on some CI runnersLowMediumLIBGL_ALWAYS_SOFTWARE=1 + Mesa llvmpipe covers every Linux runner.
gtk4::GLArea context sharing with WebRenderMediumHighValidate in Phase 5 first week. Fallback: render to FBO, blit to GLArea with a tex.
Vixen-owned layout takes longer than plannedHighHighKeep Phase 4 vertical through Page; ship only the WPT-profiled v1 subset and document gaps in docs/COMPAT.md (ADR-013).
JS host-extension churnMediumMediumKeep 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/FirefoxLowMediumUpstream issues; report and work around. Document in docs/COMPAT.md.
WPT migration backlog grows during buildMediumMediumPer-phase gate: each phase deletes Rust tests at the rate it adds WPT fixtures.
Relm4 breaking change in Factory/Worker APILowMediumPin Relm4 version per release; consult .tmp/ref/relm4/ on upgrades.

Per-phase gate summary

PhaseGate
0 — Scaffoldingjust gate-phase0 passes
1 — Net + store crown jewelsjust gate-phase1 passes
2 — JS runtimejust gate-phase2 passes; runtime is deno_core per ADR-014
3 — HTML + StyloWPT CSS fixtures pass; cascade output correct
4 — Layout20+ visual-hash fixtures match reference
5 — Paintjust run shows a page; headless PNG within 1 % of GUI on 5 fixtures
6 — Host bindingsfixtures/{dom,events,forms,storage,network}/ all pass
7 — Securityjust audit clean; all security tests green; fuzz stable
8 — Headless CDPEvery CLI flag works; CDP responds to required methods
9 — ReleaseAll 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

CommandCurrent evidence
just gate-alphaformatting, clippy, host workspace checks, generated WebIDL/runtime seams, BrowserCore ownership tests, BrowserCore-backed committed fixture runner, and stable crate-boundary allowlist
just gate-architectureleaf-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-smokereviewer baseline: formatting, clippy, host checks, and all host-runnable tests
just gate-pushhk pre-push integration point: alpha, phase-6 runtime, smoke, and diff checks
just gate-webidlgenerated WebIDL constructor/prototype coverage plus headless/CDP runtime-host integration
just gate-phase0workspace/API DTO and trait-shape foundation
just gate-phase1network/store tests, audit, and security fuzz targets
just gate-phase2deno_core runtime and headless eval seam
just gate-phase3HTML/selector/cascade behavior and CSS fixture profile
just gate-phase4Vixen layout-tree/line/fragment behavior and layout fixtures
just gate-phase5display-list/WebRender screenshot and visual fixture path
just gate-phase6engine host-family tests, WebIDL, headless runtime, and CDP runtime integration
just gate-alpha6-cdpexternal Playwright/CDP smoke over BrowserCore targets, including lifecycle, DOM/input, network, permissions, tracing, and stable errors
just test-browser-coreADR-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-reportcurrent BrowserCore-backed committed fixture/profile counts and per-source/category output
just fuzz-securityURL, CSP, cookie, and HTML parser fuzz targets at the configured run count
just auditcargo audit plus cargo deny check
just flatpak-buildsupported GNOME SDK/Flatpak GUI build path
just size-fpmeasured 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.md from just 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-build when 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.md is 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/ unique Cargo.lock dependencies ≤ 220
  • rg -e 'boa_engine|boa_runtime|taffy|tiny-skia|fontdue' Cargo.lock returns nothing
  • One display list, one paint path, two GlContext impls (per ADR-003 / ADR-006) — no CPU rasterizer, no fallback painter, no PaintBackend trait
  • No sandbox.rs, no process_pool.rs, no ipc/ (per ADR-004)
  • No WebKit dependency, no engine-webkit feature (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-headless reproduces every flag in SPEC.md "Headless CLI surface" with stable error codes preserved
  • WPT target profile in docs/COMPAT.md is green; measured pass counts are published for every supported category
  • GUI/headless artifact sizes are published from just size-fp and meet the accepted baseline/regression policy in §"Binary size gates" below
  • docs/COMPAT.md published with honest capability matrix
  • just audit passes (cargo audit + cargo deny check)
  • just check passes
  • hk hooks are installed or hk run pre-push --check passes 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::GLArea via 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 + Mesa llvmpipe (verified)
  • Display-list invariants from SPEC.md enforced 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.md enforced 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.md passes its existing assertions
  • The new ref-equivalent check 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.

PhaseGate
0 — Scaffoldingjust gate-phase0 passes
1 — Net + store crown jewelsjust gate-phase1 passes
2 — JS runtimejust gate-phase2 (vixen-headless --url <file> --eval '1+2' returns 3); runtime is deno_core per ADR-014
3 — HTML + Stylojust gate-phase3; then WPT CSS fixtures pass with cascade output correct
4 — Vixen-owned layoutjust gate-phase4; then the v1 WPT layout target profile in docs/COMPAT.md is green
5 — Paintjust gate-phase5; then just run shows a page and headless PNG diff ≤ 1 %
6 — Host bindingsjust gate-phase6; then fixtures/{dom,events,forms,storage,network}/ all pass
7 — Securityjust audit clean; all security tests green; fuzz stable
8 — Headless CDPEvery CLI flag works; CDP responds to required methods
9 — Releasejust 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:

CategoryFixtures
css17
css-cascade/css-values50
dom25
dom-core50
events1
flexbox5
forms28
grid5
layout9
layout block/inline/position6
network2
paint4
paint/ref-equivalent8
security9
selectors50
Total269

Total manifest checks: 2015.

Current check mix:

Check typeCount
selector-count398
selectors-exact223
title268
js-eval587
computed-style170
element-attribute132
layout-box104
body-contains68
visual-hash25
no-critical-diagnostics21
ref-equivalent11
display-list-contains3
dom-nodes-range1
min-nodes1
selector-match3

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 focusoutfocusinblurfocus 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.

Areav1.0 targetExpected achievabilityNotes
HTML parsing/tree constructionBroad smoke subset greenHighhtml5ever carries parser behavior; Vixen must preserve node ids/tree shape.
SelectorsModern selector subset greenHighBacked by Stylo/selectors; include combinators, attributes, :is, :where, :has, form/link pseudos.
CSS cascade/computed valuesAuthor stylesheet + inline subset greenHigh after full Stylo sliceCompact cascade is temporary; full Stylo should unlock wider WPT coverage.
CSS layout: block/inlinev1 visual/ref subset greenMediumVixen-owned layout; start with normal flow, margin/border/padding, inline line boxes.
CSS layout: flex/gridUseful common-case subset greenMediumPure helpers exist; full WPT edge coverage is post-v1.
CSS layout: tables/floats/fragmentationNot v1 release-blockingLow for v1Document as unsupported/partial until implemented.
DOM CoreTraversal, attributes, token lists, ranges, mutation observer subset greenMediumVixen-owned Web APIs over deno_core host extensions after the ADR-014 migration.
Events/forms/history/storageSelected behavioral subset greenMediumGate by fixtures from SPEC invariants and imported WPT cases.
JS languageUse V8/deno_core language coverage, not WPT percentageHigh for languageWeb API exposure remains Vixen-owned and separately gated.
Paint/ref testsDisplay-list + WebRender visual subset greenMediumOne paint path; correctness depends on layout fragments and WebRender mapping.
Media/WebGPU/WebRTC/service workersOut of scope for v1Not targetedDeferred by ADRs / acceptance post-v1 scope.

Release-blocking WPT goals

For v1.0, Vixen should be able to claim:

  1. 100 % pass on local fixtures/manifest.json.
  2. Green imported WPT smoke profile for parser, selectors, cascade, DOM core, forms, and the v1 layout subset.
  3. Measured pass counts published here for every imported category.
  4. 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 areaMinimum useful target
selectors/css-scoping/css-nesting selector behavior50 fixtures
css-cascade / css-values computed-value behavior50 fixtures
dom/nodes + traversal + ranges50 fixtures
html/semantics/forms basics25 fixtures
css/css-display + css-box + css-position normal-flow layout40 fixtures
css-flexbox common cases25 fixtures
css-grid common cases25 fixtures
paint/ref-equivalent smoke20 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 areaFixtures runChecks runPassedPass rateNotes
selectors50232232100.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-values50250250100.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-core50250250100.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.
forms25134134100.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/position63030100.0%Block flow, margin/padding/border, auto margins, border-box sizing, inline flow, and relative/absolute positioned smoke.
flexbox52525100.0%Row/column grow-basis, gap/padding, and reverse-axis smoke.
grid52626100.0%Fixed, fractional, minmax(), row/column gap, and fixed-height fractional-row smoke.
paint/ref-equivalent82424100.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-content edge 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

CrateImplemented responsibilityTarget boundary
vixen-apiBrowser-scoped typed lifecycle ids, command/event/error/handle contracts, transitional Engine/delegate/inspector traits, diagnostics, profile configuration, graphics-context trait, DTOsGUI/protocol-neutral command, event, id, snapshot, and factory contracts; no implementation dependencies
vixen-netHTTP client primitives and URL/cookie/CSP/CORS/referrer/mixed-content/permissions/security policyPure network and policy leaf; no DOM, runtime, GTK, or profile orchestration
vixen-storeBounded redb profile tables and clear-data operationsPersistence leaf using opaque partition/id keys; no network or UI policy
vixen-engineInitial production BrowserCore/thread/profile/context lifecycle, HTML, DOM/Page, Stylo integration, V8 host runtime, forms/history, Vixen layout, display list, WebRender integrationSole owner of browser/profile/context/document/navigation/resource lifecycle
vixen-shellRelm4/libadwaita chrome, GLArea surface, one app-level engine worker, BrowserCore context/session routingThin GUI adapter and host-service provider; no independent loader/history/profile model
vixen-headlessBrowserCore-backed CLI, CDP target/session adapter, interaction adapter, EGL surfaceless surfaceThin CLI/CDP adapter and composition root over the browser core
vixen-wptFixture/profile manifest, runner, reports, checks, visual evidenceEngine-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, and vixen-store are leaves with no dependencies on other Vixen implementation crates.
  • vixen-wpt may depend only on vixen-api among Vixen crates.
  • vixen-engine is 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 combining Page, Network, Store, or JsRuntime.
  • GTK/Relm4 types stay in vixen-shell; EGL/CLI/CDP types stay in vixen-headless; neither leaks into engine state.

Current migration status

  • vixen-shell depends only on vixen-api and vixen-engine among Vixen crates. One app-level worker owns one ShellBrowser; tabs retain typed ids and immutable presentation snapshots only.
  • vixen-headless depends only on vixen-api and vixen-engine in production. CLI and CDP targets route through BrowserCore and do not own Page, JsRuntime, cookies, network, or session history.
  • vixen-api::Engine remains a transitional tab-shaped trait with only a test implementation. Production paths use the browser-scoped BrowserHandle seam.

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

  1. A profile is opened once by BrowserCore. Cookies, cache, localStorage, permissions, HSTS, download history, and durable settings are profile-owned.
  2. Session history, sessionStorage, viewport/input, active navigation, runtime realms, and document state are browsing-context owned.
  3. DOM, style, layout, paint, and runtime-visible page state identify the same committed DocumentId. A navigation cannot partially replace one layer.
  4. 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.
  5. 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.

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.
  • GlContext abstracts 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:

  1. derive source origin/partition, destination, credentials, referrer, CSP, sandbox, and permission context from authoritative state;
  2. validate URL/method/headers/body and private-network policy;
  3. apply HSTS, cookies, cache, redirect, mixed-content, CORS, and request metadata policy in a defined order;
  4. stream transport with request id, limits, progress, and cancellation;
  5. apply response CORS/CORP/COEP/nosniff/integrity/content policy;
  6. 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.

BoundaryOwnerRequired behavior
CLI/CDP/GUI command → coreadapter + vixen-api DTO validationValidate ids/options/sizes; reject unknown/stale targets with stable errors
navigation/resource requestbrowser loader + vixen-netURL/private-network/header/body/policy checks on initial request and redirects
HTTP response → page/profilebrowser loaderCORS/security/integrity/content checks before exposure, execution, decode, cache, or persistence
JS → Rust op/resourceruntime host moduleWebIDL conversion, size/permission/origin checks, document-generation validation
DOM mutation → render statedocument lifecycleNode/document validity, bounded growth, explicit invalidation, no stale commit
profile write/readprofile service + vixen-storePartitioned normalized records, bounds, transactional failure diagnostics
file/portal/downloadLinux host service + download managerApproved roots/handles, safe names, no ambient arbitrary write/open
inspector/snapshotengine inspectorBounded 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:

  1. leaf-unit tests for pure policy/data/formatting algorithms;
  2. engine integration tests for ownership, lifecycle, generations, and profile partitioning;
  3. committed local fixtures for focused regressions;
  4. pinned imported WPT profiles with source×category reports;
  5. GUI/headless visual comparisons and external Playwright/CDP smokes;
  6. controlled real-site/Linux-host corridor reports; and
  7. 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/selectors for CSS, html5ever for HTML, deno_core/V8 for JS execution and host packaging, and WebRender for paint (see DECISIONS.md ADR-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 in docs/COMPAT.md; if a behaviour isn't called out below, follow the latest stable spec and document deviations in docs/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):

CodeWhen
unsupported.screenshotScreenshot requested without offscreen renderer available
invalid-selectorMalformed --extract-selector input

CDP methods required at v1.0:

  • Browser.getVersion
  • Target.createTarget, Target.attachToTarget, Target.getTargets
  • Page.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, and Page.captureScreenshot (PNG)
  • Runtime.enable, Runtime.evaluate, Runtime.awaitPromise, Runtime.getProperties, Runtime.consoleAPICalled, and Runtime.exceptionThrown
  • Network.enable, top-level Network.* navigation notifications, and the Playwright network-toggle methods (setCacheDisabled, setBypassServiceWorker, setExtraHTTPHeaders; extra headers apply to runtime fetch() requests, cache-disabled bypasses runtime fetch() cache reads/writes)
  • DOM.getDocument, DOM.querySelector, DOM.querySelectorAll, DOM.describeNode, DOM.resolveNode, DOM.getContentQuads, DOM.getBoxModel, DOM.getAttributes, DOM.getOuterHTML, DOM.setAttributeValue, and DOM.removeAttribute
  • Performance.getMetrics, Security.getSecurityState
  • Input.dispatchMouseEvent (mouse move/press/release over the current full viewport), Input.dispatchKeyEvent, and Input.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 typeAsserts
titleDocument <title> text
selector-countNumber of elements matching a selector
selectors-exactExact set of element ids matching a selector
body-containsBody text contains a substring
js-evalEvaluate JS, compare result to expected
min-nodesDOM has at least N elements
no-critical-diagnosticsNo critical EngineDiagnostic recorded
visual-hashPerceptual hash of rendered screenshot matches expected
selector-matchPer-element selector match details
computed-stylePer-element computed style value matches expected
element-attributeElement attribute value matches expected
layout-boxElement border-box (x, y, w, h) matches expected
display-list-containsStable display-list dump contains a substring
dom-nodes-rangeDOM node count is within [min, max]
ref-equivalentRendered 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)
}
}

Cookies follow RFC 6265 with these Vixen-specific defaults:

  • Default SameSite is Lax (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.
  • HttpOnly rejected from document.cookie but accepted from Set-Cookie HTTP response. This is RFC-correct but called out because it's a frequent bug source.
  • Outgoing Cookie header: SameSite=Lax cookies are sent cross-site only for safe methods (GET/HEAD/OPTIONS). SameSite=Strict cookies are sent only to same-host requests. HttpOnly cookies never appear in document.cookie reads.

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:

  1. Script executionscript-src (or default-src fallback). Inline scripts blocked unless 'unsafe-inline' or a matching hash/nonce is present.
  2. Fetchconnect-src, img-src, style-src, font-src, media-src, object-src, etc. URLs matched against source-list.
  3. Plugin content<embed>, <object> allowed only if object-src permits.

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 = min if 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 of step.
  • 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
  • focusout and focusin bubble.
  • blur and focus do 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.

  1. z-index stacking — display list sorted negative → zero → positive z-index; viewport background always first; stable sort preserves document order for equal z-index.
  2. Clip stackingoverflow: hidden clips content but not borders (CSS 2.1 § 11.1.1). PushClip/PopClip bracket content, not decorations.
  3. Opacity groups — stack-based multiplication. Parent 0.5 × child 0.5 = 0.25 effective. opacity == 0 early-exit (no draw).
  4. Visibilityvisibility: hidden and visibility: collapse skip paint but keep layout space.
  5. Background clipborder-box (no extra clip); padding-box and content-box emit PushClip/PopClip around background paint; text is post-v1.0.
  6. Background attachmentfixed uses viewport-relative positioning; scroll and local use element-relative.
  7. Background origin — positions the background image rect relative to border-box / padding-box / content-box per the property value.
  8. 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:

  1. Shape — constructor/prototype exists because WebIDL requires it.
  2. Pure value behavior — JS-only implementation is acceptable when it has no privileged state, I/O, persistence, origin policy, or layout dependency.
  3. Rust op/resource backing — required for page DOM, CSSOM, layout, network, storage, history, permissions, timers, and anything security-sensitive.
  4. 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_expression projections must be retired as equivalent deno_core host objects land.

Lessons from the first host-object migrations

  • One eval path beats clever fallbacks. Headless --eval, CDP Runtime.evaluate, and WPT js-eval should 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_expression unless 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-engine runtime 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 --eval consuming 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-engine or vixen-headless,
  • one user-visible seam check when applicable (--eval, CDP, or WPT fixture),
  • a note in COMPAT.md only when support level or known gaps materially change,
  • green just gate-phase6 before 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:

SubsystemCrate
HTML parsinghtml5ever
CSS cascadestyle (Stylo)
Selector matchingselectors
String interningstring_cache, servo_arc
JS enginedeno_core / V8 embedding
LayoutServo layout_2020 crate
Paintwebrender + 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 TElement trait 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 Engine trait 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 (weston or cage on 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 llvmpipe is sufficient; most CI runners already have it).
  • No binary-size cost from tiny-skia/fontdue and 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-fp is 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 (in vixen-shell) — wraps gtk4::GLArea, used by GUI. GL work runs inside the GLArea::render signal, where GTK has already made the gdk::GLContext current.
  • SurfacelessSurface (in vixen-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 PaintBackend trait with one impl. Rejected: a single-impl trait is premature abstraction and contradicts the "one paint path" goal. The GlContext trait (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 GlContext trait in vixen-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 llvmpipe is 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 llvmpipe software rasterizer satisfies this; most CI runners already provide it via LIBGL_ALWAYS_SOFTWARE=1 if 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-rolled Vec<TabState> + ad-hoc signal handlers.
  • Each tab is a Component with its own model/update/view, owning an EngineWorker.
  • Engine ownership is via relm4::Worker — one worker per tab, on a background thread. The worker holds the Box<dyn Engine> and forwards EngineDelegate callbacks 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-components is 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: EngineWorker emits EngineMsg::{UriChanged, TitleChanged, ...} consumed by the tab component's update.
  • Engine callbacks never run on the shell thread directly; no re-entrancy, no GTK mutate-from-background bugs.
  • Stronger dependency on Relm4 upstream. Track relm4 releases; 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 selectors alone, 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 whole servo repo at that revision), non-reproducible from crates.io, blocks Phase 3 indefinitely.
  • Switch CSS engine to taffy or another standalone cascade. Rejected per ACCEPTANCE.md hard gates (no taffy); 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 style even though the package is stylo; source uses use style::… while Cargo.toml says stylo = …. Documented in style_dom.rs to 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 (TElement etc.). Pin stylo = "0.18" and bump deliberately; track upstream https://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/selectors are present and pinned via the current Firefox/Servo reference and the crates.io stylo dependency.
  • Phase 4 can continue with vertical Page fixtures 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_2020 paths.

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 0de15a5dd2a9 for architecture or Firefox/Stylo/WebRender paths at 46e9f12a8f9b for computed values and rendering contracts.
  • docs/COMPAT.md is 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::script or pure sibling modules, not as one ever-growing script.rs file.
  • 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_core abstractions that solve that exact problem.
  • Abstract over mozjs and deno_core behind an internal JS-engine trait. Rejected: it would preserve two runtime mental models, hide useful deno_core concepts 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_core is the better Rust embedding/runtime substrate for Vixen.

Consequences.

  • deno_core is the vixen-engine::script dependency; mozjs is no longer in the active engine dependency graph.
  • Internal host modules may depend on deno_core APIs 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.md pins 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_core op/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.md and 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 just with hk commands. Rejected: just recipes 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-push first; 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 + Sync and 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-store and direct orchestration are documented temporary exceptions. just gate-architecture protects 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)

ReferenceUpstreamPinned revisionBranchUsed for
Firefoxhttps://github.com/mozilla-firefox/firefox.git46e9f12a8f9bmainCSS 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.
Ladybirdhttps://github.com/LadybirdBrowser/ladybird.git0de15a5dd2a9masterPrimary layout architecture reference. LibWeb DOM/style/layout/paint seams, TreeBuilder, formatting contexts, display-list construction.
GNOME Web (Epiphany)https://gitlab.gnome.org/GNOME/epiphany.git21e02b9a272dmainGTK4/libadwaita shell patterns, WebKitGTK embedding, GSettings usage, Flatpak manifest conventions.
Obscurahttps://github.com/h4ckf0r0day/obscura.gitca71ce3c2da9mainHeadless CLI design, CDP server patterns, single-binary distribution.
Relm4https://github.com/Relm4/relm4.git1ee9b5208b8bmainRelm4 component patterns, factory widgets, async actions. The examples/ and relm4-components/ directories are the primary value.
Deno / deno_corehttps://github.com/denoland/deno.git83c50b1da61emainPrimary 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.), never HEAD or main.
  • 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:

  1. Visible seam first. Prefer code that reaches the engine-owned browser/ context/document path, vixen-headless, CDP, or a committed WPT/fixture check. A Page slice must preserve BrowserCore ownership and name the live document seam it advances. Pure prep is fine only when the next visible seam is named.
  2. One trust boundary at a time. For security-sensitive paths, name the boundary, validate near it, fail closed, and surface stable error codes.
  3. 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.
  4. 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.
  5. 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.md or an explicit plan note.
  6. 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.

TierUse whenCommand shape
Inner loopEditing one crate/modulecargo check -p <crate> plus focused cargo test ... <name>
Pre-commitA commit is being madehk pre-commit: cargo fmt, merge-conflict/private-key scan, staged diff whitespace check
Alpha sliceA coherent partial capability is readyfocused tests + relevant just gate-phaseN
Pre-pushWork is ready to leave the machinehk pre-push: just gate-push
ReleaseVersioned release readinessevery 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.md honest: 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-phaseN as 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.md gates 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:

  1. Playwright's chromium.connectOverCDP(...) handshake.
  2. Runtime.enable, Page.enable, Network.enable, Target.getTargets, Page.getFrameTree.
  3. Page.navigate to a local fixture with a button click listener, including Playwright page.addInitScript() execution before page scripts.
  4. Runtime.evaluate / Runtime.awaitPromise for DOM reads and promise handles.
  5. CDP DOM query plumbing: DOM.getDocument, DOM.querySelector, DOM.querySelectorAll, DOM.describeNode, and DOM.resolveNode.
  6. Top-level navigation network notifications: Network.requestWillBeSent, Network.responseReceived, and Network.loadingFinished; lifecycle opt-in observes init / commit / DOMContentLoaded / load.
  7. Input.dispatchMouseEvent with mousePressed then mouseReleased over the button.
  8. The click handler mutates textContent, attributes/classes, inline style, and a small createElement/appendChild/removeChild/replaceChildren structural subtree; later Runtime.evaluate calls read those mutations back.
  9. Observe Runtime.consoleAPICalled, then call Page.captureScreenshot (png) and Playwright's high-level page.screenshot() path.
  10. Playwright page.setViewportSize() plus CDP Page.getLayoutMetrics viewport reporting, page-level viewport globals, and page.emulateMedia() updates to matchMedia() for media type/color scheme.
  11. Exercise high-level locator geometry/click/fill APIs over Vixen's minimal DOM.describeNode / DOM.resolveNode / DOM.getContentQuads backing. The smoke also covers locator.hover() mouse lifecycle events, locator.dblclick() click/detail ordering, right-click contextmenu, 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, and locator.setInputFiles() against a file input.
  12. Submit a form through Playwright's high-level locator click and wait for the resulting URL/title navigation.
  13. Traverse session history with Playwright page.goBack() / page.goForward() and refresh with page.reload().
  14. Replace document content with Playwright page.setContent().
  15. Execute/apply dynamic inline scripts and styles inserted by Playwright page.addScriptTag() / page.addStyleTag().
  16. Deliver exposed function calls through Playwright page.exposeFunction().
  17. Create and close additional pages with Playwright context.newPage() / page.close().
  18. Read object properties through Playwright JSHandle.getProperty().
  19. Surface modal dialogs through Playwright's dialog event.
  20. Replace and clear browser-context permission grants through Playwright context.grantPermissions() / context.clearPermissions(), with runtime PermissionStatus reads observing the override without rewriting profile decisions.
  21. Start/stop Chromium tracing through Playwright browser.startTracing() / browser.stopTracing(), read the bounded JSON trace through IO.read, and verify stable cdp.method-not-found errors 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.

GuideWhen to read it
mise.mdActivating the project-managed toolchain and using just recipes correctly. Start here for local setup.
cargo-home.mdWhy CARGO_HOME points at <workspace>/.cargo and how recipe-installed Cargo tools stay local.
deno-core.mddeno_core/V8 embedding, host-extension shape, and cache/pinning notes.
gnome-sdk-flatpak-builder.mdBuilding 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:

  • mise pins tool versions and exports the project environment from .mise.toml (RUSTUP_TOOLCHAIN, CARGO_HOME, HK_MISE, and PATH).
  • just owns repository actions. Add or update a justfile recipe instead of copying cargo ... command lines into docs, scripts, or CI.
  • hk owns git lifecycle hooks. The project config is hk.pkl; install hooks with just hooks-install or 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

RecipeWhat it does
just setupOptional dev tools + nightly for fuzzing + check-all-host
just hooks-installInstall/update hk hooks through hk install --mise
just check / just check-all-hostType-check the host-runnable workspace
just test / just test-hostRun host-runnable tests
just smoke / just gate-smokeFormatting check, clippy, check, tests
just gate-pushLong hk pre-push gate
just auditcargo audit and cargo deny check
just fuzz-securityPhase 1 fuzz targets at 1 M iterations
just flatpak-update-sdk / just flatpak-buildGNOME 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:

  • cargo resolves under <workspace>/.cargo/bin.
  • just resolves under mise's install directory.
  • hk resolves under mise's install directory.
  • cargo --version matches the Rust version pinned in .mise.toml.
  • CARGO_HOME is <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>/.cargo makes 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 ~/.cargo from other projects.
  • No cross-project leakage. Vixen's cargo-binstall packages 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 plus cargo-audit, cargo-deny, and cargo-fuzz installed by mise bootstrap / just setup-dev-tools are runnable from an activated shell)
  • .gitignore ignores everything under .cargo/ except config.toml, which is the project-pinned Cargo config and ships with the repo.
  • .cargo/config.toml is 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 cargo themselves; make sure they inherit the mise env (direnv integration, or launch the editor from a mise-active shell). If they don't, they'll fall back to ~/.cargo and re-download the registry there. Harmless, just slow.
  • Other projects on the host. They keep using ~/.cargo as 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(...)
  • JsValue
  • vixen-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.rs owns deno_core::JsRuntime construction and V8 value conversion.
  • webidl.rs renders 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.rs registers the first op-backed host extension; JS constructors delegate UTF-8 encode/decode work to vixen-engine::text_codec through ops.
  • dom.rs registers a page-snapshot extension for focused read-only document/Element/DOMTokenList/dataset evals. Page data crosses the deno_core op boundary through op_vixen_dom_snapshot; element data is loaded through op_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.rs registers the focused read-only CSSOM extension. CSS.supports, getComputedStyle, and document.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-webidl is the focused regression gate for this layer: generated interface/prototype coverage, JsRuntime eval, headless --eval, and CDP Runtime.evaluate must stay green together.

Rules:

  • Rust validates near the op boundary and returns stable EngineError codes.
  • JS bootstrap exposes Web-shaped objects but delegates behavior to Rust ops or shared pure modules.
  • Long-lived host state uses deno_core resources/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//50 preinstalled, 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-dev to install, version-skew, or distro-specific packages. mise bootstrap no longer installs GNOME packages (.mise.toml).
  • Reproducible. The image tag is the SDK version. gnome-50 today; bump the tag to move the SDK.
  • Matches release builds. The production Flatpak is built with flatpak-builder against 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.json builds 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 by flatpak-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

LayerWhatHas gtk4 / cargo?
Hostyour distro + mise-managed rust/justNo GNOME SDK (by design)
Container rootFreedesktop-SDK base, flatpak, flatpak-builder, meson, ninjaNo
org.gnome.Sdk//50 runtimegtk4, libadwaita, Pango, HarfBuzz, fontconfig, …Yes (consumed by flatpak-builder)
org.freedesktop.Sdk.Extension.rust-stablecargo/rustc for the buildPulled 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-builder reports the runtime or Rust SDK extension is missing. Use just flatpak-build; it passes --install-deps-from=flathub so flatpak-builder can install/update org.gnome.Sdk//50, org.gnome.Platform//50, and org.freedesktop.Sdk.Extension.rust-stable.
  • Permission denied on /workspace. The :z mount flag relabels for SELinux (Fedora). On non-SELinux hosts it's harmless. With docker, swap podman for docker in the justfile if you prefer.
  • bwrap: Can't mount proc on /newroot/proc. Run through just flatpak-build; the recipe uses podman run --privileged for nested flatpak-builder sandboxing.
  • Failure spawning rofiles-fuse / missing machine-id. Run through just 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, not cargo build in the shell.
  • Host-side cargo build --features vixen-shell/gtk-shell fails. Expected on a clean host — there is no GNOME SDK installed natively. Either use the container, or install your distro's gtk4-devel / libadwaita-devel yourself for ad-hoc native work (not the supported path).

Reference