Vixen specification
Vixen's contract. What this document captures:
- Vixen-specific surfaces (CLI, error codes, WPT check types, diagnostics shape).
- Vixen-specific configuration of upstream behaviour (URL policy blocklist, cookie defaults, CSP enforcement points).
- Behavioural invariants that must be reproduced exactly because they're easy to get subtly wrong (event dispatch order, paint rules, form-validation edge cases).
What this document deliberately does not capture:
- Restatement of web-platform specs. Vixen delegates spec-heavy behavior where
that improves correctness and size: Stylo/
selectorsfor CSS,html5everfor HTML,deno_core/V8 for JS execution and host packaging, and Flutter Paragraph/Canvas/scene/Semantics for cross-platform render primitives (seeDECISIONS.mdADR-001 / ADR-011 / ADR-014 / ADR-022). Vixen implements CSS formatting semantics in the Flutter-hosted renderer. Behavioural parity is measured by the WPT profile documented indocs/COMPAT.md; if a behaviour isn't called out below, follow the latest stable spec and document deviations indocs/COMPAT.md.
Headless CLI surface
The vixen-headless binary exposes this flag set. Flags and stable
error codes are a public contract — automation depends on them.
vixen-headless --url <URL> [options]
--url <URL> Load a URL (required).
--viewport <WxH> CSS inspection viewport (default 800x600).
--profile-dir <DIR> Persist profile state under DIR.
--extract-text Print document body text.
--extract-selector <css> Print JSON source snapshots for matching elements.
--eval <js> Execute JS, print result.
--dump-dom Dump the DOM tree.
--focus <id> Focus an element by id.
--submit-form <id> Submit a form by id.
--cdp Start text/runtime CDP on 127.0.0.1.
--cdp-port <N> CDP port (default 9222, with --cdp).
--memory-stats Print memory statistics.
Native headless is text/runtime/profile-only. The removed CLI surface includes screenshots, incremental frames, coordinate clicks, layout/display-list/line and paint-stat dumps, and font listing. Native CDP methods that require geometry, pointer hit testing, semantic bounds, or pixels fail closed.
Without --profile-dir, each invocation owns and removes an isolated temporary
profile. With it, BrowserCore stores profile data in <DIR>/profile.redb; this
also applies to native --cdp.
Rendered automation runs the chrome-less Flutter host for the entire logical session. It owns one BrowserCore and uses the same formatter/commit/painter as the GUI. On Linux it runs under Cage/headless Wayland. No native fast path may invent geometry or pixels, and callers cannot select a graphics backend.
Stable error codes (returned exactly as written):
| Code | When |
|---|---|
unsupported.screenshot | Native/text-only CDP receives a screenshot request |
invalid-selector | Malformed --extract-selector input |
CDP methods required at v1.0:
Browser.getVersionTarget.createTarget,Target.attachToTarget,Target.getTargetsPage.enable,Page.navigate,Page.reload,Page.stopLoading,Page.loadEventFired,Page.getFrameTree,Page.getResourceTree,Page.getResourceContent,Page.getLayoutMetrics,Page.getNavigationHistory,Page.navigateToHistoryEntry,Page.resetNavigationHistory,Page.setBypassCSP, andPage.captureScreenshot(PNG)Runtime.enable,Runtime.evaluate,Runtime.awaitPromise,Runtime.getProperties,Runtime.consoleAPICalled, andRuntime.exceptionThrownNetwork.enable, top-levelNetwork.*navigation notifications, and the Playwright network-toggle methods (setCacheDisabled,setBypassServiceWorker,setExtraHTTPHeaders; extra headers apply to runtimefetch()requests, cache-disabled bypasses runtimefetch()cache reads/writes)DOM.getDocument,DOM.querySelector,DOM.querySelectorAll,DOM.describeNode,DOM.resolveNode,DOM.getContentQuads,DOM.getBoxModel,DOM.getAttributes,DOM.getOuterHTML,DOM.setAttributeValue, andDOM.removeAttributePerformance.getMetrics,Security.getSecurityStateInput.dispatchMouseEvent(mouse move/press/release over the current full viewport),Input.dispatchKeyEvent, andInput.insertText
Flutter GUI shell contract
Flutter is the sole web renderer and native GUI shell target on Linux, macOS, Windows, Android, and the Apple Silicon iOS Simulator. The Linux alpha baseline implements chrome and BrowserCore FFI over the Flutter mutation/commit renderer. The Linux GUI requires a native Wayland display and rejects X11/XWayland; rendered automation/CDP uses the same host under Cage.
Platform validation follows a rolling contemporary baseline: the latest stable major release of Linux's reference distribution, macOS, Windows client, Android, and iOS Simulator at each release cutoff. Exact versions and toolchains are recorded in release evidence. Older majors are best-effort unless explicitly promoted to an additional tested tier.
- BrowserCore owns browser/profile/context/document/runtime/computed-style/ resource-policy/accessibility meaning. Dart owns bounded CSS formatting, Paragraph/Canvas scenes, renderer commits/queries, chrome, and host-service UI.
- The Dart FFI bridge carries bounded typed commands/events and opaque handles with explicit lifetime, allocation, version, sequence, and generation rules.
- BrowserCore sends exact bounded mutation/full-resync revisions; Flutter returns one atomic scene/basic-geometry/text/scroll/semantic-bound commit with an opaque Flutter-side hit-test handle and a separate presented acknowledgement.
- Flutter hit-tests the displayed commit and owns mechanical scroll geometry. BrowserCore validates targets and owns event cancellation/defaults, script scroll intent, history/persistence, selection meaning, and navigation effects.
- BrowserCore semantic meaning plus Flutter commit bounds publish one native Semantics generation; actions name the exact displayed commit.
- Rendered CLI/CDP/WPT use a chrome-less Flutter host. Text-only utilities may remain native and GUI bundles need not ship developer automation entrypoints.
Platform acceptance, Android V8/GLES/split-ABI gates, the iOS Simulator track,
Linux release/FlatPark packaging, and artifact policy are specified in
FLUTTER_SHELL.md. JavaScript and WebAssembly use the same
deno_core/V8 runtime path on every declared target.
WPT harness — check types
The WPT harness asserts document state against fixture manifests. The committed
fixtures/manifest.json remains the hermetic release-blocking smoke suite.
Larger upstream slices may instead be described by small JSON WPT profiles and
run against an ignored checkout such as .tmp/wpt/ via just wpt-profile fixtures/wpt-profiles/<profile>.json .tmp/wpt. The check types below are the
public contract for fixture/profile authors.
| Check type | Asserts |
|---|---|
title | Document <title> text |
selector-count | Number of elements matching a selector |
selectors-exact | Exact set of element ids matching a selector |
body-contains | Body text contains a substring |
js-eval | Evaluate JS, compare result to expected |
flutter-js-eval | Evaluate JS whose result requires an exact Flutter commit |
min-nodes | DOM has at least N elements |
no-critical-diagnostics | No critical EngineDiagnostic recorded |
visual-hash | Perceptual hash of rendered screenshot matches expected |
selector-match | Per-element selector match details |
computed-style | Per-element computed style value matches expected |
element-attribute | Element attribute value matches expected |
layout-box | Element border-box (x, y, w, h) matches expected |
dom-nodes-range | DOM node count is within [min, max] |
ref-equivalent | Rendered page matches a reference HTML fixture |
WPT target profile lives in COMPAT.md. End-to-end CSS/DOM/layout
behavior should move into fixtures when practical. Target Rust tests cover pure
logic such as URL/cookie/CSP parsing and redb round trips; a CSS algorithm remains
in Rust only through ADR-022's explicit stable formatter contract and
cross-language tests. The committed manifest's
document/runtime assertions and rendered assertions execute in order against the
same fixture target in the chrome-less Flutter host. Native wpt_runner retains
1,868 source/runtime checks only; Flutter commits are authoritative for
flutter-js-eval, layout boxes, visual hashes, and reference comparisons.
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 GUI shell surfaces diagnostics in chrome; the WPT
no-critical-diagnostics check consumes them. Codes are stable contract.
URL policy
Every network fetch passes through validate_http_url. The blocklist is
Vixen's configuration of what counts as a "public" HTTP target.
#![allow(unused)] fn main() { use std::net::{Ipv4Addr, Ipv6Addr}; use url::{Host, Url}; #[derive(Debug, Clone)] pub enum UrlPolicyError { UnsupportedScheme(String), BlockedHost { host: String }, } pub fn validate_http_url(url: &Url) -> Result<(), UrlPolicyError> { if !matches!(url.scheme(), "http" | "https") { return Err(UrlPolicyError::UnsupportedScheme(url.scheme().to_owned())); } if let Some(host) = url.host() && is_private_host(&host) { return Err(UrlPolicyError::BlockedHost { host: host.to_string() }); } Ok(()) } pub fn is_private_host(host: &Host<&str>) -> bool { match host { Host::Ipv4(ip) => is_private_ipv4(*ip), Host::Ipv6(ip) => is_private_ipv6(*ip), Host::Domain(domain) => { let lower = domain.to_lowercase(); lower == "localhost" || lower == "localhost.localdomain" || lower.ends_with(".local") || lower.ends_with(".internal") || lower.ends_with(".onion") || lower.ends_with(".arpa") || lower.ends_with(".test") || lower.ends_with(".example") || lower.ends_with(".invalid") } } } fn is_private_ipv4(ip: Ipv4Addr) -> bool { ip.is_loopback() || ip.is_private() // 10/8, 172.16/12, 192.168/16 || ip.is_link_local() // 169.254/16 || ip.is_unspecified() // 0.0.0.0 (unspecified only) || ip.is_broadcast() // 255.255.255.255 || ip.is_documentation() // 192.0.2/24, 198.51.100/24, 203.0.113/24 || is_cgnat(ip) // 100.64.0.0/10 } fn is_cgnat(ip: Ipv4Addr) -> bool { let o = ip.octets(); o[0] == 100 && (o[1] & 0xc0) == 0x40 // 100.64.0.0/10 precisely } fn is_private_ipv6(ip: Ipv6Addr) -> bool { ip.is_loopback() // ::1 || ip.is_unspecified() // :: || ip.is_unique_local() // fc00::/7 || (ip.segments()[0] & 0xffc0) == 0xfe80 // link-local fe80::/10 || ip.to_ipv4_mapped().is_some_and(is_private_ipv4) } }
Cookie defaults
Cookies follow RFC 6265 with these Vixen-specific defaults:
- Default
SameSiteisLax(matches modern browsers, not strict RFC 6265 which has no default). - Storage cap: 512 entries per jar. Eviction is FIFO by insertion order (not the RFC's full eviction algorithm). This is a deliberate simplification.
HttpOnlyrejected fromdocument.cookiebut accepted fromSet-CookieHTTP response. This is RFC-correct but called out because it's a frequent bug source.- Outgoing
Cookieheader:SameSite=Laxcookies are sent cross-site only for safe methods (GET/HEAD/OPTIONS).SameSite=Strictcookies are sent only to same-host requests.HttpOnlycookies never appear indocument.cookiereads. - Domain policy uses the static Mozilla Public Suffix List, including its private section. Parent public-suffix attributes are rejected; an exact-host public suffix is converted to host-only as required by RFC 6265bis.
Everything else (domain matching, path matching, secure-gating,
expiry handling, Max-Age semantics) follows RFC 6265 exactly.
CSP enforcement points
CSP is parsed from Content-Security-Policy headers and
<meta http-equiv="Content-Security-Policy">. Enforcement happens at
three boundaries:
- Script execution —
script-src(ordefault-srcfallback). Inline scripts blocked unless'unsafe-inline'or a matching hash/nonce is present. - Fetch —
connect-src,img-src,style-src,font-src,media-src,object-src, etc. URLs matched against source-list. - Plugin content —
<embed>,<object>allowed only ifobject-srcpermits.
Source-list grammar follows the CSP spec exactly ('self', 'none',
'unsafe-inline', 'unsafe-eval', host/scheme sources, nonces,
hashes).
Form validation edge cases
These are pinned down because they're easy to get subtly wrong.
Email format (typeMismatch for type="email"):
- Exactly one
@. - Non-empty local-part.
- Domain contains at least one
..
URL format (typeMismatch for type="url"):
- Valid scheme (letters followed by
:). ://separator after the scheme.- Non-empty host.
Step arithmetic (stepMismatch):
- Step base =
minif present, else the type-specific default base. - Default step per type: number/range = 1; date = 1 day; time = 60 s; week = 1 week; month = 1 month; datetime-local = 60 s.
- Valid when
(value - step_base)is within float tolerance of an integer multiple ofstep. - Date/time values use integer arithmetic on canonical units:
date→ days since epoch,time→ seconds since midnight,week→ weeks since epoch,month→ months since year 0,datetime-local→ epoch seconds.
Everything else in constraint validation (valueMissing,
rangeUnderflow/rangeOverflow, tooLong/tooShort, badInput,
customError, willValidate) follows the HTML5 spec exactly.
Composed event dispatch invariants
Specific ordering invariants that must be reproduced exactly.
Focus transitions (when document._setActiveElement runs):
focusout → focusin → blur → focus
focusoutandfocusinbubble.blurandfocusdo not bubble.
composedPath() walks target → parentNode chain, returning a flat
JS array. Respects shadow DOM boundaries when composed: true on the
event.
Renderer commit and paint invariants
These rules apply to the Flutter-hosted formatter and every GUI/automation surface:
- Exact revisions — a mutation batch applies only to its named base and
target
RenderRevision; gaps request full resync. - Atomic commit — scene-ready layout, basic geometry, an opaque Flutter-side
hit-test handle, text query state, scroll snapshot, semantic bounds, and
truncation share one
commit_idand revision. - Presented identity — input and native accessibility identify the displayed commit, not merely the newest completed layout.
- Stable paint order — stacking contexts, z-index, positioned content, and document-order ties follow CSS; viewport background remains first.
- Clip and transform identity — paint, hit testing, text/caret queries, and semantic bounds consume the same clip/transform chain.
- Opacity and visibility — group opacity composes through ancestors;
opacity: 0and hidden/collapsed paint are omitted while required layout state remains queryable. - Scroll identity — renderer offsets/extents/clips and the scene share one commit; BrowserCore scroll events/history accept only that result.
- Finite bounded geometry — non-finite, oversized, unknown-node/resource, over-depth, or required-but-truncated geometry fails closed.
- No stale fallback — stale commits cannot target input, answer required geometry, publish Semantics, or become visible after replacement.
- One renderer — after cutover no WebRender/EGL/RGBA or second screenshot path remains. Text-only tools cannot fabricate geometry.