Skip to content

fix(php-transformer): open a projected modal in its source open state - #2783

Merged
ellenbauer merged 2 commits into
trunkfrom
fix/projected-dialog-rest-state
Oct 14, 2026
Merged

ellenbauer merged 2 commits into
trunkfrom
fix/projected-dialog-rest-state

Conversation

@ellenbauer

@ellenbauer ellenbauer commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Refs #2726. Follow-up to #2744.

What was wrong

Review of #2744 found that a projected captured modal can still open invisible, or keep the browser's dialog box.

  1. Id rules never matched the dialog during analysis. The dialog has a generated id, and fix(php-transformer): keep the source panel box on a projected modal dialog #2744 carries the source panel id as its editor anchor class. Id selectors reach the dialog in the output CSS through that class, but the source analysis (selector matcher and candidate indexes) still only compared the id attribute. So CapturedDialogConverter::modalPlacement(), which reads the cascade, missed a panel positioned only by id rules (#panel{position:fixed;top:0;left:10px;width:200px}), and the dialog kept the browser box.
  2. A rest rule that hides the panel dropped the dialog block. HtmlCompilation::sourceElementStartsHidden() read display:none at rest as hidden content, and the dialog was not emitted. This was already true for class rules on trunk (.panel{display:none}), and it would have become true for id rules once they match.
  3. Without a captured ancestor state, the rest rule won on the open dialog. With #panel{display:none;transform:translateX(-100%)} and body.nav-open #panel{display:block;transform:none}, the open dialog stayed hidden and off canvas. The existing closed-state repair (ClosedStateNormalizer) does not cover this. It only reacts to :hover and :focus reveal states, its repair stylesheet is editor-only, and it exempts positioned layers.

What changed

  • CssSelectorMatcher::matchesCompound(), CssSelectorMatchCache::styleRuleCandidates() and AuthorStyleAnalysis::selectorCandidates() and selectorCanMatch(): an element carrying an id's editor anchor class is treated as that id's holder. Only a projected captured dialog carries such a class in source HTML.
  • HtmlCompilation::sourceElementStartsHidden(): a captured <dialog>'s own rest state is not hidden content; the dialog block owns its closed state.
  • StyleResolver::capturedDialogOpenStateValues(): walks the raw author rules (the static analysis drops transform and drops rules that match no element yet). A state rule is an unconditioned rule whose rightmost compound matches the dialog but whose full selector does not. For display, visibility, opacity and transform, where the rest cascade hides or offsets the panel and a state rule reverses it, the state rule's value is returned. A transform the open state keeps, such as a centring translate, is not touched.
  • CapturedDialogConverter: a modal carries those values in its inline style, without !important. That beats the rest rules, and the block stylesheet's dialog[data-blocks-engine-triggers]:not([open]){display:none!important} still closes it.

Tests

  • New tests/unit/captured-dialog-open-state.php, 6 assertions, compiled through ArtifactCompiler with a wired copy holding #panel:

    • id rest state: block kept, style="display:block;transform:none", placement source;
    • class rest state: style="display:flex;opacity:1";
    • id-only positioning: placement source and no inline values;
    • centring transform kept by the open state: left alone.

    On trunk 4 of 6 fail.

  • captured-dialog-source-panel-box, captured-dialog-projector, captured-dialog-closed-visibility, captured-dialog-trigger-survives, captured-dialog-dropdown-panel and css-selector-matcher still pass. The layer direction contract passes, and phpstan is clean on the touched files.

  • capture-evidence-equivalence: 349/349 unchanged.

  • Full unit run (memory_limit -1, one process per test): 429 pass, 0 fail.

Review round 2 (second commit, 59a9430)

  • State rule selection. The last state rule in source order used to win: .dark-theme #panel{opacity:.5} after body.nav-open #panel{opacity:1} gave opacity:.5, and .no-js #panel{display:block} replaced display:flex.
    • When the capture recorded an opened ancestor class (data-blocks-engine-ancestor-state), only state rules naming it are trusted. If none names it, nothing is carried.
    • With no recorded state, every state rule must agree on a property, or nothing is carried for it.
  • Transform offsets. translate(-50%,-50%) counted as a rest offset, so .no-js #panel{transform:none} un-centred a modal that is fine on trunk. Only a translation of 100% (a full panel) or 100vw or 100vh or more along an axis now counts as moving the panel out of view.
  • Orphaned dialog. A dialog none of whose triggers can ever show (the trigger or an ancestor is display:none at its base with no conditional value that shows it, or carries hidden) is dropped again. A burger hidden on desktop but shown in a phone media query keeps its dialog.

