Skip to content

Fix the oxlint plugin seam, then enforce the StyleX token constraint in two layers - #1172

Closed
schickling-assistant wants to merge 3 commits into
schickling-assistant/2026-09-01-stylex-shared-layerfrom
schickling-assistant/2026-09-01-lint-toolchain
Closed

schickling-assistant wants to merge 3 commits into
schickling-assistant/2026-09-01-stylex-shared-layerfrom
schickling-assistant/2026-09-01-lint-toolchain

Conversation

@schickling-assistant

@schickling-assistant schickling-assistant commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

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, tip 6003e7dd5), which supplies the @stylexjs/eslint-plugin catalog pin this needs. Merge that first.

The nix/oxc-config-plugin.nix pnpmDepsHash is contested by construction, not by accident. Its inputs include the root pnpm-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 build got: 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 refresh cannot 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 via evergreen 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:quick is 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 with Plugin 'x' not found. Two consumers already carry local workarounds with comments describing exactly this. Now only our entry is substituted.

before  $ oxlint fixture.ts --config .oxlintrc.json
        × Plugin 'probe' not found          (exit 1)

after   $ oxlint fixture.ts --config .oxlintrc.json
        × probe(always-report): probe plugin fired
        × overeng(no-raw-nondeterminism): ...   (exit 1, both namespaces)

Reproduced in situ, with this PR's real generated .oxlintrc.json and the pre-fix wrapper — this is what the blocker actually looked like:

$ oxlint packages/@overeng/oxc-config/lt-violation.tsx     # pre-fix wrapper
× Plugin '@stylexjs' not found

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.

--config first exited 1 with no output

The argument-rewrite loop used ((i++)). Post-increment evaluates to the old value, so it returns status 1 when i is 0, and writeShellApplication's set -o errexit aborted the wrapper silently. In CI that is indistinguishable from a lint failure.

before  $ oxlint --config .oxlintrc.json fixture.ts
        (no output)                          exit 1
        $ oxlint fixture.ts --config .oxlintrc.json
        × probe(always-report): ...           exit 1

after   both orders produce identical output and exit code

A newly added rule was unreachable

