Skip to content

SELD-R2 PR 1: :hover, :active, :focus and :focus-visible compiled as per-assignment interaction states (R3, R4, R5, R7, R13) - #136

Merged
thejackshelton merged 55 commits into
masterfrom
seld-r2-c
Oct 5, 2026
Merged

thejackshelton merged 55 commits into
masterfrom
seld-r2-c

Conversation

@thejackshelton

@thejackshelton thejackshelton commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

SELD-R2 PR 1, the compiler part (T064, binding spec notes/T064-seld-r2-spec.md). Built on origin/master, independent of FORM-a; no stack.

What changed

  • Selectors (css/selectors.ts, analysis/match.ts). :hover, :active, :focus and :focus-visible parse in any compound and position, including inside :is(), :where(), :not(), :has() and :nth-*(… of S). They match against an InteractionState (none by default, so every existing match is unchanged). :focus-within, :visited, :checked and the rest stay refused.
  • R4, R5 and R7 (analysis/interaction.ts). For each case, the candidates are found to a fixed point. From them come four dimensions: hover chains, active chains, and focus values (pointer focus without focus-visible, keyboard focus with it). Every combination is resolved in full, and each distinct resolution is stored once: a :focus that changes nothing is the none state (R5). The states are a second level under the case's app assignment and never count against the app state table. More than 256 distinct reachable states in one assignment, or more than 4096 combinations, are refused with DRAGON_UNSUPPORTED_SELECTOR naming SELD-R2s. seld-r2a's combinedStateRefusal and its MAX_STATE_ASSIGNMENTS count are deleted, because combined states now compile. Each state records what Chrome's CSS.forcePseudoState forces to reach it (a real pointer's whole chains, plus focus), and the parity forced cases use that.
  • R3, the web gate (emit/web-css.ts). One condition is emitted per combination a state stands for. Every condition that needs a :hover candidate goes inside @media (hover: hover), Tailwind's shape. A condition whose :hover candidates are all off is also emitted without them under not all and (hover: hover). So a tap on a phone never leaves a hover style, and desktop is unchanged.
  • R13, the hit model (project.ts, HIT_MODELLED). This is fail-closed. In the parity lanes' native compile, every interaction rule is refused in a case that has a pointer-reachable state and a compiled paint fact the hit test does not model. The refusal is [ios]/[android] and names SELD-R2b. It runs after the value checks, so a value the profile already refuses does not count, and an overflow value computed from a refused partner (overflow-y: auto beside a refused overflow-x: hidden, computeOverflowPair) does not count either. Outside the lanes it does not run, because every interaction rule is already refused on native (below). (An earlier version of this body said R13 was dormant in enforce mode. That was wrong: it added 10 music-player errors through the computed overflow-y on html, naming SELD-R2b, which would not have cleared them. Both causes are fixed, see the review round below.)
  • Native refusal until the runtime (nativeInteractionRefusals). iOS and Android have no interaction runtime until PR 3 and PR 4, and no profile row. So every rule that tests :hover, :active, :focus or :focus-visible is refused on each configured native target with DRAGON_UNSUPPORTED_SELECTOR [ios|android] :<pseudo> is not supported on <target> yet: the native interaction runtime arrives with SELD-R2 PR 3 and PR 4. Web compiles them. Only the parity lanes' compiles (compileFixture, nativeCompile) set the internal option interactionLanes, which keeps the states on native so the forced cases and hit vectors prove each state's resolution ahead of the runtime.
  • No native profile row from lane-only cases. A case the lanes compile on native only through interactionLanes (a user's compile refuses its interaction rules there) proves no iOS or Android row: the internal record lists those targets (laneOnlyNative), and the parity pipeline gives such a case no iOS keys, as it does for the web-only fonts cases. So the interaction fixtures prove web rows only, and the native profiles, support counts and north star claim nothing for them until PR 3 and PR 4 lift the refusal. The lanes still compile and compare the forced cases and hit vectors on native. Caught by android-profile.test (every case proving a promoted android row compiles unblocked for a user), which failed in the previous job run before this fix.
  • Transitions and animations in interaction rules stay refused. The transition and animation lists cascade in the none state only, and the web output writes them on the base classes. So a transition or animation declaration (longhand or shorthand) in a rule that tests an interaction pseudo-class is refused on every target, as it was before this PR, rather than silently dropped. A transition set outside the interaction rule compiles, and the browser runs it when a state rule changes the property.
  • Plants: comboStateDropped, webHoverUngated and hitUnmodelledNotRefused are new. With interactionRuleDropped, all four are caught in packages/dragon/test/interaction.test.ts.
  • Parity. seld-r2a's forced cases (<case>~ix<k>, run beside the counted cases through forced-pseudo.ts) are kept. New fixtures: interaction-active and interaction-combo (forced cases, ltr and rtl), reject-interaction-focus-within and reject-interaction-cap. reject-interaction-active is removed because :active now compiles.
  • docs/decisions.md records R1 to R16 under "Runtime styles and animation" and amends "Hover and focus" to "a tap never hovers (principle 3); focus and activation follow Chrome traces".
  • Reused branches. seld-r2a's part A (27ca07d41d) was cherry-picked by intent onto master, its conflicts resolved beside ANIM-b1 and INL, and then reworked per R4 to R9. seld-r2b (runtime and traces) and seld-r2c (cursor) belong to PRs 2 and 3.

Left for later PRs (not in this one)

  • No device runtime yet. Interaction rules are refused on iOS and Android (above) until the runtime PR (rt-interaction.ts, the R7 table in state.ts, R16) and the native glue PR lift the refusal. The lanes compile the states on native through interactionLanes.
  • FORM-a dependent pieces. Real focusables (button, range, a[href]), tabindex and the R9 display/touchConsumesTap clauses wait for FORM-a, so pointer- and keyboard-reachable focus is empty today and focus is reached only through forced cases. The interaction-focus fixture keeps seld-r2a's forced-only shape.
  • PR 2 and later: cursor (seld-r2c), the @media (hover: hover) fold (R3's authored shape), the hover-only-reveal warning, the UA focus-visible ring refusal, and the interaction-relayout fixture. The relayout fixture needs app state and the runtime.

Evidence

  • Chrome check before the regen (interaction fixtures only, live Chrome 145, runFixture). All four layout fixtures pass in both directions, forced cases included: hover 12 cases, focus 8, active 16, combo 10. All three rejects pass.
  • R3 in Chrome. Dragon's web output for interaction-hover was loaded in a touch context (hasTouch, isMobile: (hover: hover) false). Forced :hover on a's chain and a real tap on tall leave both at their none-state styles. In the mouse context the hover style applies.

Review round (precomputed review of ee1ae75)

Each fix has a test in packages/dragon/test/interaction.test.ts that fails without it (checked by running the new tests with the source fixes reverted: all five failed).

  • High, web-css.ts:211 / animations.ts:85: transition and animation declarations in interaction rules were silently dropped on web. Fixed by refusing them (interactionRefusals), as before this PR. Test: refuses transition and animation declarations in an interaction rule on every target pins .a:hover { width: 60px; transition: width 1s linear }, .a:hover { animation-play-state: paused }, :is(.a:focus) { animation: k 1s } and .a:active { transition-duration: 1s } as refused, and .a { transition: width 1s } .a:hover { width: 60px } as clean.
  • Medium, web-css.ts:211: state rules replaced the color: inherit a child under an animated colour gets (T065 R9). Fixed: the state-rule diff passes the same under flag as the base rule, so an inherited colour stays inherit in every state, and a colour the child declares in a state is still emitted. Test: keep an inherited colour inherit under an animated colour in every state pins the reviewer's document.
  • Medium, selectors.ts:159: native targets accepted the four pseudo-classes with no profile row and no runtime. Fixed by the native refusal above. Test: refuses every interaction rule on native outside the lanes pins the four [ios]/[android] errors for .a:active and .card:hover .title, blocked native targets, a ready web output, and no errors for web alone, for the lanes and for a document without interaction rules.
  • Follow-on (PM, from the job run): native rows promoted from lane-only cases. Fixed as above (laneOnlyNative). Tests: android-profile.test after the regen, and the laneOnlyNative pins in refuses every interaction rule on native outside the lanes.
  • Low, project.ts:687: R13 counted an overflow value computed from a refused partner, and the body said R13 was dormant. Fixed (above) and the body corrected. Test: counts an overflow value computed from a refused partner as refused, and leaves R13 to the lanes.
  • Low, interaction.ts:84: isFocusable differed from HTML. An invalid tabindex (HTML's rules for parsing integers) no longer makes an element focusable, and input type=hidden never is. Test: focusability (R9). R9's general display: none clause still waits for FORM-a (PR 2): known follow-up.
  • Low, cases.ts:36: Chrome never renders the conditions of collapsed combinations past the first member, nor the not all and (hover: hover) branch. Known follow-up, not fixed here: PR 2 brings seld-r2c's exclusivity and identity checks over the emitted CSS, and the touch-context Chrome run belongs with the R2 trace lane (PR 3). Today these are covered by the structural tests in interaction.test.ts and the manual touch-context check under Evidence.

Outside the spec

  • packages/parity/test/glyph-clearance-pins.json: new case lines only (DRAGON_PIN_WRITE=1), additive.
  • packages/dragon/test/compile.test.ts, selectors.test.ts, packages/parity/test/css-escapes.test.ts, device-vectors.test.ts and packages/translate/test/translate.test.ts: retargets listed below.
  • packages/parity/src/pipeline.ts (compileFixture) and packages/parity/src/native-host.ts (nativeCompile): set interactionLanes, so the lanes keep the interaction states on native.

Changed tests, each with its reason

  • interaction.test.ts: its compile helper compiles as the lanes do (interactionLanes) by default, because those tests prove the states on native as the lanes use them; the new tests pass lanes = false to pin the user-facing native refusal. No existing expectation changed.
  • selectors.test, compile.test: the refusal pins move from :active, :hover and :focus to :checked, :visited and :focus-within, which stay refused. Intent kept: interaction and state pseudo-classes outside SELD-R2 are refused.
  • css-escapes.test: div:\68over is now judged against Chrome (it compiles), and the escaped :focus-within refusal is pinned instead (selectors 59 to 60).
  • interaction-forced.test: the registry lists the four forced fixtures, and the state keys are the new match-set keys. It adds a pin that a hover-and-press combination forces both chains.
  • device-vectors.test, translate.test: p1/hit goes from 2028 to 2060, one hit vector per layout case per DPR (4 fixtures × 2 directions × 4 DPRs). The floor in p1-floor.json is unchanged.
  • parity.test, platform.test, values.test, fixture-reader.test, android-profile.test, pixel-reference.test: seld-r2a's retargets. Forced cases are counted beside the reachable cases and pass the same prepare hook to both renderings; the values group order ends with interaction; reject-selector-hover refuses :focus-within.
  • reject-interaction-active fixture: removed, because :active compiles (R4). reject-interaction-focus-within takes its place as the interaction-pseudo refusal.

What passed (job seld-r2-c2, at 3991a30)

  • pnpm regen reached its fixed point; its outputs are in their own commit.
  • pnpm typecheck: pass.
  • pnpm test: Tests 15 failed | 4812 passed | 2 skipped (4829). Failing files: interaction.test.ts device-failures.test.ts land.test.ts lanes-concurrent.test.ts lanes-records.test.ts lanes.test.ts p6a-promotion.test.ts . The device-record files are device step pending (land.test only its evidence:stamp case); rerun alone and passed: interaction.test.ts.
  • vitest run packages/parity/test/android-profile.test.ts alone after the regen: pass.
  • Targeted before the job: vitest run packages/dragon/test/interaction.test.ts compile.test.ts selectors.test.ts packages/parity/test/interaction-forced.test.ts: 154 passed. The five new review tests fail with the source fixes reverted.

Music player north star (examples/music-player/dragon/north-star-check.json, against master)

  • errors 356 -> 362
  • web DRAGON_UNSUPPORTED_SELECTOR: 9 -> 4
  • web DRAGON_UNSUPPORTED_VALUE: 43 -> 44
  • ios DRAGON_UNSUPPORTED_SELECTOR: 9 -> 8
  • ios DRAGON_UNSUPPORTED_VALUE: 52 -> 53
  • android DRAGON_UNSUPPORTED_SELECTOR: 9 -> 8
  • android DRAGON_UNSUPPORTED_VALUE: 52 -> 53

The native DRAGON_UNSUPPORTED_SELECTOR counts are the native refusal of the interaction rules until PR 3 and PR 4 (owned rises, cleared by those PRs); R13 adds none outside the lanes.

Reviewed size: 188438 bytes (Macroscope ignore patterns excluded), over the ~150 KB guide by the seld-r2a carry-over and the interaction tests; one theme, so it is not split.

🤖 Generated with Claude Code

… conflicts resolved beside ANIM-b1 (web-css animations and the interaction state rules both kept) and INL; derived-count pins left at master's values until the regen
…inations per app assignment (R4, R5, R7), the web hover gate (R3) and the fail-closed hit model refusal (R13)

selectors.ts lifts :active (focus-within and the rest stay refused); match.ts's InteractionState gains active. analysis/interaction.ts
builds, per case, the hover chains, active chains and focus values (pointer focus without focus-visible, keyboard focus with it)
from the candidates found to a fixed point, resolves every combination in full and stores each distinct resolution once
(R5: a :focus that changes nothing is the none state); more than 256 distinct reachable states in one app assignment, or more
than 4096 combinations, are refused naming SELD-R2s (R7); the states never count against the app state table, so seld-r2a's
MAX_STATE_ASSIGNMENTS count is gone, and so is combinedStateRefusal (combined states compile). Each state records what Chrome's
CSS.forcePseudoState forces to reach it (a real pointer's whole chains and focus), which the parity forced cases use.
web-css.ts emits a condition per combination a state stands for, and wraps every condition that needs a :hover candidate in
@media (hover: hover); a condition with :hover candidates only off is also emitted without them under
not all and (hover: hover), so a tap never leaves a hover style on a phone (R3). project.ts refuses, on native, every
interaction rule in a case with a pointer-reachable state and a compiled paint fact outside HIT_MODELLED (R13, fail closed,
naming SELD-R2b), after the value checks so a value the profile refuses does not count. Plants: comboStateDropped,
webHoverUngated, hitUnmodelledNotRefused (caught in interaction.test.ts).

Fixtures: interaction-active and interaction-combo (forced cases), reject-interaction-focus-within and reject-interaction-cap;
reject-interaction-active is removed because :active now compiles. docs/decisions.md records R1 to R16 and amends "Hover and
focus". Test retargets keeping intent: selectors.test and compile.test refusal pins move from :active to :checked and
:focus-within; css-escapes judges div:\68over against Chrome and pins the escaped :focus-within refusal (selectors 59 -> 60);
interaction-forced.test lists the four forced fixtures and the new state keys.
… 2028 -> 2060 in device-vectors and translate tests (one hit vector per layout case per DPR: 4 interaction fixtures x 2 directions x 4 DPRs); glyph-clearance pins gain the new cases' lines (DRAGON_PIN_WRITE=1 vitest run packages/parity/test/pixel-reference.test.ts; additive, no existing line changes); the web output's state-member list fails loudly on a state that stands for no combination
…iOS 26.5 runtime, host-arch Android image, CI memory reserve, outcome merge)
…-case captures of the interaction fixtures, emitted CSS, vectors, hit vectors, profiles, north-star check, tw:sweep and the rest of the chain
…on the Mac (device-ci.ts vectors); merge needs both halves; lane records name their hosts (producedOn)
…ns in interaction rules (no silent drop on web); keep color: inherit under an animated colour in state rules; refuse interaction rules on ios and android outside the parity lanes until the native runtime (PR 3, PR 4); R13 counts an overflow value computed from a refused partner as refused and runs only in the lanes; isFocusable follows HTML for invalid tabindex and input type=hidden
…pares each set's model; LAND_ARCH_REBASELINE for the PR that switches, exact failure sets); cli/device-ci.ts in the evidence stamp; a refused merge exits 3 and a blocked device fails its job; CI checks Xcode and the host toolchains; the compare step fails on a difference
…tionLanes; a user's compile refuses its interaction rules there) proves no ios or android profile row: the record lists those targets (laneOnlyNative) and the parity pipeline gives such a case no iOS keys, as the web-only fonts cases
…rth-star check, profiles and the rest of the chain after the native refusal, the interaction-rule animation refusal and no native rows from lane-only cases
@thejackshelton thejackshelton added the landing-failed The landing driver stopped this PR; see its comment label Oct 5, 2026
@thejackshelton

Copy link
Copy Markdown
Contributor Author

Landing stopped at step merge (pnpm land)

merge-train: merging seld-r2-c onto c78674a614f39a3ed5ee30aca2f68051b45a059d failed; conflicts in packages/dragon/src/faults.ts, packages/dragon/src/project.ts, packages/parity/src/fixtures.ts, packages/parity/test/device-vectors.test.ts, packages/parity/test/values.test.ts, packages/translate/test/translate.test.ts

Fix the cause, then hand #136 back to the landing queue with the new clean head.

…138): SELD-R2's faults move to faults/seld-r2.ts (one FAULT_GROUPS line), the interaction group is one sorted GROUPS line, master's side of the central files and tests; project.ts keeps both nativeLanes and interactionLanes; p1/hit pins carried at 2060 until the regen re-derives them
@thejackshelton thejackshelton added the regen Run pnpm regen on CI (regen-on-ci.yml) and push the outputs back label Oct 5, 2026
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: 6877d7c
Run: https://github.com/compiled-run/dragoncss/actions/runs/37275532666
@github-actions github-actions Bot removed the regen Run pnpm regen on CI (regen-on-ci.yml) and push the outputs back label Oct 5, 2026
…c-only android vectors lane; merge takes the CI outcomes alone
…ors): master's translate.test and device-vectors.test check hit against p1-floor.json, so the exact 2060 hit pins are dropped; project.ts keeps both REPL-a's images and SELD-R2's laneOnlyNative in the internal record
@thejackshelton thejackshelton added the regen Run pnpm regen on CI (regen-on-ci.yml) and push the outputs back label Oct 5, 2026
…irst and only reports the comparison (its tree's lanes are "not run"; the driver judges against the previous position); compare in device-ci.ts with a test on a not-run tree; the android vectors lane records and binds its ABI
@thejackshelton thejackshelton removed the regen Run pnpm regen on CI (regen-on-ci.yml) and push the outputs back label Oct 5, 2026
thejackshelton and others added 7 commits October 5, 2026 12:09
…two runs never blame each other's writes on their own steps
Commands: pnpm regen --skip lanes-host --skip tw-sweep (macos-26) and pnpm regen --only lanes-host (xcode-27), alternated
to a fixed point, then pnpm regen --only tw-sweep (macos-26).
Base: cbfa388
Run: https://github.com/compiled-run/dragoncss/actions/runs/37341041399
floor-merge: never-lower for pins, whole-file replace, no guessing in ordered lists
@thejackshelton thejackshelton removed the landing-failed The landing driver stopped this PR; see its comment label Oct 5, 2026
thejackshelton and others added 10 commits October 5, 2026 12:45
regen: one regen per worktree (the MQ-R1 'outside its declared outputs' failure was two regens at once)
tw-sweep capture: the fonts wait fails after 10 s instead of hanging
Commands: pnpm regen; pnpm run parity:devices; pnpm regen
Commands: pnpm regen; pnpm run parity:devices; pnpm regen
@thejackshelton
thejackshelton merged commit 78241af into master Oct 5, 2026
5 checks passed
@thejackshelton
thejackshelton deleted the seld-r2-c branch October 5, 2026 18:24
thejackshelton added a commit that referenced this pull request Oct 5, 2026
…'s lowering includes the interaction variants; both sides' record fields, exports and imports; p1-floor.json takes master's until regen re-derives it
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant