@aquex/stage1
v0.6.0
Published
Stage1 Inspector — local website capture, evidence views and design comparison
Readme
Stage1 Inspector
Stage1 shows what shipped and compares it with declared design intent. Capture a website locally, inspect its evidence in a browser, and compare the measured values with a local Figma kit, CSS/SCSS declarations or JSON tokens. Each comparison names its rule, explains non-matches and links findings to captured evidence.
Stage1 is free, Apache-2.0 software with the stage1 CLI, stage1-mcp stdio server and a local UI. It reports measurements, coverage and missing evidence. Newly produced Stage1 records contain no Stage1-made quality score or confidence rating; drift severity stays in JSON for the receiving object contract and is not shown in Stage1 views. Tool-native values remain in their raw reports. See LICENSE, NOTICE and THIRD-PARTY-NOTICES.md.
OODS Foundry declares what things are; Stage1 shows what shipped and checks it against what was declared. Use Stage1's own views for 0.5.x records until OODS Foundry admits their new artifact versions. A joint demonstration must use versions admitted by both products.
This guide describes Stage1 Inspector 0.6.0. No supported JavaScript library API is exported.
Install · Quick start · Capture options · Suites · Guided sessions · Design comparison · Baselines · Team rules · CI · MCP · Troubleshooting · API inspection · Source inspection
Install and upgrade
Use a maintained Node 22 release at least 22.19.0, or Node 24 at least 24.0.0. Node 20, 23 and 25 are outside this release contract. The release qualification scope is Ubuntu 22.04/24.04 x64, macOS 14 ARM64 and macOS 15 Intel. Windows (including Windows 11 x64) and macOS 14 Intel remain unverified. Other OS/architecture combinations are outside this verified scope.
npm install --global @aquex/[email protected]
stage1 --version
stage1 --helpUpgrade by installing an explicit new version with the same command. Keep existing run directories: installation does not migrate or rewrite them. Direct runtime dependencies are pinned; their registry-resolved transitive dependencies can change between installations.
Local capture uses installed Chrome, or the package's pinned Chromium fallback. If Chrome is unavailable:
stage1 browser setupThis downloads browser binaries from the automation runtime's distribution service; it needs internet access and disk space. A measured macOS arm64 installation downloaded about 266 MiB and occupied about 539 MiB for Chromium, its headless shell and FFmpeg, separately from npm dependencies. Sizes vary by platform/revision; allow additional space for downloads and run evidence. Linux also needs the browser's system libraries. Setup uses the installed Patchright revision, not a separately downloaded latest npm tool.
Capture, view, export
Substitute a site you control. Commands below use paths relative to your current directory. Capture prints the run directory; replace RUN_DIR and RUN_ID with that directory and its manifest run ID. Quote paths that contain spaces. For a local development server, include the scheme (for example, http://localhost:3000). Keep usernames and passwords out of URLs; use --auth with a local browser storage-state file for signed-in captures.
stage1 inspect app --url https://example.com --out ./runs --max-pages 5 --crawl-depth 1
stage1 view RUN_DIR --port 0The second command prints a loopback URL and stays running until Ctrl-C. View reads the source run. Routes expose capture coverage, page evidence, accessibility, performance and comparison when present. Performance covers at most five pages. Responsive width replay requires --include responsive.widths; it is not part of the default capture. Run-side token records show observed values and site-declared custom-property names, without invented design roles. Missing or failed measurements are disclosed; absence is not zero.
Review the run's contents and rights before approving a local export:
stage1 view RUN_DIR --export ./share-run --approve-sharing RUN_IDOpen share-run/index.html directly, including via file://. The destination must be fresh and outside the source. This writes locally; it does not upload, host or deploy.
--approve-sharing declares that you control this run and approve its subject and selected comparison for sharing. The receipt binds your declaration to the run and optional analysis manifest digests. It is an operator declaration, not independent proof of rights. Another run ID, changed manifests, unsafe paths and existing destinations are refused.
Authenticated captures also require --approve-authenticated-sharing RUN_ID. This additional declaration covers the authenticated contents, including any selected comparison. Operator-controlled guided records require it too, since a person can sign in during capture.
The report is an inline view model, with selected linked files: screenshots, summarized accessibility evidence and available visual-system drill-downs. Comparison operand files are included only when findings link to them. This is not a copy of every run artifact. Computed-style evidence, raw CSS-rule evidence, crawl and network records, drift_report.json, captured HTML, HARs, raw Lighthouse reports, offload records and credentials are not exported as files.
Linked evidence is checked against source attestations; missing or changed evidence is withheld. URL query values are removed from JSON copies; source and exported digests are recorded separately. Screenshots can still contain visible personal or confidential information. Original run bytes remain unchanged.
Capture limits and options
inspect app defaults to depth 2, at most 50 pages and all ordinary include groups. --include can be repeated for a11y, perf, network, responsive, theming, styles.detail, all and explicitly responsive.widths. Width replay is not part of all. --seed-routes seeds.json takes a JSON array of route strings and disables discovery beyond those seeds.
stage1 inspect surface https://example.com --seed-route / --seed-route /about --out ./surface-runsSurface capture defaults to style.fingerprint plus dependencies, depth 0, at most 50 pages, and no discovery. Explicit seeds are the complete candidate set. --discover enables bounded same-origin discovery and defaults depth to 2; a nonzero --crawl-depth requires it. Allowed --passes values (comma-separated) are web.crawl, web.computed_styles, style.fingerprint, token.guess, web.stylesheet_rules, style.breakpoints, style.theming.
Lighthouse 13.5.0 measures at most the first five available pages in root-then-route order. This is a bounded sample, not a representative sample of page types. Selection and unselected pages are recorded. Explicit responsive width replay selects at most 10 pages, 8 widths and 40 probes per page; reports disclose selection and truncation. Capture budgets do not promise complete site coverage. Review actual pages, failures and first-party health in the run.
Both capture modes accept --name, --auth (local browser storage-state JSON), --device, --viewport WxH, --sequential, repeatable --first-party-host, --robots-override <reason>, --sampling compact|full, and evidence retention switches. Respect the site's access rules; an override reason is recorded.
--sampling sets how many elements per page computed styles are read from. full (the default) reads up to 250 elements per page and 50 per element group. compact reads up to 120 and 24, with shorter selectors. Computed styles are read from a replay of each saved page with its scripts made inert. Each record says whether its element was painted in the captured state at the capture width, and why not, and sampling reads painted elements first. Each record also says where each judged value came from (the site's CSS, the browser's own stylesheet, or nothing declared, so the CSS initial value), read from the capture browser, whose build and colour scheme the run manifest records. The crawl also records which parts of each page a third party's script inserted, and records inside them name that host. The run manifest records the mode with the Stage1 version that made the run. --keep-evidence (the default) and --no-keep-evidence only decide whether evidence is retained; they change nothing a capture records. Output precedence is --out, then STAGE1_OUTPUT_ROOT, then ./out. Relative CLI paths resolve at invocation. Capture help lists exact flags. Unknown commands, options, groups and pass IDs fail.
Device profile names are case- and space-sensitive, for example --device "iPhone 15" or --device "Pixel 7". Use --viewport 1440x900 for a custom viewport. Run stage1 inspect app --help or stage1 inspect surface --help for command-specific options.
Local browsing is the default, even if provider settings are inherited. For your own Bright Data Scraping Browser account, supply its authenticated wss CDP endpoint in STAGE1_SCRAPING_BROWSER_CDP_URL through your environment, then explicitly select remote:
stage1 inspect app --url https://example.com --out ./remote-runs --browser remote --max-pages 1Provision and pay for that provider account yourself. Do not paste credentials into commands, shared configuration, captures or issue reports. No account is bundled. Missing/invalid remote configuration fails; Stage1 never silently switches to local. Actual live-pass tier and health are recorded. Optional STAGE1_SCRAPING_BROWSER_COUNTRY selects the provider country. Inherited enable flags alone do not authorize paid capture.
Multi-site suites and discovery
Discover candidates from any newline URL list or JSON array of URL strings / {"url":"…"} objects:
stage1 discover --from ./urls.txt --name public-sites --count 10 --keep-path --out ./runs
stage1 inspect suite --config GENERATED_SUITE_YAML --out ./runs --sequential
stage1 view SUITE_RUN_DIR --port 0Discovery checks robots and reachability before including a URL. It keeps every candidate and its exclusion reason, deduplicates by exact origin (including port), follows at most ten redirects with a robots check at each hop, and defaults to ten included origins and a 10,000 ms request deadline (--timeout-ms). --keep-path retains a candidate path as an explicit seed; otherwise the origin root is used. A robots refusal or unavailable host stays excluded. With no accepted candidates it writes a discovery receipt, no config, and exits 1. Discovery uses ordinary HTTP requests and no paid browser. It offers no robots override.
Review the generated config before capture. This strict example captures one seed per target:
suite:
name: public-sites
apps:
- name: First public site
targetId: first
url: https://example.com/
seedRoutes: [/]
maxPages: 1
- name: Second public site
targetId: second
url: https://example.org/
seedRoutes: [/]
maxPages: 1
include: [a11y, styles.detail]Unknown fields and duplicate target identifiers are refused. targetId defaults to the name's slug. URLs must be HTTP(S) without embedded credentials. include defaults to [all]; per-target include overrides it. Targets run sequentially; --sequential also orders each target's passes. Each target uses local browsing by default, explicit seed bounds, at most five Lighthouse pages, and no web-unlocker fallback, regardless of inherited provider enable flags. Per-target browser: remote is the explicit paid-browser opt-in. No suite-wide tier or robots override flag exists.
Per-target settings also include crawlDepth, device, viewport, firstPartyHosts, repoPath, figmaFileKey, auth: {storageState: private-file.json}, and api with the same named fields as MCP app inspection. Local file references resolve relative to the config. Credentials remain file/environment references. An authorized exception to robots is robots_override: {reason: your recorded reason} on the individual target; that reason reaches crawl/API evidence and the suite's robots summary.
The run lives at runs/suites/SUITE_ID/RUN_ID/. Every attempted target has an ordinary run under targets/TARGET_ID/; the root attests its child manifests and files. Every configured target keeps a captured, failed or unscanned state with a reason and robots status. Partial or cancelled suites remain inspectable and return failure. #/suite puts measured style values, literal structure presence, persisted facet spellings, JSON-LD types, API fields/endpoints and accessibility results beside each other. Needs-review and violations stay separate. Missing measurement is not zero; recorded input caps and dropped counts remain visible. Target links open the ordinary site views. No similarity score or semantic equivalence is inferred.
Resume is explicit:
stage1 inspect suite --config ./suite.yaml --out ./runs --resume RUN_ID
stage1 inspect suite --config ./suite.yaml --out ./runs --resumeThe second form selects the unambiguous latest recorded suite. Resume verifies the resolved config, root/child identities, hashes and actual files. Only fully verified targets whose passes all succeeded are retained; incomplete targets are recaptured. Changed inputs, changed bytes, unknown extra files, aliases and ambiguous latest selection are refused. Completed verified runs stay byte-identical. Legacy suite records without the integrity contract cannot resume. One CLI writer or MCP server owns an output root at a time; stop that server before running a CLI capture against the same root. A dead process's lease can be reclaimed; restart never resumes capture automatically.
Export adds an explicit public-subject approval for every configured target:
stage1 view SUITE_RUN_DIR --export ./share-suite --approve-sharing RUN_ID --approve-target first --approve-target secondAn authenticated target also needs --approve-authenticated-target TARGET_ID and the existing root --approve-authenticated-sharing RUN_ID. The declaration binds each target identity and child manifest digest. Unapproved targets are named and refused. The export verifies all suite-attested bytes before and after rendering; suite and target links work from the resulting files. Original runs are unchanged.
MCP provides stage1_discover_suite (fromPath, optional name, count, timeoutMs, keepPath) and stage1_inspect_suite (configPath, optional sequential, keepEvidence, sampling, resume: true | UUID). Both return persisted job acknowledgments and use stage1_job_status, stage1_job_result and stage1_job_cancel. Discovery's terminal result includes suiteConfigPath or null. Supply absolute input paths inside the configured output root; every local file reference in suite YAML must also resolve inside it. Tool arguments never take credential values. Cancellation retains finished targets; interrupted jobs stay interrupted until an explicit new resume request. stage1_export adds approveTargets and approveAuthenticatedTargets arrays. stage1_get_artifact reads a suite aggregate by its JSON filename.
Guided sessions
Guided capture opens a local browser on the operator's desktop. Sign in and navigate there, then enter capture <label> in the terminal to preserve a state. Labels distinguish repeated captures at the same URL. quit, window close, or an interrupt ends the session and finalizes the retained evidence.
Open the completed run with stage1 view RUN_DIR. Overview shows recorded session bounds, authentication input, login-state save status, response-body redaction and omitted measurements. Each labelled state opens its own screenshot, page facts and live axe findings, even when several states share a URL. Captured coverage counts retained checkpoints and visited routes; the site's total route/state count remains unknown. Performance, full network tracing and responsive replay are not measured by guided capture. Older evidence without state identities cannot supply state-specific facts or missing live axe results.
stage1 capture guided https://example.com --out ./runs --name journey --no-auto-capture --max-captures 100 --idle-timeout 1800
stage1 inspect guided RUN_DIR/evidence --out ./runsAutomatic navigation capture is enabled unless --no-auto-capture is passed. The defaults are 100 checkpoints and 1800 seconds of inactivity; supported limits are 1–1000 checkpoints and 1–86400 seconds. Operator input, navigation and checkpoints reset the idle timer; status polling does not. Bounds and termination reasons are recorded. The limit refuses additional checkpoints without discarding earlier ones. inspect guided creates a fresh discoverable analysis and preserves its source evidence.
--auth /private/login.json reads an existing browser storage-state file. --save-auth /private/saved-login.json explicitly saves state outside the entire run, with owner-only permissions; auth saves it during the session. No credential values or credential-file paths are written into the guided record or job metadata. Keep these files private. Interactive runs require authenticated-sharing approval when exported, even if no input login file was supplied.
For MCP, use stage1_guided_start with url, optional name, absolute authStorageStatePath/saveAuthPath, autoCapture, maxCaptures and idleTimeoutSeconds. The server must run on the operator's own desktop. Its first reply is starting; poll stage1_guided_status with sessionId until running. Then call stage1_guided_capture with sessionId and label, and finish with stage1_guided_end or stage1_guided_cancel. Guided and ordinary captures share one active job slot and the same root lease. Closing the window finalizes automatically; repeated end returns the same result.
After a server restart, unfinished jobs are interrupted. Calling stage1_guided_end analyzes their retained evidence into a fresh run; it does not relaunch the browser or rewrite the interrupted capture. stage1_inspect_guided takes a completed root-scoped runId and also creates a fresh analysis. Use list, artifact and view tools with the resulting run ID. The legacy one-shot guided tool is retired.
Local .fig comparison
The value comparison covers colour, font size, radius, spacing, border colour, shadow and font weight. Library component families compare with declared markup. Optional Figma REST metadata adds a comparison of literal CSS token-name references; it does not establish variable bindings.
Each family records its exact match rule. Spacing compares captured raw padding/margin values with spacing/space/padding/margin/gap-named numeric tokens. Border colour compares measured border colours with border/stroke-named colour tokens under the recorded alpha policy. Shadows compare ordered visible drop/inner-shadow layers by colour (including alpha), pixel offsets, blur, spread and inset; unsupported or incomplete effects remain unresolved. These are parameter comparisons, not rendered-pixel comparisons. Font weight compares numeric font-weight-named tokens (1–1000) with captured weights across typefaces; labels such as Regular and Bold are not converted into numbers. Colour conformance pools the declared palette; separate per-collection and per-mode coverage names the mode (or tied modes) explaining the most compared occurrences among tokens whose values distinguish modes. Single-mode palettes and unmoded styles are shown separately. Unknown collections do not support theme inference. Live values no theme mode explains stay visible; coverage never establishes a clean build.
Type is compared per typeface, first against size variables with modes named for that typeface, then
against its text styles. Exact matches are conformant. A size within 0.16px of a moded size is labelled
"normalized type (rounding)"; a typeface with no comparand is unjudged. For a face without a declared
mode, redrift --type-factor 'Example Sans=1.06' declares a factor against its text-style scale (or
the kit's unqualified scale when it has no matching styles). Repeat the option for distinct families.
Factors must be positive decimals at most 4. These matches are "normalized type (declared factor)",
never conformant. Nothing is fitted to the site.
Missing design or live values produce empty_comparand with a reason and no drift signal.
Comparison headings do not score the site or show conformance ratios. Older analyses retain their
recorded results.
Use your own exported design.fig, a fresh extraction directory and a previously captured run. No remote Figma account is required. Decoded design contents remain local; console output contains counts and filenames.
Extraction writes fig_local_tokens 1.6.0. An alias resolves mode by mode, the way Figma resolves it: a step in the variable's own collection uses that mode, and a step in any other collection uses that collection's default, its first mode. Each variable keeps the scopes Figma declares for it, and a collections table names each collection's modes and default. Variables, collections and styles that Figma has soft-deleted (kept in the file only so older references still resolve) are flagged soft_deleted; comparisons in this release still read their values.
stage1 fig-extract ./design.fig --out ./fig-out
stage1 redrift RUN_DIR --fig-tokens ./fig-out/design.tokens.json --out ./comparison --durable
stage1 view RUN_DIR --analysis ./comparison --port 0
stage1 view RUN_DIR --analysis ./comparison --export ./share-comparison --approve-sharing RUN_IDSupply --figma-library <printed-library-path> to redrift, alone or beside --fig-tokens,
to compare the library with declared_components. The Comparison page lists matched,
design-only and site-only records with evidence, plus every library component and set.
Missing declared markup reads not measured. Exact published component keys are read from
explicit data-figma-component-key on block elements and the local twin index (or REST keys).
Otherwise names compare by lowercase family head before slash/dot with punctuation as separators.
Namespaces, aliases and words are not removed. BEM elements/modifiers are evidence, not proof
that a design variant rendered. A differing explicit key cannot be overridden by a name match.
Unmatched records mean unmatched under this rule, not an implementation defect. Captured
pages do not establish whole-site coverage. Historical analyses keep their recorded meaning.
stage1 redrift <run-dir> --figma-library <extraction-dir>/<label>.figma-library.json --out <fresh-analysis-dir> --durableSince 0.5.0, ratios use raw values from full sampling by default. Values explained by capture state, browser defaults, third-party content or the comparison rules are listed separately; they count as neither conformant nor drift. Conformant still means an exact token match, so ratios from 0.3.x, 0.4.0 and 0.5.0 measure different populations and should not be read as a trend.
The decoder reports filenames derived from the input basename; use those printed names if yours differ. redrift writes a separate analysis and preserves its source run. It reports the available evidence on each side, not inferred agreement for missing data. Live values are compared as recorded, not as rounded groups. Each value finding names up to five places it occurs: the page, the element's selector, the property and the computed-style record. For a run captured before 0.4.0, redrift recomputes those values from the run's retained computed-style evidence after checking it against the run's own attestation. An occurrence on an element not painted at capture is listed apart with its reason and place, never counted as conformant or drift; each family states how many occurrences it compared. On a run captured before 0.4.0 only an element's own display:none is applied, and other hidden states read "not recorded on this run". A value the browser supplied itself is listed apart in the same way, naming the browser build; on a run captured before 0.4.0 it reads "not recorded on this run". So are values inside content a third party's script inserted, grouped by host; the site's own markup, even a button that opens such a widget, stays compared. Comparison rules explain non-token values without counting them as conformant, including: a border colour in the kit's palette with no border role, a negated spacing token, and a length relative to the element's own font size (a fixed multiple, or declared in em/ex/ch/cap/lh/ic). Each family publishes the rule that decided which tokens it compared. --max-signals-per-side is retained for historical compatibility; new declared-presence records are not capped. The durable analysis directory can be moved with its attested files and supplied to view --analysis.
To inspect a hosted Figma file with your own personal access token, set FIGMA_TOKEN in the
process environment and pass only the file key. Stage1 does not load .env automatically.
stage1 inspect app --url https://example.com --figma-file FILE_KEY --out ./runs
stage1 redrift RUN_DIR --out ./figma-comparison --durable
stage1 view RUN_DIR --analysis ./figma-comparison --port 0MCP uses stage1_inspect_app.figmaFileKey, with FIGMA_TOKEN set on the server. No CLI flag,
MCP field, run manifest or artifact accepts or retains the credential. Captures without a file key
make no Figma requests. Figma acquisition is recorded as header-authenticated, so exporting its
records also requires --approve-authenticated-sharing RUN_ID (or MCP approveAuthenticatedSharing),
in addition to the ordinary sharing declaration. This does not imply a logged-in browser. The Comparison page shows library records, declared variant properties,
component descriptions, status values, native annotations and clearly labelled naming hints even
before a comparison is created. Token-name references in descriptions and names activate the
literal-name comparison against captured custom-property declarations, including those within recorded layers and conditions. Matching names do not prove variable bindings, effective use or equal design values.
Requests use Figma's REST files API: 30 seconds and 20 MiB per response, at most 24 requests per
pass, 50 component-set IDs per batch and 1,000 sets per pass. A 429 stops requests in that pass and
reports Retry-After; there are no automatic retries. Scope failures retain Figma's scope message.
Unpublished drafts fall back to a file tree limited to depth 5; fallback node IDs are never treated
as published component keys. Native annotations and shared/plugin presence are read within that
same depth. Deeper content is outside the measurement. A partial result remains labelled partial;
missing access never means the file has no declarations. Name-derived hints carry no confidence
scores and are never promoted to plugin-declared intent merely because plugin data exists.
You can also supply a design system's local CSS or SCSS declarations:
stage1 design-tokens ./tokens.scss ./design.css --out ./source-tokens
stage1 redrift RUN_DIR --design-tokens ./source-tokens/design_source_tokens.json --out ./source-comparison --durable
stage1 view RUN_DIR --analysis ./source-comparison --port 0Add --fig-tokens to compare both design sources independently. Comparison and Signals name
the source of each result; a value found in one source does not fill a gap in the other.
The new artifact records file hashes, line/column locations, alias declaration IDs, unresolved
reasons and the conversion basis for every resolved rem value. Existing destinations are refused.
Extraction accepts at most 50 distinctly named .css/.scss files, 10 MiB total, and 10,000
relevant declarations. It parses syntax locally with PostCSS and postcss-scss. It does not fetch
imports, run Sass, evaluate CSS cascade, or execute project configuration. Literal colours,
numbers, pixel/rem dimensions, explicit shadows, simple font stacks and explicit variable aliases
are supported. CSS aliases resolve within their declaration block, an unambiguous unconditional
:root/html/:host block (including lists), or a supplied @theme/@theme inline block.
Constant calc() arithmetic over resolved numbers and px/rem lengths is labelled derived,
with its root-size basis. Relative element units, functions, incompatible units and division by
zero remain unresolved. SCSS aliases resolve unique top-level variables among the supplied files.
Missing, cyclic or ambiguous aliases, Sass functions/maps/arithmetic/control flow, nesting, and
unsupported units remain unresolved. Compile complex Sass with your design system's own build
first, then supply its CSS; this preparation is outside Stage1.
Typography pairs literal sizes with an unambiguous explicit font-family on the same selector in
the same file, including media declarations. Inherited families are not guessed. This is an
inventory of declarations across conditions, not a claim that every value applies at every
viewport. --initial-font-size <px> records the initial browser font-size basis (default 16);
an unconditional html/:root font-size adjusts it. Conflicting or conditional roots leave rem
unresolved. This basis must match the capture environment. Type comparisons require a fingerprint
with sizes recorded by typeface; older pooled fingerprints read as unmeasured. The view lists
resolved and unresolved declarations so an incomplete source cannot appear to be a complete scale.
DTCG, Tokens Studio and native Figma token files
design-tokens also writes design_source_tokens 1.1.0 from local JSON. Supply JSON separately
from CSS/SCSS. The same redrift --design-tokens command compares it with a retained capture.
Comparison shows each collection, mode, declared type, grammar, alias and source-file JSON pointer.
A token with any unresolved mode counts as unresolved; its other resolved modes remain usable.
A DTCG 2025.10 file (brand.json):
{
"ink": { "$type": "color", "$value": { "colorSpace": "srgb", "components": [0, 0, 0] } },
"space": { "$type": "dimension", "$value": { "value": 0.5, "unit": "rem" } },
"border": { "$value": "{ink}" }
}stage1 design-tokens brand.json --out ./brand-tokensTypes come from an explicit token type, a whole-token alias's resolved type, then the nearest
group type. Values never determine their own type. Curly references identify whole tokens;
$root must be explicit. A value such as { "$ref": "#/ink/$value/components/0" } uses an
RFC 6901 property/array pointer. $extends deep-merges groups and replaces whole tokens.
Deprecated metadata is retained, including a child's false override; it does not remove values
from the comparison. Nonstandard grammar is labelled per token and per mode. A DTCG dimension needs
{value, unit}; a bare number stays unresolved.
A Resolver (brand.resolver.json) combines local files in declared order, then resolves aliases:
{
"version": "2025.10",
"sets": { "base": { "sources": [{ "$ref": "./brand.json" }] } },
"modifiers": { "theme": {
"contexts": { "Light": [{ "$ref": "./light.json" }], "Dark": [{ "$ref": "./dark.json" }] },
"default": "Light"
} },
"resolutionOrder": [{ "$ref": "#/sets/base" }, { "$ref": "#/modifiers/theme" }]
}stage1 design-tokens brand.resolver.json --out ./resolved-brandEach modifier becomes a collection and each context a mode. When enumerating one modifier, others use their declared defaults; combinations are not multiplied. Referenced local JSON files load within the directories of explicitly supplied inputs. URLs, escaping symlinks and unavailable files never fetch content; missing values carry reasons. JSON5/JSONC is not accepted.
Tokens Studio supports both its legacy form and its $ form, including string dimensions:
{
"legacy": { "type": "dimension", "value": "8px" },
"modern": { "$type": "dimension", "$value": "{legacy} * 2" }
}stage1 design-tokens studio.json --out ./studio-tokens
stage1 design-tokens './tokens/$themes.json' --out ./studio-themesWith $themes.json, theme group becomes collection and name becomes mode. tokenSetOrder
from sibling $metadata.json controls merging. Enabled sets emit tokens; source sets only resolve
aliases; disabled sets are excluded. Duplicate theme IDs merge their selected sets when their
name/group agree. Missing selected files are listed as warnings. For example:
[{ "id": "light", "group": "Brand", "name": "Light",
"selectedTokenSets": { "primitives": "source", "semantic/light": "enabled" } }]Native Figma writes one file per mode, with no collection name. A minimal native-shaped file is:
{
"$extensions": { "com.figma.modeName": "Light" },
"space": { "$type": "number", "$value": 8,
"$extensions": { "com.figma.variableId": "VariableID:1:2", "com.figma.scopes": ["GAP"] } }
}stage1 design-tokens Light.tokens.json Dark.tokens.json --collection Brand --out ./figma-tokensMode names come from metadata, falling back to the file stem. Collection defaults to the file
stem too; use --collection to combine exports deliberately. Native string/boolean grammar is
labelled figma. Literal values remain usable when Figma's separate alias metadata names a target
outside the supplied files. Exact source handles link supplied targets; display names never do.
Source-only targets have provenance but no emitted graph edge. Opaque extension payloads are not
copied. Native variable exports omit styles; this cannot replace style-backed .fig values.
Only constant addition, subtraction, multiplication and division over resolved numbers/dimensions
are evaluated, labelled derived. Functions, division by zero, incompatible units and vendor-derived
values (such as Blueprint's com.blueprint.derive) remain unresolved. Nothing is executed. Colors
use the same space-aware comparison as captures; hex fallbacks remain separately declared. An
invalid optional hex fallback is warned and ignored. System colors need an unavailable environment
and stay unresolved. Typography declares its own typeface; a standalone font-size scope cannot
invent one. Generic dimensions do not imply spacing or radius. Named Figma scopes and explicit
semantic types supplement name rules; layout widths still cannot become spacing. Numeric native
Figma dimensions use the existing pixel comparison convention. JSON rem uses the recorded
--initial-font-size basis, default 16px.
The bounds are 50 files, 10 MiB, 10,000 tokens or (collection, token, mode) values, 64 nesting/alias levels, 100 contexts, and 100,000 resolution steps for reading the files and again for each context. Standalone unmoded files merge in argument order; later whole tokens replace earlier ones. Use a Resolver to declare different contexts instead of inferring modes from folder names. A Resolver token is filed under the set or modifier that defines it: one a modifier's contexts define has one value per context (other modifiers at their defaults), and one only sets define has a single value under the last set that defines it. Original source bytes and historical captures remain unchanged.
DTCG out: stage1 dtcg-export
stage1 dtcg-export --fig-tokens ./fig-out/design.tokens.json --out ./design-dtcg
stage1 dtcg-export --design-tokens ./design/design_source_tokens.json --out ./css-dtcg
stage1 dtcg-export --run RUN_DIR --out ./live-dtcgGive exactly one source: a .fig extraction, a CSS/SCSS reading from stage1 design-tokens, or a single-site capture run.
This writes your kit's variables as DTCG 2025.10 token sets plus a Resolver: sets/<collection>.tokens.json for a collection with one mode, sets/<collection>/<mode>.tokens.json for each mode of the others, and <kit>.resolver.json, whose modifiers default to each collection's default mode. Every token sits under its collection as the top-level group, and its name nests at /, so the same name in two collections stays two tokens. Aliases are {…} references; colours are {colorSpace, components, alpha, hex} objects; $extensions keep com.figma.variableId and com.figma.scopes. Figma's string and boolean variables use the string and boolean types Figma's own export uses, which DTCG 2025.10 does not define; the receipt counts them.
Before anything is kept, Stage1 reads the export back with its own reader. Every value must equal the source exactly, each under its own collection and mode, and every alias must come back as a reference. On any mismatch the command exits 1, names the mismatches and leaves nothing behind; --out must be new or empty. dtcg_export.json (dtcg_export 1.0.0) records the files and their SHA-256s, the counts written and read back, each collection's default mode and why, merged and conflicting duplicates, and everything not written with its reason: soft-deleted variables and styles, values the source does not hold, aliases into a deleted variable (written as their values), and text styles. DTCG typography needs fontWeight, lineHeight and letterSpacing, which fig_local_tokens does not record, so text styles are listed rather than completed with invented fields. Exported from the public USWDS design kit, that is 41 files, 1,189 tokens, 2,319 values and 1,440 aliases, all read back equal.
From CSS/SCSS (--design-tokens), only declarations for the document root become tokens, under their own custom-property names: unconditional :root, :host or html declarations form the :root set, @theme blocks form sets named by the at-rule, and a class or attribute selector that re-declares root names (such as .dark) becomes a modifier whose :root context holds the root values of the names it re-declares. A var() in @theme therefore resolves per context, as the cascade does. var(--x) becomes the reference {--x}; a calc() value is written as its computed value and listed; colours keep their own space; dimensions keep their declared px or rem. Declarations for other selectors apply to matching elements, not to the document, and are listed with their context, as are unresolved values. A JSON reading is already DTCG or Tokens Studio and is refused. shadcn's globals.css: 80 tokens, 115 values and 41 aliases read back equal; 54 declarations are listed (element rules, unresolved values and computed calc() values).
From a live site (--run), Stage1 first verifies the run against its manifest, then writes the custom properties the capture computed at the effective root, under the site's own names, plus a .dark modifier made only from declared .dark blocks (including blocks under @supports, which the receipt names). A property whose value differs between routes is listed, not averaged. A value is written only when its DTCG type can be read from the value itself (colour, px/rem dimension, number, cubic-bezier()); calc(), other units, keywords and font stacks are listed, never given an invented type. Each written root token carries ai.aquex.stage1.observed with value-matched occurrence and page counts from the run's computed styles: equal values, not uses of the variable, since nothing records which var() produced a value. The receipt states how many routes and effective-root variables the capture held. ui.shadcn.com (capture of 40 routes): 365 effective-root variables, 312 tokens and 352 values written and read back equal, 53 listed with reasons. designsystem.digital.gov: 0 effective-root variables on 40 routes, because USWDS compiles its Sass tokens into literal values; its 11 custom properties belong to a web component's shadow root, so the export is empty and says so.
Baselines and stage1 diff
Record what a run found, then compare later runs with it:
stage1 diff ./out/analysis/<analysis-id> --write-baseline .stage1/baseline.json
stage1 diff ./out/analysis/<later-analysis-id> --baseline .stage1/baseline.json<run-dir> is a durable analysis (redrift --durable) or a single-site capture run, so a team without a design file can still compare accessibility and IA. The baseline (check_baseline 1.0.0) holds each finding's identity, which families were measured, and the pins below. It is never overwritten and never written inside the run it describes; commit it beside your code. The run is verified against its attestation first and is never modified.
A finding's identity is exact. A drift value is its comparison surface, family, value as recorded and typeface. An explained value adds its category, and the origin for a browser default. An accessibility violation is its axe rule and impact; affected selectors are evidence, because many are generated ids. Presence is the site block or the design node. IA is a route's page type, or a navigation label and link. A Lighthouse insight is its route and id. Example locations are evidence, not identity, and values are never grouped: a value that moves in its last digit is a new finding, and the old one is resolved.
diff lists new, resolved and changed findings (changed means the same identity with different occurrence counts), with no totals. When the two sides are not comparable it lists nothing, exits 2 and names every pin that differs: the capture and analysis Stage1 versions, the drift report version, the Lighthouse version, the route set, the pages accessibility and Lighthouse measured, the sampling depth, and the design-input files. A family a run did not measure is reported as not measured with its reason, never as zero. Lighthouse insights move between identical captures (a same-release USWDS recapture 20 minutes apart gained two and lost one), so treat them as report-only. Multi-site suites are not supported by diff in this release.
Your team's rules and stage1 check
The measurements can be common; which of them count for your product, and which findings stop a release, are your team's call. Write them down in .stage1/rules.yaml:
rules_version: 1
families: [color, radius, border_color, a11y]
rules:
- id: no-new-off-theme-colour
select: { finding: value_drift, value_family: [color, border_color] }
when: new
block: true
- id: no-critical-a11y
select: { finding: a11y_violation, impact: critical }
when: present
block: true
- id: radius-on-theme
select: { finding: value_drift, value_family: radius }
when: present
block: false
baseline_policy:
new_unexplained_drift: block
waivers:
- finding: { finding: a11y_violation, rule_id: button-name, impact: critical }
reason: "Every affected node is inside an embedded video player"
expires: 2026-12-31stage1 check ./out/analysis/<analysis-id> --rules .stage1/rules.yaml --baseline .stage1/baseline.json --out ./stage1-checkfamilieslists what your product counts: the value familiescolor,font_size,radius,spacing,border_color,shadowandfont_weight, plusa11y,presence,iaandperf. A rule selects only findings in counted families.selectnames a finding type (value_drift,explained,a11y_violation,presence_site_only,presence_design_only,ia_page_type,ia_nav_item,perf_insight) and, optionally, exact values of its identity fields (one value or a list).when: presentfails when a selected finding exists;when: newfails when one is new since the baseline.block: truemakes that failure fail the job.- With a baseline,
baseline_policy.new_unexplained_drift: block(the default) also fails on any new off-system value in a counted value family;reportlists them without blocking. - A waiver suppresses exactly one finding identity until its date, and the report names what it suppressed; after the date the rule fails again and names the expired waiver.
--on YYYY-MM-DDsets the date waivers are judged by (default: today in UTC). - There are no thresholds, counts, ratios or scores to select, and severity is not selectable: rules block on findings, never on a number. A rules file that tries is refused, naming the field.
- Lighthouse insights change between identical captures, so a
perfrule may report but never block.
check writes check_report.json to a new directory: the pins and whether the baseline was comparable, each counted family's state, each rule's state (passed, failed, or not measured with its reason) with the findings behind it, the waivers, and the exit code. It holds no totals or scores. It exits 0 when no blocking rule failed, 1 when one did, and 2 when the check could not be made: an invalid or unreadable rules file, run or baseline; a broken attestation; a baseline that is not comparable; a blocking rule on a family the run did not measure; or a blocking when: new rule without a baseline. When the inputs were readable, the report is written even for exit 2, so a CI artifact shows the cause. This follows the model of linters and accessibility test tools such as ESLint, Lighthouse CI and axe-core, where a team configures which rules apply and which failures stop a build.
Run the check in GitHub Actions
This workflow captures a declared set of pages on every pull request, compares them with your design tokens and runs your rules. The job fails when a blocking rule fails (exit 1) or when the check could not be made (exit 2). Commit three files beside it: .stage1/routes.json, the pages to capture (for example ["/", "/docs/installation", "/docs/components/base/button"]); .stage1/rules.yaml, your rules from the section above; and .stage1/baseline.json, the findings you have accepted.
name: Stage1 check
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
stage1:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '24'
- run: npm install --global @aquex/[email protected]
- run: stage1 browser setup --with-deps
- name: Capture the declared pages
run: |
stage1 inspect app --url https://staging.example.com/ --seed-routes .stage1/routes.json --crawl-depth 0 \
--max-pages 3 --sampling compact --sequential --out "$RUNNER_TEMP/capture" > "$RUNNER_TEMP/capture.json"
echo "RUN_DIR=$(node -p "require('$RUNNER_TEMP/capture.json').runDir")" >> "$GITHUB_ENV"
- name: Compare with the design tokens
run: |
stage1 design-tokens src/styles/globals.css --out "$RUNNER_TEMP/design"
stage1 redrift "$RUN_DIR" --design-tokens "$RUNNER_TEMP/design/design_source_tokens.json" --durable --out "$RUNNER_TEMP/analysis"
- name: This run's baseline (commit it to accept what this run found)
run: stage1 diff "$RUNNER_TEMP/analysis" --write-baseline "$RUNNER_TEMP/stage1/baseline.json" > /dev/null
- name: Your rules
run: stage1 check "$RUNNER_TEMP/analysis" --rules .stage1/rules.yaml --baseline .stage1/baseline.json --out "$RUNNER_TEMP/stage1/check"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: stage1
path: ${{ runner.temp }}/stage1/- The route set is
.stage1/routes.json.--crawl-depth 0captures those pages and no others; set--max-pagesto their number. The sampling depth is--sampling compact(orfull, the slower default). The baseline pins both, so changing either is a deliberate re-baseline, never a silent difference. - The exit code is the job's result: 0 when no blocking rule failed, 1 when one did, 2 when the check could not be made.
check_report.jsonin the uploaded artifact names the rule, the findings or the cause.stdoutcarries only each command's JSON result; progress goes to stderr. - Adopt a baseline: run the workflow once from the Actions tab. Its check exits 2 because
.stage1/baseline.jsondoes not exist yet. Download the run'sstage1artifact and commit itsbaseline.jsonas.stage1/baseline.json. - Update the baseline the same way: replace
.stage1/baseline.jsonwith thebaseline.jsonof a run whose findings you reviewed, in a commit of its own. Take it from a CI run, not a local one, so both sides come from the same capture environment. A new Stage1, Lighthouse or design-token file, or a changed route set or sampling depth, makes the old baseline not comparable: the check exits 2 and names the pin, and the fix is a new baseline. - Without a design file, skip the comparison step and pass
"$RUN_DIR"todiffandcheck: accessibility and IA rules work on a capture run. - Pin the Stage1 version in
npm install, so an upgrade is a reviewed change; the baseline pins it too.
Measured on GitHub's ubuntu-24.04 runner with these steps against three ui.shadcn.com pages, in two runs: stage1 browser setup --with-deps took 25 seconds, each of four captures 248–345 seconds and each comparison 1 second. Capture ran headless with no xvfb and no display, and no pass failed or degraded. Each run exited 0 with the rules above and 1 with a planted blocking rule. The no-design-file variant on three designsystem.digital.gov pages captured in 125 seconds and exited 0 with accessibility and IA rules and 1 with a planted one. Same-release noise, measured in both runs: two captures of the same three pages with the same build, four to six minutes apart, were comparable. No value, accessibility or IA finding was new, resolved or changed; Lighthouse insights moved (3 new and 1 resolved, then 2 new and 1 resolved), which is why a perf rule can report but never block.
MCP clients
After installation, configure a stdio client with an absolute writable output root. Replace the example root with a real local path (Windows JSON paths need escaped backslashes). If your client cannot find the global executable, use its absolute installed path.
{
"mcpServers": {
"stage1": {
"command": "stage1-mcp",
"args": ["--out", "/absolute/stage1-runs"]
}
}
}Without a global install, use "command": "npx" with "args": ["-y", "--package", "@aquex/[email protected]", "stage1-mcp", "--out", "/absolute/stage1-runs"]. A bare npx @aquex/stage1 runs the stage1 CLI, not the server.
STAGE1_OUTPUT_ROOT is an alternative; explicit --out wins. Missing, relative or unwritable roots fail before capture. One server owns a root; one capture runs at a time. All list/read/export operations stay within that root. Do not put provider secrets into a shared client configuration.
Start stage1_inspect_app with {"url":"https://example.com","maxPages":1} or stage1_inspect_surface with its surface options (both also take sampling: compact or full). Valid capture requests return a job acknowledgment, not capture success. Invalid target schemes, embedded URL credentials, API options, repositories or suite inputs return isError: true with { code: "CAPTURE_REFUSED", message } before creating a job, run record or site request. The message names the validation rule without echoing the submitted URL or credentials. MCP input-schema violations remain protocol errors. Poll stage1_job_status with jobId, then call stage1_job_result. Use stage1_job_cancel to cancel. Status remains responsive during capture; a second capture returns busy. Job metadata survives restart; unfinished jobs become interrupted and do not resume paid work. Remove retained terminal metadata locally when no longer needed.
Other tools are stage1_list_runs, stage1_list_analyses, stage1_get_artifact, stage1_check, stage1_fig_extract, stage1_design_tokens, stage1_redrift, stage1_view, and stage1_export. Artifact reads take a run ID or an analysis ID and an attested artifact key, not an arbitrary path; pointer (RFC 6901) selects a subtree, and a response over maxChars (default 40,000; at most 1,000,000) is refused with OVER_CAP, the cap and the full size, never cut. stage1_list_analyses lists durable analyses newest first with their source run and artifact names. stage1_check applies a rules file (and optionally a baseline) to a run or analysis with the same engine and the same report bytes as stage1 check, writing below the root's checks directory; the result carries exit_code and the report when it fits maxChars. Failures are named: UNKNOWN_ID, OUTSIDE_ROOT, OVER_CAP, BAD_POINTER, ATTESTATION_MISMATCH and INVALID_INPUT. Fig extraction takes absolute local file paths and returns counts/filenames below the configured root. Design extraction takes absolute files, a simple outName, and optional initialFontSizePx (default 16), writing below the configured root's design directory. Redrift takes a runId plus optional absolute figmaLibrary/figTokens/designTokens paths and optional typeFactors: [{family, factor}], and returns an analysis ID. Type factors have the same validation and meaning as the CLI option. View takes runId and optional analysisId; export adds required approveSharing equal to runId, returning a fresh exportDir. Unknown properties are rejected. Stdout is JSON-RPC only; diagnostics use stderr.
For authenticated captures, stage1_export also requires approveAuthenticatedSharing equal to runId.
Migration and next capabilities
This distribution supports single-site and multi-site capture, URL-list discovery, bounded API and local repository inspection, run views, approved static export, Figma REST inspection and local .fig comparisons. Internal reconciliation, remote evidence storage and internal maintenance commands are excluded. Existing historical records remain readable when their artifacts are available. There is no compatibility alias for retired producer commands or the old subject-name export switch.
CLI help/success exits 0; invalid input, refused export or a failed capture pass exits 1. Two commands differ: stage1 check exits 0 when no blocking rule failed, 1 when one did and 2 when the check could not be made; stage1 diff exits 0 when it compared or wrote a baseline and 2 when it could not, and never 1. Usage errors for those two exit 2. SIGINT/SIGTERM preserve truthful failure records and release acquired resources. MCP schema errors are protocol errors; operational failures are structured errors. Report problems to [email protected] with the missing measurement and the installed version, without attaching credentials or private capture content.
Troubleshooting
stage1: command not found: ensure your npm global executable directory is onPATH, or runnpx --yes --package @aquex/[email protected] stage1 --help. Use the same prefix before other CLI commands. MCP clients may need the absolute path tostage1-mcp.- Browser missing: run
stage1 browser setupafter installing or upgrading Stage1, then retry. Browser revisions belong to the installed runtime; an unrelated Playwright installation may have a different revision. Linux also needs Chromium's system libraries. - View port in use: use
stage1 view RUN_DIR --port 0to choose a free local port. Keep the terminal running while using the view; Ctrl-C closes it. - Output root busy: stop the other CLI capture or MCP server using that root, or choose a separate
--outdirectory. Do not remove a live process's lease. - Capture exited 1: check
failedPassesand the run's view for the failed measurement and retained evidence. An MCP job acknowledgment is not a completed capture; poll its status and result. - MCP startup failed: supply a writable absolute
--outpath orSTAGE1_OUTPUT_ROOT; relative paths are refused.stage1-mcp --helpandstage1-mcp --versionwork without starting a server. - Export refused: use a fresh destination outside the source run and the matching run ID in
--approve-sharing. Authenticated and suite captures need the additional declarations described above. - Something else: email [email protected] with the installed version (
stage1 --version) and what failed. Do not attach credentials or private capture content.
API declarations
Add API evidence to inspect app with --api <base-url>. Use --api-spec <url-or-file> for an OpenAPI/Swagger declaration, or --api-type graphql --api-graphql-endpoint /graphql for introspection. The default type is auto: it selects GraphQL only when the lowercased --api base URL contains /graphql; otherwise it selects REST. Use an explicit type for other GraphQL endpoint names. Without an explicit spec, REST checks known declaration locations. It does not call declared operations or probe resources by default. --api-probe explicitly permits bounded GET probes when no spec is found.
--api-max-endpoints defaults to 100, accepts 1–1000, and caps retained operations and fallback resource requests. Discovery records the bound, observed operation count, truncation, spec location and probe request count. The default HTTP deadline is 10 seconds; known specification-location discovery uses 5 seconds and fallback probes use shorter 2–3-second deadlines. Declarations and introspection responses are capped at 4 MiB. Robots rules are enforced. External OpenAPI references are retained without fetching. WebSocket capture is refused. Unknown authentication requirements remain unknown; optional credential parameters are not treated as required.
Use --api-auth-env <name> or --api-auth-file <file> (mutually exclusive) to refer to JSON shaped like {"type":"bearer","credentials":{"token":"…"}}. Other types are oauth2 (token), basic (username/password) and api-key (value, name, and optional in: header or query). The file/environment value is loaded privately; credential values never belong in argv, URLs or MCP arguments. Auth files are limited to 64 KiB. Credentials are sent only to the declared API origin. Credential- or PII-keyed example values and exact supplied credential values are scrubbed before evidence and artifact writes. Other declared examples and probe samples can remain; redaction does not remove every example or guarantee anonymous content. Schema names, types, descriptions and references remain readable.
MCP exposes the same behavior through stage1_inspect_app.api: baseUrl, specUrl, type, graphqlEndpoint, probe, maxEndpoints, authEnv, authFile. Credential files use absolute paths. Four artifacts are produced: raw and normalized API manifests, entity catalog and capability map.
The run view links to #/api when API records or an API capture attempt exist. It shows the specification/probe source, dialect, robots decisions, endpoint groups, authentication declarations, entity fields and enums, unresolved references and warnings. Missing or failed captures remain explicit. Historical API records keep their recorded meaning; old heuristic auth percentages are not displayed.
Local source repository
Use stage1 inspect app --url https://example.com --repo /path/to/source --out /path/outside/source
or MCP stage1_inspect_app.repoPath to read source beside the captured site. Output must be outside
the source tree. Nothing in the tree is installed, executed or written; symlinks, hidden files,
dependencies and build-output directories are excluded. The shared selection walks breadth-first in entry-name order, selecting .ts, .tsx, .js, .jsx, .vue, .css, .scss, .sass, .less, .json, .mjs and .cjs. It stops at 1,000 selected files,
20,000 directory entries or 16 MiB total; individual files over 1 MiB are skipped.
Structure shows framework markers, source route patterns beside captured routes, component syntax
and directory conventions, and design-system markers from dependencies, local shadcn directories
and the repository's own package declaration. Semantic token-pattern counts cover var() references
and selected Tailwind class patterns; raw counts cover literal CSS patterns.
Each analyzer reports examined/read files, parsing failures and selection limits. These static
patterns do not prove runtime use or complete coverage. The source root is stored as . in
repo_map 2.0 and the manifest; no absolute source-root path or confidence/usage estimate is stored.
The cache key includes a truncated SHA-256 of the absolute root and scan configuration; it is not
a content digest or a claim that source patterns are used in the captured page.