Dialog probe matrix against trunk 2a963f0 (the reviewer's dlg2.sh cases plus two trigger cases):

Case Trunk This branch
id rest none + body.nav-open no inline values display:block;transform:none, placement source
opacity: nav-open 1, then dark .5 none none (disagree), placement source
display: nav-open flex, then no-js block none none (disagree)
centred translate + no-js none none none
rest only under @media none none
class rest none + .menu-open dialog dropped display:block
#menu:hover ~ #panel none none
no rest hide no placement placement source
trigger display:none everywhere dialog kept (rest id rule unseen) dialog dropped
trigger hidden on desktop, shown on phone kept kept, display:block

captured-dialog-open-state.php now has 12 assertions: 7 fail on trunk, and 5 fail on the previous PR head (the review cases). capture-evidence-equivalence: 349/349 unchanged. Full unit run (memory_limit -1, one process per test): 429 pass, 0 fail.

Import proof (workbench, new-vocabulary captures)

Studio CLI create --from with the liberation workbench captures (DLA main 569c336). The baseline is SSI v1.31.2 with BE trunk 2a963f0 (wb-*-trunk). The proof package is the same ZIP with #2780, #2781 and #2783 merged on 2a963f0 (ssi-be-proof-2726-combined-e51205f.zip). iPhone user agent at 375; desktop at 1280. Quality gate passed on every import (quality_pass: true, fail_import: false). The proof sites are stopped, not deleted.

  • Dunbar Oaks (localhost:8953): the menu dialog keeps placement source with no inline open values (its panel has no state rule to replay). Box at x=27.5, 320px wide, beige layer, the same as trunk (localhost:8952).
  • Dunbar at 1280 and at 375 with the menu closed: 0 differing pixels.
  • St Francis (localhost:8955): the dialog still opens through its bound trigger with all root items on screen, the same as trunk.
  • None of the workbench sites uses an id-only rest state, so the new cases are covered by the compiler probes and unit tests above, not by these imports.

🤖 Generated with Claude Code

Review of #2744 found that a projected captured modal could still open
invisible or keep the browser's dialog box:

- Id rules never matched the dialog during analysis. Its id is generated and
  the source panel id rides on its editor anchor class, so placement "source"
  missed a panel positioned only by id rules. The selector matcher and both
  candidate indexes now treat an element carrying an id's editor anchor class
  as that id's holder.
- A rest rule that hides the panel (by id or by class) made the dialog
  hidden content and the dialog block was dropped. A captured dialog's own
  rest state no longer counts as hidden; the block owns its closed state.
- Without a captured ancestor state, the rest rule won on the open dialog
  (`#panel{display:none;transform:translateX(-100%)}` against
  `body.nav-open #panel{display:block;transform:none}`). For each hiding or
  offsetting property that a state rule on the same panel reverses, the
  dialog now carries the state value inline. The block stylesheet's important
  closed-state rule still closes it.

Refs #2726

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ellenbauer

Copy link
Copy Markdown
Contributor Author

Please hold: our independent review found the open-state picker takes whichever state rule comes last (e.g. a .dark-theme or .no-js variant), and a centring translate(-50%,-50%) is treated as an at-rest offset, un-centring modals that were fine on trunk. Fix in progress on this branch.

… a modal's open values

Review of c0b3e89:

- The last state rule in source order won: `.dark-theme #panel{opacity:.5}`
  after `body.nav-open #panel{opacity:1}` gave the dialog `opacity:.5`, and
  `.no-js #panel{display:block}` replaced `display:flex`. When the capture
  recorded an opened ancestor class, only state rules naming it are trusted;
  otherwise every state rule must agree on a property, or nothing is carried.
- A centring `translate(-50%,-50%)` counted as a rest offset, so
  `.no-js #panel{transform:none}` un-centred a modal that was fine on trunk.
  Only a translation of a full panel (100%) or viewport (100vw, 100vh) or
  more now counts as moving the panel out of view.
- A dialog none of whose triggers can ever show (no viewport shows the
  trigger or an ancestor) is dropped again instead of surviving as an
  orphaned closed dialog.

Refs #2726

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ellenbauer
ellenbauer marked this pull request as ready for review October 14, 2026 06:54
@ellenbauer
ellenbauer merged commit 3800aaf into trunk Oct 14, 2026
22 checks passed
@ellenbauer
ellenbauer deleted the fix/projected-dialog-rest-state branch October 14, 2026 06:55
@ellenbauer

Copy link
Copy Markdown
Contributor Author

Hold lifted. Merged after three independent review rounds. Follow-up to #2744: id rules match a projected dialog through its editor-anchor class, so source placement sees id-only positioning; a dialog's own rest state no longer drops it; open-state values (display, visibility, opacity, out-of-view transforms) are carried inline only from state rules naming recorded ancestor state, or that all agree; centring transforms are left alone; a dialog whose triggers can never show is dropped. Known gaps, same as trunk: pixel-offset drawers, JS-added trigger classes. Refs #2726. cc @chubes4

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