The injected plugin is a Nix build-time snapshot, so editing packages/@overeng/oxc-config/src/*.ts had no effect and adding a rule reported not found in plugin 'overeng'. OVERENG_OXC_CONFIG_PLUGIN now overrides the path.

before  $ oxlint --config .oxlintrc.json f.ts
        × Rule 'stylex-no-raw-color' not found in plugin 'overeng'

after   $ OVERENG_OXC_CONFIG_PLUGIN=./packages/@overeng/oxc-config/src/mod.ts oxlint ...
        × overeng(stylex-no-raw-color): Raw colour `#00000014` in `boxShadow`. ...

Regression test

nix/devenv-modules/tasks/shared/tests/oxlint-plugin-injection.test.sh, 13 assertions, picked up automatically by devenv 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 to nix build outside 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.nix in 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:

× overeng(stylex-no-raw-color): Raw colour `rgb(0 0 0 / 0.1)` in `boxShadow`.
  LiteralField.tsx:134  boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), ...'
                        ^ upstream valid-styles reports nothing here

Banning composite properties outright is not viable — they need literal offsets. The overlap on plain colour properties is kept deliberately: an array limit drops its custom reason text, 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.ts re-exports the upstream rules with meta: { 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 no meta.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 root node_modules, and this repo's root package.json is 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 root node_modules happened to exist.

The shim keeps the dependency on the package that owns lint config, references a path inside the workspace exactly like ./mod.ts does, and still yields rule names that read exactly as upstream documents them.

Rules enabled

valid-styles (with propLimits), valid-shorthands, no-unused, no-legacy-contextual-styles, no-lookahead-selectors, enforce-extension. sort-keys is deliberately skipped — it fights the property-specificity resolution the design relies on. Policy lives in stylexOxlintRules in genie/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-selectors is in the set because the spec names it; no-legacy-contextual-styles because 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-styles is what found the 13 pilot sites.

Colour properties are enumerated, not globbed

Measured: *olor* also matches colorScheme, colorAdjust, forcedColorAdjust, printColorAdjust and colorInterpolation, all keyword-valued, all of which would be wrongly banned. fill, stroke, floodColor, stopColor and scrollbarColor are also excluded despite taking colours, because each accepts a value an allowlist would reject (none, a url(#fragment) paint reference, a pair). The first-party rule still covers colour literals in those.

overeng/stylex-no-raw-color

Bans 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: only create is visited, never defineConsts/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 on FieldGroup.tsx's color-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/forcedColorAdjust keywords, SVG fragment refs (url(#abc)) that look like short hex, a quoted hash in content, hex runs longer than 8, colour-shaped strings outside create, 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, and as const around the style map.

overeng/stylex-outline-focus-visible-only

Reserves outline, outlineOffset and outlineColor for 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 containing focus-visible counts, covering both :focus-visible and the accessible-component library's [data-focus-visible]. Plain :focus does 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.

any vs TSESTree — a deliberate divergence

The sixteen existing rules annotate the untyped plugin API any with a NOTE comment. These two use TSESTree from @typescript-eslint/utils instead. The no-any rule 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.json and the real built wrapper, not a hand-written probe config.

Violation fixture — one run, both namespaces resolving:

$ oxlint packages/@overeng/oxc-config/lt-violation.tsx
× overeng(stylex-no-raw-color):  Raw colour `#ff0000` in `color`. ...
× overeng(stylex-no-raw-color):  Raw colour `#00000014` in `boxShadow`. ...
× overeng(stylex-no-raw-color):  Raw colour `#fff` in `backgroundImage`. ...
× overeng(stylex-outline-focus-visible-only): `outline` is reserved for the
    focus-visible state but is set under `[data-selected]`. ...
× @stylexjs(valid-styles): color value must be one of: ...
Found 0 warnings and 6 errors.

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.text from a *.stylex.ts module, backgroundColor: 'transparent', a color-mix() derivation over a token, boxShadow restyled by [data-selected], outline under :focus-visible, and fill: 'url(#brandGradient)'):

$ oxlint packages/@overeng/oxc-config/lt-compliant.tsx lt-tokens.stylex.ts
× overeng(jsdoc-require-exports): Missing JSDoc comment for exported 'const colors'.
× overeng(jsdoc-require-exports): Missing JSDoc comment for exported 'const styles'.
Found 0 warnings and 2 errors.

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:

$ OXLINT_WRAPPER_BIN=<post-rebase build>  bash …/oxlint-plugin-injection.test.sh
All oxlint plugin injection tests passed

$ OXLINT_WRAPPER_BIN=<pre-fix wrapper>    bash …/oxlint-plugin-injection.test.sh
FAIL: third-party plugin is not clobbered
  expected output NOT to contain: Plugin 'probe' not found

Also:

  • 56 unit tests across the two rules, RuleTester under both the ESLint and TypeScript parsers. The count matches the enumerated cases exactly, so nothing is silently skipped.
  • 13 assertions in the wrapper regression test.
  • Repo-wide run with the generated config: 0 errors over 1537 files, 172 rules. The one warning (import/no-dynamic-require in genie/ci-scripts/bundle-smoke.ts) is pre-existing.
  • tsc --noEmit clean for @overeng/oxc-config, with all six new files confirmed in the program via --listFiles.
  • devenv tasks run check:quick green.

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-001 is 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.

  • 13 sites use the deprecated top-level pseudo-class syntax.
  • 4 raw colours: #ffffff x3 in color, rgb(0 0 0 / 0.1) twice inside one boxShadow.

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-line with a reason, not a file-scoped override. A file-scoped off would 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

Posted on behalf of @schickling
field value
agent_identity dev3.direct.omp.6xjdfab7
session dev3.6xjdfab7
agent_persona generalist
agent_supervisor unavailable
agent_tool OMP
agent_tool_version 18.0.11
agent_runtime OMP 18.0.11
tooling_profile dotfiles@5a8d579-dirty

@schickling-assistant schickling-assistant added type:feature New user-visible or system capability · Set: manual area:nix Nix flakes, derivations, FOD hashes, builders · Set: manual area:tooling Developer tooling, scripts, and utilities · Set: manual labels Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Storybook Previews

Subject Status Report Details Updated
notion-md success notion-md preview deployed Preview is ready 2026-09-01 22:00 UTC
genie success genie preview deployed Preview is ready 2026-09-01 22:00 UTC
effect-react success effect-react preview deployed Preview is ready 2026-09-01 22:00 UTC
notion-cli success notion-cli preview deployed Preview is ready 2026-09-01 22:00 UTC
react-inspector success react-inspector preview deployed Preview is ready 2026-09-01 22:00 UTC
effect-schema-form-aria success effect-schema-form-aria preview deployed Preview is ready 2026-09-01 22:00 UTC
tui-react success tui-react preview deployed Preview is ready 2026-09-01 22:00 UTC
notion-react success notion-react preview deployed Preview is ready 2026-09-01 22:00 UTC
megarepo success megarepo preview deployed Preview is ready 2026-09-01 22:00 UTC
Report history

PR 1172 · 2026-09-01 22:01 UTC

Subject Status Report Details Updated
notion-md success notion-md preview deployed Preview is ready 2026-09-01 22:00 UTC
genie success genie preview deployed Preview is ready 2026-09-01 22:00 UTC
effect-react success effect-react preview deployed Preview is ready 2026-09-01 22:00 UTC
notion-cli success notion-cli preview deployed Preview is ready 2026-09-01 22:00 UTC
react-inspector success react-inspector preview deployed Preview is ready 2026-09-01 22:00 UTC
effect-schema-form-aria success effect-schema-form-aria preview deployed Preview is ready 2026-09-01 22:00 UTC
tui-react success tui-react preview deployed Preview is ready 2026-09-01 22:00 UTC
notion-react success notion-react preview deployed Preview is ready 2026-09-01 22:00 UTC
megarepo success megarepo preview deployed Preview is ready 2026-09-01 22:00 UTC

PR 1172 · 2026-09-01 18:43 UTC

Subject Status Report Details Updated
notion-md success notion-md preview deployed Preview is ready 2026-09-01 18:42 UTC
react-inspector success react-inspector preview deployed Preview is ready 2026-09-01 18:42 UTC
notion-cli success notion-cli preview deployed Preview is ready 2026-09-01 18:42 UTC
effect-react success effect-react preview deployed Preview is ready 2026-09-01 18:42 UTC
effect-schema-form-aria success effect-schema-form-aria preview deployed Preview is ready 2026-09-01 18:42 UTC
tui-react success tui-react preview deployed Preview is ready 2026-09-01 18:42 UTC
genie success genie preview deployed Preview is ready 2026-09-01 18:42 UTC
notion-react success notion-react preview deployed Preview is ready 2026-09-01 18:42 UTC
megarepo success megarepo preview deployed Preview is ready 2026-09-01 18:42 UTC

PR 1172 · 2026-09-01 17:01 UTC

Subject Status Report Details Updated
genie success genie preview deployed Preview is ready 2026-09-01 17:01 UTC
notion-md success notion-md preview deployed Preview is ready 2026-09-01 17:01 UTC
react-inspector success react-inspector preview deployed Preview is ready 2026-09-01 17:01 UTC
effect-react success effect-react preview deployed Preview is ready 2026-09-01 17:01 UTC
effect-schema-form-aria success effect-schema-form-aria preview deployed Preview is ready 2026-09-01 17:01 UTC
tui-react success tui-react preview deployed Preview is ready 2026-09-01 17:01 UTC
notion-react success notion-react preview deployed Preview is ready 2026-09-01 17:01 UTC
notion-cli success notion-cli preview deployed Preview is ready 2026-09-01 17:01 UTC
megarepo success megarepo preview deployed Preview is ready 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
schickling-assistant force-pushed the schickling-assistant/2026-09-01-lint-toolchain branch from 36143c8 to 7cecf10 Compare September 1, 2026 18:38
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
schickling-assistant force-pushed the schickling-assistant/2026-09-01-lint-toolchain branch from 7cecf10 to 9803768 Compare September 1, 2026 21:55
@schickling-assistant
schickling-assistant changed the base branch from main to schickling-assistant/2026-09-01-stylex-shared-layer September 1, 2026 21:56
@schickling-assistant

Copy link
Copy Markdown
Collaborator Author

Superseded by #1191, which collapses this stack onto main as one tree.

Closing rather than merging: propagating main through eight stacked branches meant re-reconciling the lockfile, the fixed-output hashes and the Buck projection once per branch. Branch 2 alone produced 11 conflicts, a semantically broken auto-merged lockfile, a BUCK admission rename and a circular tooling block. On one tree each of those happens once.

This PR's content is in #1191, verified with git merge-base --is-ancestor over all eight heads — #1184 was found missing that way and merged in, because a gh stack base does not make a branch contain that base.

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
field value
agent_identity dev3.direct.omp.mhx73ur2
session dev3.mhx73ur2
agent_persona generalist
agent_supervisor unavailable
agent_tool OMP
agent_tool_version 18.0.11
agent_runtime OMP 18.0.11
tooling_profile dotfiles@e8f0615-dirty

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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:nix Nix flakes, derivations, FOD hashes, builders · Set: manual area:tooling Developer tooling, scripts, and utilities · Set: manual type:feature New user-visible or system capability · Set: manual

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant