Repository navigation
Fix the oxlint plugin seam, then enforce the StyleX token constraint in two layers - #1172
Closed
schickling-assistant wants to merge 3 commits into
Conversation
Contributor
Storybook Previews
Report historyPR 1172 · 2026-09-01 22:01 UTC
PR 1172 · 2026-09-01 18:43 UTC
PR 1172 · 2026-09-01 17:01 UTC
|
schickling-assistant
added a commit
that referenced
this pull request
Sep 1, 2026
Eight fixed-output derivations take the root lockfile as an input, and renaming the token package plus adding the gate stack to @overeng/utils moved it. Seven are CLI-shaped and were measured by `evergreen fod refresh --linux-system x86_64-linux`; `nix/oxc-config-plugin.nix` is hand-written, so evergreen cannot reach it and its value comes from the `got:` line of a local `nix build .#oxlint-npm`, then re-verified by building again clean. Note for whoever merges this alongside #1172: that eighth hash is contested by construction, not by accident. Its inputs include the root lockfile, so it moves independently for any branch that touches dependencies — #1172 adds a devDep to the very package it covers. Both values are correct for their own tree and neither is correct for the merged one. Resolve the conflict by re-measuring on the merged tree, never by picking the surviving side.
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-01-lint-toolchain
branch
from
September 1, 2026 18:38
36143c8 to
7cecf10
Compare
The wrapper existed to make `@overeng/oxc-config` resolve, but it wrote the whole `jsPlugins` list, so any plugin declared beside ours became unresolvable. That is why two consumers already carry local workarounds with comments describing the clobbering. Substituting only our entry point makes adopting a third-party plugin — `@stylexjs/eslint-plugin` is the immediate one — possible at all. The match is the entry point, not the package directory, because we now ship a second plugin entry from the same directory and swapping that for ours would reintroduce the same silent failure one level down. Two more defects fixed in the same pass, both of which cost real debugging time. `((i++))` evaluates to the OLD value of `i`, so it exits 1 when `i` is 0, and `writeShellApplication`'s `set -o errexit` turned that into a silent abort: with `--config` as the first argument the wrapper exited 1 and printed nothing, which in a pipeline is indistinguishable from a lint failure. And because the injected plugin is a Nix build-time snapshot, editing a rule had no effect and adding one reported "not found in plugin 'overeng'" — rule development was impossible through the wrapper. `OVERENG_OXC_CONFIG_PLUGIN` now overrides the path, so pointing it at the plugin's TypeScript entry lints against live source (the host runtime is Bun, which imports .ts directly). The regression test drives the real built wrapper against the real oxlint rather than a stub, because all three were failures of plugin RESOLUTION, which a stub cannot model. It prefers the already-realised wrapper on PATH so it costs nothing inside the devenv shell.
Our token architecture makes raw colours available and semantic tokens merely conventional, and a raw scale step is an inlined constant: a component reading one compiles cleanly and silently ignores dark mode. Types cannot close that gap, because they constrain what callers pass in, not what an author writes inside their own style object. So it has to be a failing check. Both layers ship because they are provably complementary, not redundant. Upstream per-property value limits are strictly better than anything we would hand-roll for pure colour properties — they distinguish a literal from a token reference — but a limit is keyed on one property and structurally cannot see a colour embedded in a composite value. Measured: upstream reports nothing about the colour inside `boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'`, which the first-party rule catches. Banning composite properties outright is not viable; they need literal offsets. The overlap on plain colour properties is deliberate, because an array `limit` drops its custom `reason` and only our message names the remedy. Upstream loads through a first-party namespace shim rather than the bare package specifier. Upstream ships no `meta.name`, so oxlint would derive the namespace from the specifier — and a bare specifier only resolves from the ROOT `node_modules`, which this repo's root manifest cannot own because it is a pure genie workspace aggregate. The shim supplies `meta.name` explicitly, so the dependency sits on the package that owns lint config and rule names still read exactly as upstream documents them. Colour properties are enumerated rather than globbed: measured, `*olor*` also matches `colorScheme`, `colorAdjust`, `forcedColorAdjust`, `printColorAdjust` and `colorInterpolation`, which take keywords and would be wrongly banned. The second rule partitions the focus ring. StyleX orders conditions by kind, not by authoring position, so a selected-state shadow beat a focus-visible shadow and an element that was both selected and keyboard-focused silently lost its focus ring. Reserving `outline*` for focus-visible makes that collision impossible rather than managed, and it is the half of the invariant a linter can see. `stylex-no-raw-color` treats `color-mix()` and relative-colour syntax as derivations, not constructors: a derivation over a token still follows the colour scheme, so flagging it would contradict the rule's own purpose. Raw arguments inside them are still caught, because the patterns are not anchored. These rules use TSESTree types instead of the `any` the sixteen older rules use. The no-any rule is binding and postdates that convention, and `@typescript-eslint/utils` is already a devDependency, so the type-only import is erased before bundling. The older rules are left alone. The pilot package fails the enforcement derived from it, in 17 places: 13 sites still using the deprecated top-level pseudo-class syntax, and 4 raw colours. That is the rules working — they found real drift in the one place everyone would have assumed was clean. Migrating it is out of scope here: nesting a pseudo-class changes which condition wins, and that package has no visual gate, so changing it now would be exactly the unverified visual change our requirements forbid; the colours need semantic tokens that do not exist yet. Each site therefore carries a per-line disable with a reason instead of a file-scoped override, so new code there stays gated. Tracked in #1171 alongside that package's existing open divergence, which needs the same visual gate.
Adding `@stylexjs/eslint-plugin` to `@overeng/oxc-config` moves the root lockfile, which is an input to every pnpm-deps fixed-output derivation — so three hashes had to be re-measured, not just the one covering that package. `nix/oxc-config-plugin.nix`'s hash is hand-written; `evergreen fod refresh` cannot address it, so the value is the one `nix build` reported. The other two were refreshed by evergreen (it writes the correct value even though its post-write verification step fails to evaluate these attrs). That hash is contested by construction rather than by accident: its inputs include the root lockfile, so it moves for any branch that touches dependencies. The value here is correct for THIS tree and will not be correct for the merged tree. Whoever merges second must re-measure rather than keep either side.
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-01-lint-toolchain
branch
from
September 1, 2026 21:55
7cecf10 to
9803768
Compare
schickling-assistant
changed the base branch from
main
to
schickling-assistant/2026-09-01-stylex-shared-layer
September 1, 2026 21:56
This was referenced Sep 1, 2026
Collaborator
Author
|
Superseded by #1191, which collapses this stack onto Closing rather than merging: propagating This PR's content is in #1191, verified with This body stays as the record of the per-change evidence, which #1191 summarises but does not reproduce in full. Posted on behalf of @schickling
|
schickling
pushed a commit
that referenced
this pull request
Sep 2, 2026
… the main merge (#1191) * pin the StyleX lint plugin and the Tailwind->StyleX converter Both are catalog-only: nothing declares them yet, so no generated file or lockfile entry moves. Consumers opt in per package, which is the point for the converter — it must never reach a shared component package. @stylexjs/eslint-plugin rides at 0.19.0 rather than its own latest so it stays in lockstep with the compiler pins; its dependency closure pins @stylexjs/shared 0.19.0, so a split would be silently incoherent. The converter is pinned to the published 0.1.0-alpha.1 rather than a git rev of the maintainer's fork. The fork is where the useful work is, but its build output is gitignored and it declares no `prepare` script, so a git-rev install resolves to a package with no lib/ at all and every import dies with ERR_MODULE_NOT_FOUND. That is true even with lifecycle scripts fully enabled, so it is not a policy question. alpha.1 carries gitHead a150f9e, the exact fork revision that was wanted, and ships the built output, so we get the intended code through an integrity-pinned npm version instead of an unbuildable git reference. The three @babel/* pins are load-bearing, not incidental. Babel resolves plugin names relative to the calling package, so the consumer must declare the syntax plugins even though the converter lists one of them as its own dependency; without them every invocation fails. Babel 7 because @babel/plugin-syntax-typescript@7 peers @babel/core@^7 and Babel 8 trips strictPeerDependencies. * require Storybook 10.5 so the visual gate can test one project per theme The gate needs light and dark covered as separate Vitest projects. That is `storybookTest({ initialGlobals })`, added in 10.5.0 (#35226) and absent from 10.4.6 — verified by diffing the shipped plugin option types, not by reading release notes: 10.4.6's UserOptions has no such field, 10.5.10's documents it as "define one Vitest project per theme". Without the bump the gate cannot express the requirement at all. 10.5.10 is current stable across the whole cohort. Checked upstream MIGRATION.md for 10.4->10.5: one entry, the deprecation of ExternalDocs and ExternalDocsContainer, which this repo does not use. No breaking change to absorb. @storybook/addon-vitest peers `storybook@^10.5.10`, so the cohort has to move together rather than one package at a time. addon-a11y is here because parameters.a11y.test is inert unless the addon is registered, and it defaults to warn-only. genie/internal.ts had the framework version restated as a literal in packageExtensions, which would have left a second @storybook/react-vite resolving after this bump. It now derives from the catalog so it cannot drift again. The unplugin duplicate exception was re-checked rather than assumed: @storybook/csf-plugin@10.5.10 still declares unplugin ^2.3.5, so the exception is still required. Its reason now names the constraint instead of a consumer package, because the consumer is moving. The pnpm-lock regeneration invalidated eight pnpm-deps FODs; those hashes are the measured values, refreshed in one batch. Proof: storybook:build green for one DOM package (effect-schema-form-aria, 39 stories) and one TUI package (tui-stories, 11 stories), both resolving 10.5.10, since the two share a factory but not a code path. check:quick green. * refactor(stylex): make the token package browser-pure and own CSS injection Two things were tangled behind one name. `@overeng/stylex-preset` shipped browser-side design tokens *and* a Node-side bundler plugin, and the plugin was what dragged the package into 18 workspace closures while only two packages actually consumed a token. Renaming it to `@overeng/stylex-tokens` says what it durably is — the Tailwind framing is gone with Tailwind — and moving the plugin into `@overeng/utils` beside the Storybook and Playwright factories drops it out of 16 of those closures rather than merely relocating it, because utils needed the token package only to wire the story factory. The integration is rewritten rather than moved. Upstream decides which emitted CSS asset receives the compiled rules by matching filenames: a caller predicate, then an unhashed `index.css`, then `style.css`, then — silently, at exit code zero — the first CSS asset in the bundle. A predicate that matches nothing degrades to exactly the fallback it was meant to prevent, and on a route-chunked build the first CSS asset can be a lazily-loaded chunk, which strands every rule while the build succeeds. That is not a bug to configure around; it is the wrong seam. Every peer compile-to-CSS plugin routes generated CSS through the module graph instead, and StyleX itself already does so in dev. So compiled CSS now enters as a virtual CSS module that each named entry imports, and the bundler owns placement, hashing, minification and manifest membership. The eager guarantee reduces to "the entry imports it", which a build can check. Content is not known until every module has been transformed, so the module loads a placeholder and its real content is swapped in from `renderChunk` via the `vite:css-post` transform handler — the UnoCSS technique. Per-module virtual CSS (the parcel-macros technique) was rejected: StyleX atomic rules must be globally sorted by priority and de-duplicated, which only one collected blob can express. Going through the pipeline also picks up minification, which the appended-asset path bypassed. The import is appended rather than prepended to the entry, so unlayered StyleX rules sort after the entry's own stylesheets — the precedence the migration relies on while a utility framework is still present. The entry stays checked JavaScript for the reason #1167 established: Vite loads config through Node, which refuses TypeScript stripping under `node_modules`. Also drops the Storybook factory's `stylex` flag. It was redundant wherever an app's Vite config already registers the plugin, and measurably harmless rather than a defect, so it goes as noise. Compiled class names move. A `defineConsts` value inlines its literal, but the atomic class hash still incorporates the constant's package-qualified identity, so renaming the package rehashes every constant-valued declaration while leaving the declaration itself byte-identical. Emitted rules are unchanged as a multiset; the markup baselines were regenerated for the names. * feat(utils): add the story-driven visual and accessibility gate BLOCKED, and deliberately committed red: this does not type-check until `genie/external.ts` gains the five catalog pins `CatalogPins` owns — @storybook/addon-vitest 10.5.10, @storybook/addon-a11y 10.5.10, @vitest/browser 4.1.9, @vitest/browser-playwright 4.1.9, playwright 1.61.0 — and those pins are declared in `packages/@overeng/utils/package.json.genie.ts`. Nothing else is missing; see the PR description for the exact remaining wiring. Three settings here are non-default, and each ecosystem default fails silently, which is why the gate is a shared helper rather than per-package config: The comparator does not compare exactly even with the allowed mismatch at zero. pixelmatch defaults to a perceptual threshold of 0.1 and to ignoring anti-aliased pixels — verified by reading the shipped defaults. A one-pixel radius change with a six-of-255 colour shift passes under those defaults. So the project pins threshold 0 with antialiasing included. Per-story accessibility is a no-op by default: `parameters.a11y.test` defaults to 'todo', which the addon maps to a warning rather than a failure. The gate raises it to 'error'. That parameter is also inert unless `@storybook/addon-a11y` is registered, so `createDomStorybookConfig` grew an `a11y` flag rather than leaving a second thing to remember. Baselines are derived from a git ref and never committed. Captures depend on the host's installed fonts wherever a generic family is used, which moves one to three thousand pixels per story — one to two orders of magnitude more than a real regression — so a committed baseline is only valid on the machine that made it. `runStoryGate` materializes a worktree at the ref, borrows the installed dependencies when the lockfile has not moved, captures there, then compares on the same host. Applying the annotations is done from `beforeAll` rather than at module scope because `setProjectAnnotations` replaces rather than merges and the addon calls it from its own setup file; setup-file ordering is decided by Vite's config merge and is not ours to control, but every setup module is evaluated before any hook runs, so a `beforeAll` is deterministically last. Story add/remove is the one failure the test results cannot show, because a story that no longer exists produces no test. `resolveScreenshotPath` is called once per assertion, so the run records the baselines the current tree asked for and the runner reports any baseline nobody claimed. Dimension mismatch turned out to be already loud in this stack — the comparator refuses to compare differently-sized images and says so — unlike the difference composite in the earlier prototype, which under-reported silently. * feat(stylex): wire the story gate into effect-schema-form-aria Declares the gate stack, exports `@overeng/utils/node/storybook/gate` and its browser-side setup entry, and gives the already-migrated Aria package a `vitest.gate.config.ts` so the gate has something real to run against. The five gate packages are devDependencies of `@overeng/utils`, not dependencies, for the same reason `storybook` and `@storybook/react-vite` already are: anything that runs the gate necessarily owns its own Storybook install, and making them real dependencies would put Storybook back in the closure of everything that depends on utils — the exact shape this branch just spent a commit removing. `a11y: true` on the Aria Storybook config is load-bearing rather than cosmetic: `parameters.a11y.test` is inert unless `@storybook/addon-a11y` is registered, so without it the gate's accessibility check passes everything silently. * fix(stylex): register the StyleX compiler in the gate's Vitest project Vitest treats `vitest.gate.config.ts` as the Vite config for the run, so the package's `vite.config.ts` is never loaded — unlike a Storybook build, where the builder merges it. Without the compiler transform every story throws `Unexpected 'stylex.keyframes' call at runtime` before a single assertion runs. * fix(stylex): let the gate report regressions rather than absolute health A baseline capture that exits non-zero is not a broken capture. Under --update the screenshots are written regardless, and the reference ref can legitimately carry failing stories: this package has real accessibility violations today, and aborting on them would make the gate unusable on exactly the packages that need it most. So the capture is kept when it produced a report, its failures are recorded, and the compare phase subtracts them. What the gate answers is 'did this change make it worse', which is what R08 asks for. Pre-existing failures are still reported, under their own key, so subtracting them cannot quietly become ignoring them. * fix(stylex): serve workspace sources to the gate's derived worktree The baseline half of a run happens inside a git worktree that borrows the main tree's node_modules by symlink, so every workspace source — including the gate's own setup file — resolves to a path outside the served root and Vite refuses it. The whole run failed at import with nothing but "Failed to fetch dynamically imported module". Strict fs roots buy nothing on a throwaway test server and cost the derived-baseline mechanism entirely. Also ignores `.vitest-attachments`. Those are the diff and actual images written when a screenshot assertion fails: evidence for one run, never an artifact. With baselines derived from a git ref into node_modules/.cache, nothing the gate produces is committable, which is the point of R09. * docs(changelog): record the story gate * fix(nix): refresh the pnpm-deps hashes the lockfile move invalidated Eight fixed-output derivations take the root lockfile as an input, and renaming the token package plus adding the gate stack to @overeng/utils moved it. Seven are CLI-shaped and were measured by `evergreen fod refresh --linux-system x86_64-linux`; `nix/oxc-config-plugin.nix` is hand-written, so evergreen cannot reach it and its value comes from the `got:` line of a local `nix build .#oxlint-npm`, then re-verified by building again clean. Note for whoever merges this alongside #1172: that eighth hash is contested by construction, not by accident. Its inputs include the root lockfile, so it moves independently for any branch that touches dependencies — #1172 adds a devDep to the very package it covers. Both values are correct for their own tree and neither is correct for the merged one. Resolve the conflict by re-measuring on the merged tree, never by picking the surviving side. * style(stylex): satisfy the repo's explicit-boolean-compare rule Mechanical: `!existsSync(x)` -> `existsSync(x) === false` and friends across the new gate runner, the StyleX plugin factory and the Storybook factory. No behaviour change; `check:quick` is green and the gate still reports zero on an unchanged tree. * fix(stylex): stop the gate passing over an unusable baseline, and freeze motion Two defects found from the consumer side, both in the shared layer. The gate subtracts failures present at both refs so it reports regressions rather than absolute health — necessary, or it is unusable on any package carrying drift. But with no floor the degenerate case inverts it: when every story fails at the baseline they all land in `preExisting`, the regression list is empty by construction, and the gate reports success over a total loss of styling. Measured at 212/212 failed on one app and 708/942 on a component library. The proximate line was ordering — the pre-existing skip ran before the missing-reference branch, so a story with no baseline image had its compare-side failure swallowed as debt. Coverage is now derived from the filesystem rather than inferred from message text: a baseline the compare run asked for that does not exist is `uncovered` and fails, and cannot be absorbed into `preExisting` by any wording. Zero passes at the baseline is a hard failure. Deliberately no fraction threshold — a quarter passing is degraded but still informative, and a threshold set wrong rejects that along with the genuinely broken case, so the machine refuses only the unambiguous case and the counts go in front of a human. The verdict is extracted as `isStoryGateOk` and unit-tested against both measured shapes, so the guard is provable without a capture cycle. The stability timeouts underneath were transitions firing once on mount, not continuous motion: every Button variant failed while Avatar and Badge passed, and Button at rest has only `transition-colors duration-150`. An A/B with the accessibility addon off changed nothing, which rules out axe triggering them, so disabling transitions before first paint is sufficient and not merely necessary. A freeze cannot reach canvas or JS-driven animation, so those stories declare `parameters.storyGate.unstable` and are reported as excluded — otherwise they fail at baseline and quietly poison the same subtraction this commit just fixed. Also gives the gate a runnable entry point. It was a library function with no caller, so every target had to write its own driver, and a check nobody can run is not the working visual check R08 asks for. * fix(nix): substitute rather than replace the oxlint JS-plugin list The wrapper existed to make `@overeng/oxc-config` resolve, but it wrote the whole `jsPlugins` list, so any plugin declared beside ours became unresolvable. That is why two consumers already carry local workarounds with comments describing the clobbering. Substituting only our entry point makes adopting a third-party plugin — `@stylexjs/eslint-plugin` is the immediate one — possible at all. The match is the entry point, not the package directory, because we now ship a second plugin entry from the same directory and swapping that for ours would reintroduce the same silent failure one level down. Two more defects fixed in the same pass, both of which cost real debugging time. `((i++))` evaluates to the OLD value of `i`, so it exits 1 when `i` is 0, and `writeShellApplication`'s `set -o errexit` turned that into a silent abort: with `--config` as the first argument the wrapper exited 1 and printed nothing, which in a pipeline is indistinguishable from a lint failure. And because the injected plugin is a Nix build-time snapshot, editing a rule had no effect and adding one reported "not found in plugin 'overeng'" — rule development was impossible through the wrapper. `OVERENG_OXC_CONFIG_PLUGIN` now overrides the path, so pointing it at the plugin's TypeScript entry lints against live source (the host runtime is Bun, which imports .ts directly). The regression test drives the real built wrapper against the real oxlint rather than a stub, because all three were failures of plugin RESOLUTION, which a stub cannot model. It prefers the already-realised wrapper on PATH so it costs nothing inside the devenv shell. * feat(oxc-config): enforce the StyleX token constraint in two layers Our token architecture makes raw colours available and semantic tokens merely conventional, and a raw scale step is an inlined constant: a component reading one compiles cleanly and silently ignores dark mode. Types cannot close that gap, because they constrain what callers pass in, not what an author writes inside their own style object. So it has to be a failing check. Both layers ship because they are provably complementary, not redundant. Upstream per-property value limits are strictly better than anything we would hand-roll for pure colour properties — they distinguish a literal from a token reference — but a limit is keyed on one property and structurally cannot see a colour embedded in a composite value. Measured: upstream reports nothing about the colour inside `boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'`, which the first-party rule catches. Banning composite properties outright is not viable; they need literal offsets. The overlap on plain colour properties is deliberate, because an array `limit` drops its custom `reason` and only our message names the remedy. Upstream loads through a first-party namespace shim rather than the bare package specifier. Upstream ships no `meta.name`, so oxlint would derive the namespace from the specifier — and a bare specifier only resolves from the ROOT `node_modules`, which this repo's root manifest cannot own because it is a pure genie workspace aggregate. The shim supplies `meta.name` explicitly, so the dependency sits on the package that owns lint config and rule names still read exactly as upstream documents them. Colour properties are enumerated rather than globbed: measured, `*olor*` also matches `colorScheme`, `colorAdjust`, `forcedColorAdjust`, `printColorAdjust` and `colorInterpolation`, which take keywords and would be wrongly banned. The second rule partitions the focus ring. StyleX orders conditions by kind, not by authoring position, so a selected-state shadow beat a focus-visible shadow and an element that was both selected and keyboard-focused silently lost its focus ring. Reserving `outline*` for focus-visible makes that collision impossible rather than managed, and it is the half of the invariant a linter can see. `stylex-no-raw-color` treats `color-mix()` and relative-colour syntax as derivations, not constructors: a derivation over a token still follows the colour scheme, so flagging it would contradict the rule's own purpose. Raw arguments inside them are still caught, because the patterns are not anchored. These rules use TSESTree types instead of the `any` the sixteen older rules use. The no-any rule is binding and postdates that convention, and `@typescript-eslint/utils` is already a devDependency, so the type-only import is erased before bundling. The older rules are left alone. The pilot package fails the enforcement derived from it, in 17 places: 13 sites still using the deprecated top-level pseudo-class syntax, and 4 raw colours. That is the rules working — they found real drift in the one place everyone would have assumed was clean. Migrating it is out of scope here: nesting a pseudo-class changes which condition wins, and that package has no visual gate, so changing it now would be exactly the unverified visual change our requirements forbid; the colours need semantic tokens that do not exist yet. Each site therefore carries a per-line disable with a reason instead of a file-scoped override, so new code there stays gated. Tracked in #1171 alongside that package's existing open divergence, which needs the same visual gate. * fix(stylex): limit the motion freeze to motion, and record its flake honestly Drops `caret-color` and `scroll-behavior` from the injected rule. Caret blink is already handled by the matcher's own `caret: 'hide'` screenshot option and the scroll rule was speculative; an unevidenced `!important` against every element is a liability rather than insurance. Animations are made to finish rather than pause, because pausing freezes each one at whatever frame it had reached when the style applied, which depends on mount timing. Neither change fixes the flake, and the flake is real. Measured on effect-schema-form-aria, 39 stories, both sides captured under the freeze in one invocation: pre-freeze 0 changed x3 runs, plus an independent control at the pre-freeze commit that also saw 0 and no timeouts freeze, pause 1, 3, 1 freeze, finish 1, 0, 0, 1 freeze, finish+narrow 2, 0, 0, 0 So the freeze intermittently produces one to three spurious diffs on a package that has no animations at all and only 150ms transitions. Cause unresolved. Shipping it anyway, deliberately, because the asymmetry matters: this is a false ALARM, not a false pass. It never lets a regression through, and the two stacks blocked on the freeze are blocked by stability timeouts that make their gate report nothing at all. A gate that occasionally cries wolf is strictly better than one that cannot see. It is not yet good enough to gate a merge unattended, and the PR says so rather than implying the gate is clean. * chore: regenerate config and re-measure the pnpm-deps FODs Adding `@stylexjs/eslint-plugin` to `@overeng/oxc-config` moves the root lockfile, which is an input to every pnpm-deps fixed-output derivation — so three hashes had to be re-measured, not just the one covering that package. `nix/oxc-config-plugin.nix`'s hash is hand-written; `evergreen fod refresh` cannot address it, so the value is the one `nix build` reported. The other two were refreshed by evergreen (it writes the correct value even though its post-write verification step fails to evaluate these attrs). That hash is contested by construction rather than by accident: its inputs include the root lockfile, so it moves for any branch that touches dependencies. The value here is correct for THIS tree and will not be correct for the merged tree. Whoever merges second must re-measure rather than keep either side. * fix(effect-schema-form-aria): resolve the pilot package's three drift findings Three separate checks each found real drift in this package, none of them looking for it: the state-idiom review behind decision 0004, the StyleX lint rules the package's own patterns produced, and the first run of the visual and accessibility gate. All three were deferred on the same prerequisite — a working visual gate for this package — which the gate now supplies, so they are settled together rather than three times over. Raw colours become semantic tokens. `on-primary` is the foreground for anything drawn on a `primary` background; `shadow-raised` is the popover elevation and takes its default from the `shadows.lg` scale step rather than restating the rgba stack. Both defaults reference a named scale value, so the palette choice stays in one reviewable file. `primary` moves from `blue500` to `blue600`, and that is a legibility fact rather than a preference. The accessibility gate failed ten of this package's thirty-nine stories on exactly one thing: white body text on the selected segment measured 3.76:1 where AA requires 4.5:1. `blue600` measures 5.25:1. Renaming the literal to `on-primary` would have preserved the violation behind a nicer name. `accent` follows `primary` so the brand blue stays one colour. The thirteen deprecated top-level pseudo-class sites move into condition objects. This is the change the deferral was actually about, because nesting a pseudo-class changes which condition wins, so every site was checked for a competing state on the same property rather than translated mechanically. Eleven of the thirteen set a property nothing else touches and carry no precedence risk at all. The two that do compete — the segmented control's and the list option's background under hover versus selection — are resolved by application order instead: hover lives in the base style, selection is a later `stylex.props` argument, and it restates the hover value because a later unconditional `backgroundColor` does not replace an earlier `backgroundColor` under a `:hover` key. That is the R06 shape, and it means the outcome no longer depends on attribute conditions outranking pseudo-class ones. Focus moves from `:focus` to the accessible-component library's own focus-visible state where the element is one of its components, and to the native `:focus-visible` on the one plain `<input>` that is not. A pointer click no longer paints a keyboard focus ring. Note what this is NOT: the ring is still drawn with `boxShadow` rather than converted to `outline`. The partitioning invariant already holds here — nothing else on these elements sets `outline*`, and the lint rule confirms it — and no story exercises a focused element, so repainting the ring would be a visual change the gate structurally cannot adjudicate. Recorded as follow-up rather than done blind. State resolution stops re-deriving what the component already knows. The checkbox derived its box style from its own `value` prop; selection lives on the CheckboxButton, which is an ancestor of the box, so the box cannot read it as one of its own conditions and React Aria's render prop is the sanctioned mechanism for that case. The segmented control and the list options branched between two mutually exclusive style objects, hover rule included; they now apply one additive override in argument order. The accessibility gate's eleventh failure was structural, not colour. React Aria's `Header` renders `<header>`, and a `<header>` outside sectioning content is a `banner` landmark, so two nested field groups produced two banners and tripped `landmark-no-duplicate-banner` and `landmark-unique`. The label is now a plain element and the group carries the accessible name it was missing. The seventeen per-line `oxlint-disable` suppressions are gone; the package is gated by all eight rules again. * docs(changelog): record the pilot package drift resolution * fix(stylex): pin React to the consuming tree so cross-checkout stories render `@overeng/utils` reaches most gate consumers as a `link:` into a sibling megarepo checkout that carries its own `node_modules`, and Node resolves from a link's real path. So `@storybook/react-dom-shim` — the thing that renders every story — resolved `react`/`react-dom` inside the effect-utils tree while the consuming package's stories and its `react-aria-components` resolved them inside theirs. Two React copies, same version, one optimise pass, different absolute files. The renderer installs the hook dispatcher on its own copy's internals, so any component reaching a hook through the other copy read a null dispatcher and threw, React unwound and retried, the DOM never settled, and the screenshot matcher reported a stability timeout naming none of the above. Measured on one consumer's `Button.stories`: 40 `Invalid hook call` warnings, 20 null-dispatcher errors, 10/10 stories failed in 67.6s. `resolve.dedupe` does not fix this — it picks one copy of one package, but esbuild shares a chunk per module and these are two files on disk. `optimizeDeps.include`/`exclude` move modules between graphs without changing which file `react` resolves to. Only a resolution-level override collapses them. So the gate now injects a `resolve.alias` that pins every React specifier to the CONSUMING package's copy, resolved from Vite's own project root rather than from this module's location — resolving from `import.meta.url` here would pin every consumer to the effect-utils copy, i.e. the wrong side of the duplicate. For a consumer that lives inside the effect-utils tree the two resolutions are the same file, so it degrades to an identity mapping. Every `find` is anchored: a bare `'react'` is a prefix match and would swallow `react-dom` and `react-aria-components`. The five measured entry points map to resolved files; two anchored subpath rules follow them so specifiers nobody enumerated — `react-dom/test-utils` is already live in one prebundle — still land in the consuming tree by construction. * fix(stylex): let the comparator tolerate sub-pixel AA without going blind `threshold: 0` failed on anti-aliasing rather than on regressions. A border at a fractional y renders as ~70% coverage on one row with the remainder bleeding into the next, and the split is not stable across capture sessions. Measured on a captured artifact: identical dimensions, one 1px-tall row, 689 pixels, each off by 1-2/255 against white. The tolerance looked dangerous and is not, which is the part worth recording. The noise is an INTENSITY delta; the smallest regression the gate must catch is a STRUCTURAL one across a whole edge. Swept against the real comparator: threshold AA fringe 1px border shift 0 689 1868 0.005 689 1868 0.0075 0 1868 0.02 0 ~1868 0.05 0 1860 0.1 0 0 0.02 sits ~2.7x above where the noise dies and ~5x below where a 1px shift starts eroding at all. `allowedMismatchedPixels` stays 0 deliberately. Absorbing the fringe with a pixel-count budget instead would have cost the property that a single structurally-different pixel still fails; keeping the count at 0 preserves it. `includeAA: true` is unchanged. * feat(stylex): turn on cascade layers for the one target that can take them Unlayered output was never a preference. It is what lets converted code beat a utility framework's layered utilities without any ordering work, which is the property a migrating target depends on. So `useCSSLayers` is a per-target option that defaults OFF, and this turns it on for `effect-schema-form-aria` only. Nothing outside this repo changes, and the other repos must not take this flip while they still carry Tailwind. The audit, in full, because it did not come back the way "effect-utils is Tailwind-free" suggests. No package in the repo declares a utility framework. Exactly one package uses StyleX, plus the token package itself. Five CSS files exist: three belong to `notion-react`, which does not use StyleX and never shares a document with it here; one is a re-export; and the fifth is `@overeng/stylex-tokens/preflight.css`. That fifth file is the finding. It was unlayered and it sets `box-sizing`, `margin`, `padding` and `border` on `*`. Layered CSS loses to ANY unlayered CSS, so flipping layers on without touching it hands those four properties to the reset on every component in the package — the exact silent regression the migration exists to prevent, arriving through the change meant to clean the cascade up. Measured rather than argued. With the reset left unlayered, the gate fails sixteen stories with DIMENSION mismatches — every component loses its padding and borders and collapses. With the reset declared in `overeng.reset` and named in the compiler's `before` list, the gate is 39 compared, 39/39 passed at baseline, 0 changed: the layered output renders identically to the unlayered output it replaces. The ordering is fixed by declaration, not by luck. Naming the reset in `before` makes the compiler emit `@layer overeng.reset, priority1, ...;` ahead of its rules, so the reset sits below every StyleX priority regardless of which stylesheet the browser parses first — the same rule the token layer already follows, and the reason this is not another bundler-injection-order dependency. * feat(stylex): refuse a green run over a matrix dimension that never varied A Storybook theme toolbar global was keyed on `:root[data-theme=…]` while the decorator set the attribute on its own wrapper div. Both gate theme projects therefore captured the same light palette, compared them, and reported green double-coverage. Measured: light and dark both computed rgb(255,255,255). This is a different failure from the four before it. Those were a pass signal defined as the absence of a failure marker. This one is a matrix dimension that does not vary being indistinguishable from one that does — the gate ran, both projects produced captures, every comparison succeeded, and the theme axis was imaginary the whole time. No care at the call site detects that; only a count does. So the runner now counts, over the baseline captures, how many stories render differently across theme projects, and refuses to pass at zero. Three details, each of which the naive form gets wrong: - The count is taken over the COMPARABLE set only. The baseline tree is now captured twice and the second capture is kept; any story whose two captures of one unchanged tree disagree is excluded and named in `selfInconsistent`. Without that, a nondeterministic story differs across projects for the wrong reason and satisfies the assertion vacuously. Measured shapes: 9 of 70 and 17 of 212 captures on two surfaces, both traced to a single element appearing in one capture and not the next. - A count, not a boolean. A boolean still passes at one differing story, so it misses a real axis that a change silently flattened; the number makes that visible and is free, since both captures already exist. - `themeVaries: false` (`--single-scheme`) is how a target that legitimately ships one colour scheme declares itself. Measured counterexample: a site pinning one scheme has 0 of 57 probes differing, correctly. Failing it would punish a correct target for a property of the target, and the guard would get switched off. The second baseline capture also removes a cold/warm asymmetry the design built in: the baseline was captured in a freshly derived worktree while the compare side ran warm, and the first capture into a cold cache was measured to differ from every later one deterministically, reproducing to the pixel at 689 and 693 pixels on two stories. Keeping the second capture makes both sides warm, so the comparator's tolerance is spent on real host variance instead of on an artefact we introduced. What this does NOT establish, and the comment says so: that the axis varies CORRECTLY. The count is unchanged if light and dark are swapped wholesale. Comparing rendered values against expected ones is a separate check. * fix(stylex): stop the animation tokens compiling to nothing at all `animations.*` were `animation` SHORTHAND strings. Measured against @stylexjs/babel-plugin@0.19.0, compiling stylex.create({ loader: { animation: `${spin} 1s linear infinite` } }) emits the @keyframes rule and then `{ loader: { $$css: true } }` — an empty style object. No `animation` declaration, no `animation-name`, no warning, no error. tsc is happy, the linter is happy, review is happy, and the animation never runs. A pulse in one consuming app had silently stopped and only a computed-style diff caught it. Replaced with four longhand constant groups. The same input compiles to animation-name, animation-duration, animation-timing-function and animation-iteration-count, with the keyframes name resolved — verified by compiling this file through the plugin and reading the emitted constants. The shorthand export is REMOVED rather than deprecated, because removal is the only thing that turns this into a type error: `animations.spin` no longer resolves, so every call site fails tsc instead of silently rendering nothing. There is no way to make a value of type `string` reject a shorthand at the StyleX API boundary. No in-repo callers existed. `bounce` carried no timing function upstream and keeps the CSS initial `ease` rather than acquiring one here. * fix(stylex): wait for a settled DOM instead of two equal frames The gate's readiness signal was "two consecutive frames are identical", which is a PROXY for "the DOM has settled" and fails in both directions: it accidentally passes on a tree that is merely quiet between two async arrivals, and it accidentally works when an unrelated delay happens to straddle a transition. Replaced with a signal that measures the thing the proxy stood for. Measured cause, on an application whose harness waited a fixed 400ms. A maplibre AttributionControl mounts hidden and empty, then populates from source metadata on its `sourcedata` event: the links appeared at +1873ms, +8573ms, and NOT WITHIN 3300ms across three runs of one story. The same mechanism produced a marker div arriving at ~4s elsewhere in the same app. The result was elements present in one capture and absent in the next -- nine stories differing from themselves, a structural difference no comparator tolerance can or should absorb. The predicate polls the captured subtree's shape -- element count plus markup length -- until it repeats three times running, bounded at 20s. Deliberately NOT computed-style or pixel based: it decides *when* to read, so if it depended on *what* is read, the readiness check and the measurement could agree with each other while both were wrong. Rooted at `canvasElement`, which is exactly what `toMatchScreenshot` captures, so the predicate and the capture cannot disagree about scope. Recording the outcome is the load-bearing half, more than the waiting. Every story emits a record: settled with its cost, or never-settled with a reason and the observed shape history. A story that never settles is excluded from the comparison, NAMED, and counted -- an exclusion rather than a disappearance. It returns without asserting, so it resolves no baseline path and enters no manifest, which is what keeps it out of `preExisting` where a baseline-side failure would silently subtract its own compare-side failure. Declared and observed exclusions stay distinguishable. `excluded` is `parameters.storyGate.unstable`, a reviewed decision recorded in source, and passes. `unsettled` is one the harness discovered and nobody reviewed, and fails -- because passing would report green over a story the gate did not compare, which is the defect its other guards exist to prevent. The remedy is visible: fix the story, or declare it and put the decision on the record. Three further things this had to fix to work at all: `--reporter json` ALONE was discarding the channel. It replaces the console reporter, and the JSON report has no field for a browser-side `console.info` -- so the story-gate markers, the only channel carrying "why this story was not compared", never reached the runner. Now both reporters run and the JSON one is named in `--outputFile.json`. Capture liveness is asserted BEFORE any pass/fail number, with a hard "NOTHING SETTLED" when the settled count is zero. The signature that has burned this project repeatedly is zero-work-plus-zero-errors: a harness that never launches a browser emits no failures and reads as perfect. The settled count is emitted positively, per story, by the browser itself, so it cannot be faked by silence. Tree identity is recorded and asserted across each capture set. If source changes land mid-pair, HMR recompiles and BOTH captures span both trees, so they agree with each other while both are wrong -- a self-consistency check is structurally blind to contamination common to both samples. Scoped to tracked content plus untracked files under the package's source and story roots, because hashing every untracked file would refuse runs over a scratch script and a guard that cries wolf gets switched off. The refusal prints what it hashed and which entry moved. Comparator threshold stays at 0.02, untouched. This is additive: sub-pixel intensity noise dies by 0.0075 while a one-pixel border shift still scores ~1860 mismatches at 0.05, and widening the tolerance on the strength of this change would undo a measured result. Also states in the report what each verdict rests on: one invocation captures the baseline tree twice and the compare tree ONCE, so a run cannot speak to the compare side's self-consistency. Separating capture from comparison is the real repair for that and is deliberately left alone here -- one semantic change at a time is what keeps a measurement attributable. The settle-signal-as-primitive idea is SchicklingDevStack's; PtgRest built and measured it (212/212 captures settled, 0 errored, against nine self-inconsistent stories plus capture timeouts before); this commit lifts it into the shared layer. * fix(stylex): actually subtract the exclusion set the gate already computes `selfInconsistent` was computed by the baseline probe, printed in the summary, and then used in exactly one place: the theme-variation count. It was never subtracted from `changed`, which subtracted only `preExisting`. So the gate identified the nondeterministic stories, reported how many there were, and then reported them as regressions anyway. Measured on an IDENTICAL tree with nothing changed: 21 stories reported as `changed`, of which 19 were already named self-inconsistent by the gate's own probe. Every conversion PR on every target therefore opened with a changed-list that was almost entirely noise the machinery already knew how to filter. The reason it was never wired is that the two sides speak different namespaces, and joining them wrongly is worse than not joining them. A capture key ends in the story ID (`.../NumberField.stories.tsx/components-numberfield--with-hint.png`) while a Vitest assertion carries the BARE story name (`With Hint`) -- measured, not assumed. Keying on the name alone would conflate every `Default` in the library, letting one file's nondeterminism silently absorb a real regression to a same-named story in another file. That is a false green, which is the failure class this gate exists to prevent, so the join is scoped by story file and matched on Storybook's own id slug. `parseAssertions` now carries each assertion's file for that purpose. Excluded stories move to their own `nondeterministic` list rather than being dropped, so the report can state how much of the difference was noise rather than quietly shrinking. A story there is not evidence of a regression and not evidence of its absence; it needs its nondeterminism fixed before the gate can say anything about it at all. Kept separate from the settle-signal commit deliberately: two semantic changes measured together are one measurement you cannot attribute. The 21-of-which-19 figure is GeistComponents'; it found the unused exclusion set while measuring the component library's baseline. * fix(stylex): stop the settle signal believing quiet it has not earned The predicate had a hole that reported as a clean result, and it was found by reading the code rather than by measuring it -- which is the wrong way round for the check that decides when every capture is taken. THE HOLE. The floor is three quiet polls at 200ms, so a story settles at ~600ms. But the defect the settle signal was built for is a map attribution control that mounts hidden and EMPTY and populates from network metadata at a measured +1873ms, +8573ms, or not within 3300ms. Between mount and population the DOM is perfectly quiet. So the shape-only predicate was satisfied at its floor and captured the empty state. That is worse than the flake it replaced. Every capture would report as settled AND self-consistent, because a reliably-empty capture is genuinely deterministic: the original nondeterminism disappears by never capturing the content at all, a real change to that content becomes invisible, and any story whose population happened to land inside 600ms would flip-flop exactly as before. `212/212 settled, 0 never-settled` is fully consistent with this -- which is why it looked like success. MEASURED, against the shipped loop, with the +1873ms figure: - network reported idle -> settled at 600ms, shapes ['3:120']. One shape, the empty one. It never saw the content. - resource timeline hot until arrival -> settled at >=1873ms, shapes ['3:120','42:2400']. Both states recorded, capture after arrival. THE FIX. Quiet is accepted only once nothing known-pending remains: no network activity within `workQuietMs` (600ms, matched to the DOM quiet budget so both conditions demand the same evidence) and no incomplete images inside the captured root. `work-never-drained` is a distinct reason from `shape-never-quiet`, because "the DOM kept moving" and "the network never drained" have different fixes and one reason covering both sends whoever reads it to the wrong one. Network activity is read off the RESOURCE TIMELINE rather than by patching `fetch`/`XMLHttpRequest`: the gate runs inside Vitest's own browser client, and monkey-patching the transport it communicates over risks breaking every run in order to fix a subset of them. An in-flight entry reports `responseEnd: 0`, so `startTime` is used as the lower bound -- the conservative reading, which also covers the window before a request completes. Images are included because an image completing changes pixels while leaving element count and markup length untouched, blind in exactly the way the shape predicate is blind to a font swap. NOT COVERED, stated in the module doc rather than left to be rediscovered: a mutation driven by a pure timer with no network or image activity behind it. Nothing short of the full bound catches that, and the bound is the backstop. The predicate moves to `gate/settle.ts` with its clock and platform injected. That is what made the hole answerable at all: a 20-second bound and a +1873ms arrival are now exercised in microseconds against the real loop, instead of inferred backwards from flake counts after a full suite. The hole existed because the loop was only reachable through a browser. `pollIntervalMs` is deliberately unchanged. A shorter interval makes premature settling MORE likely, so cutting the cost while widening the hole would be the wrong trade. Cost of the added gate, measured on effect-schema-form-aria, 39 stories, host load 24.6-30.8 on 32 cores: mean per-story settle 601ms -> 737ms, total settling 23.4s -> 28.8s. 13 of 39 stories now wait beyond the 600ms floor, up to 1022ms -- those are stories where the shape predicate saw nothing move while resources were still landing, so the extra wait is the fix doing its job rather than overhead. Still 39/39 settled, 28/39 passed at baseline, 39 captures: no interference from reading the resource timeline, which was the real risk. The hole was spotted by SchicklingDevStack from the floor arithmetic against the +1873ms measurement. * feat(stylex): land the focus-order detector as a runnable check A validated detector left in a temp directory is a detector nobody runs, and one left in a docs folder is the same thing with better filing. This moves it into the shared layer with an export entry and a runnable entry point, so it can be invoked by hand or wired into CI against a production stylesheet. WHAT IT DETECTS. `@stylexjs/shared@0.19.0` spells `:focusVisible` and `:focusWithin` in camelCase in its priority table but looks them up by literal CSS text, so both fall through to the unknown-pseudo default of 40 while `:hover` sits at 130. Hover therefore outranks focus-visible -- the inverse of Tailwind, which emits focus-visible after hover at equal specificity. A conversion that looks correct silently inverts shipped behaviour, with nothing for a compiler, linter or type checker to see. WHY IT READS THE ARTEFACT. A source-level audit looks for one property object carrying both keys. That misses a pair split across two style objects or across ordered `stylex.props` arguments, which still compete as atomic rules in the cascade. Reading the emitted stylesheet also forces the right granularity: CSS has no style keys, only leaf properties, so it cannot manufacture the false collisions that matching style-key objects produces -- a real audit did exactly that and reported 12 of them. Four properties preserved from the original because each was deliberate, and one of them matters more than the detection: 1. It pairs only rules that TIE on one property. A pair at differing specificity is decided by the cascade and is not exposed, so reporting it would be noise -- and a detector that cries wolf gets switched off, which costs more than the case it was guarding. 2. IT REFUSES TO REPORT ON A ZERO PARSE, with a distinct exit code 2. An agent independently produced exactly the failure this prevents: a scan printing zero hover rules on a page carrying an entire utility framework, because `cssRules` throws on a cross-origin sheet and the loop silently continued. It printed identically to a clean result and was caught only because the number was implausible. "I did not look" and "I looked and it is clean" must not share an exit code. That refusal is worth more than the specificity model, which a reader cannot independently check. 3. It prints both fixes with the reason to prefer them, because a verdict channel that cannot carry its own cause gets bypassed. 4. It refuses to conclude the app is broken. Order-exposed means any element carrying both classes is wrong, not that such an element exists; that is a per-element question only the producers and composition sites can answer, and it says so rather than guessing. `vacuous` is kept distinct from `clean` for the same reason as the zero parse: a build with no native focus rule at all passes for a different reason than one whose ordering was checked and found sound. A test asserting otherwise was wrong and was corrected rather than the code. Rationale note, because the wrong explanation propagates further than the right code: the fixes are NOT "specificity instead of priority". The compiler materialises priority AS specificity by repeating `:not(#\#)`, so those are one axis. What makes both fixes sound is that their correctness is INVARIANT UNDER THE TABLE'S VALUES -- a compound key sums positive parts, and a `default`-condition rule is lowest by construction. Anything depending on two specific numbers being in a particular order is correct today by accident of the defect and flips back on a routine dependency bump. Scope is stated in the module doc: right for compiled atomic output, not for hand-written CSS, and on a dev page pair count is not severity because the whole utility layer and tool chrome get parsed too. Written and validated by SmallAppSurfaces.MiscSurface against two independent production builds, both of which came out order-exposed; PtgSurfaces reproduced it on a third. This commit adapts it, adds the tests, and makes it runnable. * fix(stylex): stop the gate exiting 0 without running when reached by symlink Measured while setting up a megarepo consumer's gate run: the process exited 0, printed nothing, and did no work. One second, no output, no error, success. Cause. A megarepo materialises `repos/effect-utils` as a SYMLINK into the store, so `process.argv[1]` is the symlinked path while `import.meta.url` is the resolved one. The direct-invocation guard compared them literally, so it never matched, the module loaded, and the entry point simply did not run. This is the same silent-failure signature the gate exists to remove, sitting in the gate's own entry point: zero work reported as success. Worse than a crash, because a caller and any CI step reading the exit code cannot tell it from a clean pass. And it affects EVERY megarepo consumer, since they all reach this file through such a symlink -- which is the normal way this gate is invoked, not an edge case. Fixed by also comparing through `realpath`. Verified both directions: a symlinked invocation now prints its usage, where before it produced nothing at all. * fix(stylex): stop a story settling twice from counting as reproducible The self-consistency probe captured the baseline tree twice and treated agreement as stability. That has measured false negatives: `Avatar > All Sizes` passed the pair check and then differed on a third capture of the identical tree, so a pair's "agreed" silently contained two populations. This is not a readiness problem and no better settle predicate reaches it. Readiness asks whether the DOM stopped changing; reproducibility asks whether the render repeats. A surface whose DOM is quiet while its compositor is not — a 3D transform, an image downscale under a rounded clip — satisfies the first and fails the second. Capture three times by default, keep the last (still warm, so the cold/warm reasoning is unchanged), and report the outcome three ways: reproduced across all three, differed on the second, differed only on the third. The third class is the one worth naming because it was invisible before. Sized honestly, at GeistComponents' insistence: this is a better detector, not an eliminator. A story alternating between two frames at ~50/50 agrees across N captures at 2^-(N-1), so three captures still miss it 25% of the time. The report prints that residual next to the count, because a probe trusted as certain while being 75% reliable is worse than one known to be partial. `--skip-third-capture` opts out for a fast local loop. The default is the thorough value and the report always states which was used; a run that silently captured less than it claims would be a fifth way this gate reports success while doing less work than it says. `differedOnThird` is reported as NOT MEASURED rather than as zero under the flag, and a cached baseline taken with fewer captures is refused rather than reused, so one fast run cannot poison later ones. Settle signal and the 0.02 comparator threshold are untouched. Both are measured and neither is what this addresses. Credit: the false negative and its ~50/50 two-frame cause were measured by GeistComponents, which also insisted the residual be stated rather than the change sold on Avatar being fixed. * test(effect-schema-form-aria): add the focused-control stories the ring conversion needs Additive coverage, and a prerequisite rather than a nicety. Before this file no story in the package rendered a focused control, so the focus ring sat outside the visual gate entirely -- every other property of these components is gated, and the ring was the one thing a conversion could silently repaint with a full pass. That is the same blind spot that let a real ring defect ship unnoticed in the design system these components borrow their conventions from: 8 of 11 ring-carrying files inheriting a white offset default, painting a solid halo on a near-black backdrop, invisible for years because nothing rendered a focused control. Three properties make these captures usable as baselines: - The gate's setup freezes transitions and sets caret-color transparent, so a focused text input has no blinking caret. Without the caret rule a focused-input story could not have a stable baseline at all. - Focus is taken with userEvent.tab() rather than element.focus(). React Aria decides data-focus-visible from its own input-modality tracking, which flips to keyboard only on a key event, so a bare .focus() focuses the element and paints no ring. - Each play asserts the focus state actually landed. A story that captured an unfocused element would still produce a stable baseline and a green gate, so the assertion is what makes the capture evidence rather than a picture. Authored by a delegated agent that parked before committing; recovered and verified here. VERIFIED: in-package tsc --noEmit clean. NOT YET VERIFIED: story execution -- run reported separately. * fix(story-gate): thread consumer plugins into every project, not the root Vitest projects do not inherit root-level plugins -- each project is its own Vite config. createStoryGateConfig returns a bare project config for one theme and {test:{projects}} for several, so the documented consumer pattern of merging plugins into the returned config works by accident in the single-theme case, where the merge target IS the project, and silently stops applying the moment a second theme is added: the plugins land beside 'test' where nothing reads them and every story fails to load from untransformed source. This is why the ptg story gate has never executed a story on any pin, a fact carried across eight PRs with no cause. Measured on one host minutes apart: the one-theme consumer ran 4 stories and captured 4 distinct references; the two-theme consumer reported 'Tests no tests'. Threading the same plugins per-project moved it to 10 stories executed. Adds a 'plugins' option applied to every project, ordered before the Storybook plugin because a compiler transform must see the source first. The Storybook plugin construction becomes an injected seam, because storybookTest eagerly loads a real Storybook config directory and the invariant worth guarding is WHERE plugins land, which is independent of what they are. The test is proven live rather than merely passing: reverting the placement to the pre-fix form fails 3 of its 4 cases. It asserts per project and also that the root does NOT carry them, so a refactor cannot satisfy it by duplicating plugins everywhere. It imports project.ts relatively, so it is immune to workspace link direction. VERIFIED: @overeng/utils tsc --noEmit clean; the 4 unit cases pass and 3 fail on the pre-fix placement. NOT VERIFIED: the effect-schema-form-aria config migration. In this worktree that package resolves @overeng/utils to a sibling checkout rather than to this source, so its green says nothing about this change -- confirmed by a negative control, where a deliberately bogus option produced no type error either. The change is a four-line mechanical migration and CI installs properly; flagging it rather than presenting an uninformative pass. * refactor(effect-schema-form-aria): move the focus ring from boxShadow to outline Reserves 'outline' for the focus ring at all four ring sites, so a ring and a decorative shadow can coexist: StyleX writes one value per property, and a ring plus a shadow are two writers of boxShadow with no compile error when one silently wins. LiteralField already has a real elevation shadow on boxShadow for its popover -- a different element today, but a trigger is exactly the control that later grows one. VISIBLE DELTA, MEASURED -- this conversion is NOT pixel-equivalent, and the FocusRing stories added in #1183 are what caught it on their first use. Captured before and after on one host, with a self-comparison control at zero and identical canvas dimensions in every case, so this is paint and not layout: text / number / literal-trigger 3856/73920 px (5.216%) maxChannelDelta 83 optional-number (compact input) 351/61440 px (0.571%) maxChannelDelta 91 The bounding box covers the whole ring perimeter rather than the corners, so the band moves -- this is not anti-aliasing noise. 'box-shadow: 0 0 0 1px' and 'outline: 1px solid' are therefore not interchangeable, which contradicts the comment I first wrote here and which is now corrected in place at the site. THIS NEEDS A HUMAN DECISION and the PR says so: accepting the convention means accepting a small visible change to the focus ring on every field in the package. I have not tried to tune it back to byte-identical, because matching a box-shadow's exact band with outline-offset would trade a named visible delta for an unexplained coincidence. One site is called out in place: NumberField's compact input is the only ring keyed on a pseudo-class rather than an attribute, which makes it the site exposed to the upstream @stylexjs/shared priority-table defect where ':focus-visible' falls through to 40 against ':hover' at 130. Nothing writes ':hover' on outline there, so the two cannot meet -- and keeping the ring on its own property is what guarantees that rather than luck. VERIFIED: cold composite build ('tsc --build' in a fresh worktree with no dist) exits 0; zero boxShadow focus sites remain; both remaining boxShadow uses are real elevation shadows. * build(nix): refresh seven pnpm closure hashes for the merged tree The merge of main brought a third dependency state, so neither side's hash was correct: measured, genie's merged closure hashes to vtu2jnhz... where this branch carried BdWDmLT9... and main carried H5i7Vtl.... Refreshed as one batch through evergreen rather than one hash per cycle. Two independent corroborations rather than one: - genie's written value exactly matches what a plain 'nix build' printed as 'got:' before the refresh ran, so the tool measured rather than merely wrote. - All seven build to real store paths. That is a POSITIVE assertion, not an absent error: evergreen skipped its own post-write verification on npm-release and tui-stories because its verifier imports build.nix with only 'pkgs' while these files require 'src', so a silent pass there would have been indistinguishable from an unverified write. * build: regenerate the merged lockfile and refresh eight contracted FOD hashes Follows the procedure main's own 663e07b0e documents for this exact situation -- 'regenerate pnpm-lock.yaml from the merged package.json specs using the pinned pnpm 11.8.0 (the auto-merged lockfile kept a broken reference)' followed by refreshing the contracted pnpm-deps hashes against the regenerated lockfile. The advice pnpm prints is NOT sufficient here and that is worth recording. It says 'run pnpm install --no-frozen-lockfile'; both that and --force no-op in about a second with zero drift, because pnpm's up-to-date check finds the lock consistent for the full 39-project workspace. The inconsistency exists only in the PRUNED workspace the Nix deps build projects, where a dropped devDependency changes vitest's peer set and the lock has no entry for that identity. Removing the lockfile and re-resolving ran a genuine pass -- 28.4s, 667 packages -- and the ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY failure became a plain hash mismatch, which is the expected next layer rather than the same error again. Mechanism, measured rather than inferred: @vitest/browser-playwright is declared in @overeng/utils and is NOT on main, and the eight packages carrying contracted hashes are exactly those depending on utils. Main's commit introduced two-stage prune-then-install with a canonicalized pruned-lockfile key, so main's new projection and this stack's new dependency collide -- neither alone is at fault. VERIFIED: all eight packages build to real store paths, asserted positively rather than read as an absent error. * chore(genie): regenerate the projections that could be generated Four files regenerated cleanly, including packages/@overeng/stylex-tokens/BUCK, which validates the stylexPreset -> stylexTokens admission rename: the projection for the renamed package generates rather than merely typechecking. One file remains blocked: buck2/dependencies/BUCK fails with 'importer bin playwright is ambiguous between @playwright+test@1.61.0 cli.js and playwright@1.61.0 cli.js'. Measured, not assumed: the ambiguity is NOT introduced by the lockfile regeneration -- both locks contain both packages, before and after. The root package.json declares neither. The colliding importer is @overeng/utils, which carries @playwright/test through its peerDepNames spread AND bare playwright for the story gate, and both packages ship a 'playwright' bin. aria declares only the bare one and is clean. Both are genuinely used: @vitest/browser-playwright peer-depends on bare playwright, while utils' own ./node/playwright/config export uses @playwright/test. So this is not accidental duplication and dropping either one removes something real -- which is why it needs a decision rather than a patch. This is the third instance of one pattern: main's 663e07b0e added new invariants (a pruned-lockfile projection, then unambiguous importer bins) and this stack added dependencies that violate them. Neither side alone is at fault, and each violation surfaced only at a different layer. * fix(buck2): give the importer root .bin a pnpm-compatible winner instead of failing The projection rejected two direct dependencies declaring the same bin name, so it could not reproduce a tree pnpm materialises without complaint. Parity with the pnpm tree i…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the oxlint wrapper so third-party JS plugins can load at all, then uses that to ship StyleX enforcement as two complementary layers. Second half depends on the first: without the wrapper fix a config naming both
overeng/*and@stylexjs/*rules fails to resolve, so neither could be tested.Implements the StyleX spec's "Enforcement" section and decision 0005 Amendment 2.
Base and merge order — read before merging
Rebased onto
schickling-assistant/2026-09-01-stylex-catalog-pins(PR #1173, tip6003e7dd5), which supplies the@stylexjs/eslint-plugincatalog pin this needs. Merge that first.The
nix/oxc-config-plugin.nixpnpmDepsHashis contested by construction, not by accident. Its inputs include the rootpnpm-lock.yaml, so it moves for any branch that touches dependencies — and this PR adds a devDependency to the very package it covers. #1170 moves it too, for its own reasons. The value here is correct for this tree; it is not correct for the merged tree, and neither is #1170's.So when the second of those two merges: re-measure from your own
nix buildgot:line. Do not resolve the conflict by keeping a side, and do not carry a hash across from #1170 or #1173. A carried-forward hash looks plausible, passes review, and fails after merge, which is the worst time to find out.evergreen fod refreshcannot address this attr — it is hand-written, not a CLI-shaped one (tooling gap filed separately); just write what nix reports.The lockfile move also invalidated two CLI-shaped FODs (
ci-tools,notion-cli), refreshed in the last commit viaevergreen fod refresh --name <pkg> --linux-system x86_64-linux. Note evergreen writes the correct value even though its post-write verification step fails to evaluate these attrs — the warning is not a failure.devenv tasks run check:quickis green on this tree.1. Three wrapper defects (
nix/oxlint-with-plugins.nix)Plugin list replaced instead of substituted — this was the blocker
The wrapper wrote
.jsPlugins = [ourPath], discarding every other entry, so a config naming a third-party plugin failed withPlugin 'x' not found. Two consumers already carry local workarounds with comments describing exactly this. Now only our entry is substituted.Reproduced in situ, with this PR's real generated
.oxlintrc.jsonand the pre-fix wrapper — this is what the blocker actually looked like:The match is our entry point (
oxc-config/src/mod.ts), not the package directory, because this PR adds a second plugin entry from that same directory — matching the directory would swap a third-party plugin for ours one level down, reintroducing the same silent failure.--configfirst exited 1 with no outputThe argument-rewrite loop used
((i++)). Post-increment evaluates to the old value, so it returns status 1 wheniis 0, andwriteShellApplication'sset -o errexitaborted the wrapper silently. In CI that is indistinguishable from a lint failure.A newly added rule was unreachable
The injected plugin is a Nix build-time snapshot, so editing
packages/@overeng/oxc-config/src/*.tshad no effect and adding a rule reportednot found in plugin 'overeng'.OVERENG_OXC_CONFIG_PLUGINnow overrides the path.Regression test
nix/devenv-modules/tasks/shared/tests/oxlint-plugin-injection.test.sh, 13 assertions, picked up automatically bydevenv tasks run devenv-modules:test. It drives the real built wrapper against the real oxlint, because all three defects were failures of plugin resolution — the one thing a stub cannot model. It prefers the already-realised wrapper on PATH, so inside the devenv shell it costs nothing and only falls back tonix buildoutside it.Confirmed to fail against the old wrapper (
Plugin 'probe' not found) and against an intermediate that matched the package directory rather than the entry point (Plugin 'sibling' not found) — it is a regression test, not a tautology.The two consumer workarounds this unblocks (
devenv.nixin dotfiles,flakes/vista/nix/r59-oxc-plugin.js) are outside this repo; reporting them rather than touching them.2. StyleX enforcement, both layers
Why both
Upstream per-property value limits are strictly better than anything we would hand-roll for pure colour properties — they distinguish a literal from a token reference. But a limit is keyed on one property and structurally cannot see a colour embedded in a composite value. Measured on this repo's own code:
Banning composite properties outright is not viable — they need literal offsets. The overlap on plain colour properties is kept deliberately: an array
limitdrops its customreasontext, so only the first-party message names the remedy.Upstream loads through a namespace shim, NOT the bare specifier
packages/@overeng/oxc-config/src/stylex-upstream-plugin.tsre-exports the upstream rules withmeta: { name: '@stylexjs' }.Please do not "simplify" this to
jsPlugins: ['@stylexjs/eslint-plugin']. It cannot work here, and the reason is structural rather than incidental. Upstream ships nometa.name, so oxlint derives the namespace from the specifier — which means the specifier must be the bare package name. A bare specifier resolves only from the rootnode_modules, and this repo's rootpackage.jsonis a pure genie workspace aggregate ({name, private, packageManager, workspaces}) that cannot carry a dependency at all. Earlier prototyping suggested the bare specifier was required because it ran in an isolated temp project where a rootnode_moduleshappened to exist.The shim keeps the dependency on the package that owns lint config, references a path inside the workspace exactly like
./mod.tsdoes, and still yields rule names that read exactly as upstream documents them.Rules enabled
valid-styles(withpropLimits),valid-shorthands,no-unused,no-legacy-contextual-styles,no-lookahead-selectors,enforce-extension.sort-keysis deliberately skipped — it fights theproperty-specificityresolution the design relies on. Policy lives instylexOxlintRulesingenie/oxlint-base.ts, repo-wide rather than scoped, so a target is gated the moment it starts using StyleX; the rules are inert in files that never call the API.no-lookahead-selectorsis in the set because the spec names it;no-legacy-contextual-stylesbecause the brief named it. Shipping the union — both are cheap and neither is a "skip" in either document. It is a good thing:no-legacy-contextual-stylesis what found the 13 pilot sites.Colour properties are enumerated, not globbed
Measured:
*olor*also matchescolorScheme,colorAdjust,forcedColorAdjust,printColorAdjustandcolorInterpolation, all keyword-valued, all of which would be wrongly banned.fill,stroke,floodColor,stopColorandscrollbarColorare also excluded despite taking colours, because each accepts a value an allowlist would reject (none, aurl(#fragment)paint reference, a pair). The first-party rule still covers colour literals in those.overeng/stylex-no-raw-colorBans colour literals as values inside
stylex.create. The pattern is deliberately not anchored, which is what reaches inside composites. The token layer is untouched by construction: onlycreateis visited, neverdefineConsts/defineVars/createTheme.One refinement beyond the brief, found by running against real code.
color-mix()and relative-colour syntax (oklch(from <colour> ...)) are derivations, not constructors — a derivation over a token still follows the colour scheme, so flagging it contradicts the rule's own purpose. Treating them as constructors produced 4 false positives onFieldGroup.tsx'scolor-mix(in oklab, ${tokens.border} 50%, transparent). They are not a loophole: a raw argument inside them is still caught, because the patterns are not anchored.Silent on: the token layer, non-colour literals,
colorScheme/forcedColorAdjustkeywords, SVG fragment refs (url(#abc)) that look like short hex, a quoted hash incontent, hex runs longer than 8, colour-shaped strings outsidecreate,db.create({ color: '#f00' }), and colours "forged" across an interpolation boundary. Catches hex 3/4/6/8, seven functional notations, pseudo-class and media-query nesting, pseudo-element blocks, gradients, shadow and border shorthands, template literals, both arms of a conditional in a dynamic style, fallback arrays, andas constaround the style map.overeng/stylex-outline-focus-visible-onlyReserves
outline,outlineOffsetandoutlineColorfor the focus-visible state. This is the lintable half of the focus-ring invariant: StyleX assigns priority per condition kind, not by authoring position, so a selected-state shadow beat a focus-visible shadow and an element that was both selected and keyboard-focused silently lost its focus ring. Partitioning the properties makes the collision impossible rather than managed.Allows the unconditional base value (so
outline: 'none'can suppress the UA ring),default, at-rule conditions (a forced-colours override is legitimate), and pseudo-elements. Any key containingfocus-visiblecounts, covering both:focus-visibleand the accessible-component library's[data-focus-visible]. Plain:focusdoes not — that is the mistake it exists to catch. Fires on either nesting order, once per offending pair, and does not treat dynamic styles as an escape hatch.anyvs TSESTree — a deliberate divergenceThe sixteen existing rules annotate the untyped plugin API
anywith a NOTE comment. These two useTSESTreefrom@typescript-eslint/utilsinstead. The no-anyrule is binding and postdates that convention, the dependency is already there, and the type-only import is erased before bundling — so the cost is zero. The existing sixteen are left alone; churning them is not this PR's job.Verification
All of the following was re-run after the rebase, on the real generated
.oxlintrc.jsonand the real built wrapper, not a hand-written probe config.Violation fixture — one run, both namespaces resolving:
Note what upstream did not say: nothing about the colour inside
boxShadow, and nothing about the colour inside the gradient. Only the first-party rule reaches those. That is experiment 0004's central measurement, reproduced here in situ rather than in a scratch project.Compliant fixture — both StyleX layers silent, including on all the shapes that must not fire (
color: colors.textfrom a*.stylex.tsmodule,backgroundColor: 'transparent', acolor-mix()derivation over a token,boxShadowrestyled by[data-selected],outlineunder:focus-visible, andfill: 'url(#brandGradient)'):Both remaining diagnostics are an unrelated pre-existing house rule reacting to a throwaway fixture with no JSDoc. Zero StyleX diagnostics.
Regression test is still non-tautological after the rebase — checked explicitly, because a rebase across a lockfile change is exactly when that can quietly stop being true:
Also:
RuleTesterunder both the ESLint and TypeScript parsers. The count matches the enumerated cases exactly, so nothing is silently skipped.import/no-dynamic-requireingenie/ci-scripts/bundle-smoke.ts) is pre-existing.tsc --noEmitclean for@overeng/oxc-config, with all six new files confirmed in the program via--listFiles.devenv tasks run check:quickgreen.3. The pilot package fails the enforcement derived from it
Enabling the rules produced 33 diagnostics over 17 sites, all in
packages/@overeng/effect-schema-form-aria— the reference implementation this whole pattern was derived from (#1152).That is not embarrassing; it is the enforcement working. The rules found real drift in the one place everyone would have assumed was clean, on their first run, without anyone having to look. It is also the second recorded divergence for that package, not the first —
DELTA-001is open for its use of lifted React state where own conditions are now preferred. Both need the same visual gate, so #1171 says resolve them together.#ffffffx3 incolor,rgb(0 0 0 / 0.1)twice inside oneboxShadow.Not fixed here. Nesting a pseudo-class changes which condition wins on that property, and that package has no visual gate — changing it now would be exactly the unverified visual change our requirements forbid. The colours need semantic tokens that do not exist yet, and the tempting fix (
colors.white, already in the shared scales) would silence the rule while permanently foreclosing scheme variation, because a constant cannot vary by scheme.Each of the 17 sites carries a per-line
oxlint-disable-next-linewith a reason, not a file-scoped override. A file-scopedoffwould disable the rules for future code in that package too — bad anywhere, worse in the package people read as the example. Per-line keeps new code gated, makes the debt visible where it is, and makes the cleanup greppable:grep -rn 'See #1171'.Net effect, stated plainly: the machinery is real and green, but in this repo it currently gates only compliant code. That matches the roadmap's own "Enforcement reach" entry, which expects the third target to be the first lint-covered gate.
Follow-ups filed
DELTA-001.Posted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile