diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db85ca82..2cfd95de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,8 +44,10 @@ jobs: cache-on-failure: true # The host is the browser, and the browser is not a crate a site can - # install: `chuzz-headless` is built from source by the composite action - # below, which also installs the driver. That replaced `qa-inspect-host`, + # install: the composite action below downloads the verified + # `chuzz-headless` release matching its own revision (building that + # revision from source only if no release exists), and installs the + # driver. That replaced `qa-inspect-host`, # which was a second headless browser with the web platform in only the # other one -- no `URLSearchParams`, no `matchMedia`, no storage, no # observers -- so every gap closed for the browser had to be closed a @@ -54,12 +56,18 @@ jobs: # # 0.1.12 is still on crates.io, so nothing broke when the crate was # deleted from source; this is the last consumer to move off it. + # + # Pinned to a chuzz revision that has a published host (0.1.40). The + # previous pin predated the releases, so every run compiled that old + # source against today's registry, and it stopped compiling once its + # unlocked dependencies moved on (tokio features, blitz-control-protocol + # 0.5.3): every PR here went red on a browser build, not on this code. - name: Headless browser host id: qa - uses: pathscale/chuzz/.github/actions/headless-host@b0e4ce0d35fe96230c90d2a3ed55a64686abe370 + uses: pathscale/chuzz/.github/actions/headless-host@094d77b6f28f78ffcdc20b8b19e160ee6c4992ee with: token: ${{ secrets.SIBLING_REPOS_TOKEN }} - chuzz-ref: b0e4ce0d35fe96230c90d2a3ed55a64686abe370 + chuzz-ref: 094d77b6f28f78ffcdc20b8b19e160ee6c4992ee ps-qa-version: =0.7.3 - name: Install Dependencies diff --git a/docs/reviews/2026-07-27-ai-smell-deadcode-tests-docs.md b/docs/reviews/2026-07-27-ai-smell-deadcode-tests-docs.md index 18e5468e..c42c03bb 100644 --- a/docs/reviews/2026-07-27-ai-smell-deadcode-tests-docs.md +++ b/docs/reviews/2026-07-27-ai-smell-deadcode-tests-docs.md @@ -901,9 +901,9 @@ bun run playground:dev # fastest visual loop; aliases @pathscale/ui -> ``` **Reproduce the duplication census:** the 6-line normalized-window scanner used for SEV-4 is -at -`/private/tmp/claude-501/-Users-revenge-code/4526fe76-867f-4d6a-a325-84ff907ebbb8/scratchpad/dup.ts` -(scratchpad, may be cleaned up; it is ~40 lines and trivial to rewrite). Spot checks: +was a +`dup.ts` in the reviewer's scratch directory, not kept in the repository (it is ~40 +lines and trivial to rewrite). Spot checks: ```bash rg -n "const invokeEventHandler" src | wc -l # 25 diff --git a/docs/reviews/2026-07-27-theming-css-build-perf.md b/docs/reviews/2026-07-27-theming-css-build-perf.md index 81050b29..c867d4ac 100644 --- a/docs/reviews/2026-07-27-theming-css-build-perf.md +++ b/docs/reviews/2026-07-27-theming-css-build-perf.md @@ -733,7 +733,7 @@ Read in this order: Commands that work in this checkout (no `node_modules` present): ```bash -git -C /Users/revenge/code/UI rev-parse --short HEAD +git rev-parse --short HEAD diff <(sed -n '88,135p' src/styles/themes/light.css) <(sed -n '87,134p' src/styles/themes/dark.css) # SEV-6: silent rg --no-filename -o "z-index: [^;]+;" src/components --glob '*.css' | sort | uniq -c | sort -rn # SEV-7 rg -n -- "--z-overlay\s*:" src # SEV-7: no hits diff --git a/src/components/_shared/overlayHost.css b/src/components/_shared/overlayHost.css index 0a35dd93..4063c916 100644 --- a/src/components/_shared/overlayHost.css +++ b/src/components/_shared/overlayHost.css @@ -35,10 +35,24 @@ * * Import this file and put `ui-overlay-host` on the component's root, alongside * whatever positioning that root already sets. + * + * ## Why an open host lifts itself + * + * The stacking context has a cost: the overlay's own `z-index` now only ranks + * it inside its host. The host itself paints at `z-index: auto`, in tree order, + * so any later positioned sibling of an ancestor paints over the open overlay. + * A Select in a table row is the case that shipped: `.table__row` is + * `position: relative`, so the next row covered the open list and its options + * could not be pressed. While anything inside the host is open, the host takes + * the overlay's layer so the whole subtree paints above what follows it. */ @layer components { .ui-overlay-host { position: relative; isolation: isolate; } + + .ui-overlay-host:has([data-open="true"]) { + z-index: 80; + } } diff --git a/src/components/_shared/overlayPosition.ts b/src/components/_shared/overlayPosition.ts index 8fc472a4..0c88e98f 100644 --- a/src/components/_shared/overlayPosition.ts +++ b/src/components/_shared/overlayPosition.ts @@ -135,9 +135,32 @@ export const createOverlayPosition = ( options.anchorRect?.(), ); if (!triggerRect) return; - const overlayRect = overlay.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; + + // Size before measuring. The width limits below arrive through the + // style signal, which lands only after this pass, so the overlay was + // measured at its unconstrained width: for Select, whose CSS floor is + // `max(100%, 14rem)` on a fixed box, that is the whole viewport. The + // left clamp then pinned it to the screen edge. Chromium corrected it a + // frame later through the ResizeObserver, which read as a blink; an + // engine whose observer does not fire kept it there. Writing the same + // limits onto the element first makes the one measurement the real one. + const maxWidth = `${round(Math.max(0, viewportWidth - viewportPadding * 2))}px`; + const minWidth = options.matchTriggerWidth?.() + ? `${round(Math.max(triggerRect.width, options.minWidth?.() ?? 0))}px` + : undefined; + // A throw here would halt every reactive owner in the app, and an + // overlay stand-in without a style declaration (tests measure with + // one) is still positionable, so the write is skipped rather than + // assumed. + const overlayStyle = overlay.style as CSSStyleDeclaration | undefined; + if (overlayStyle) { + overlayStyle.maxWidth = maxWidth; + if (minWidth) overlayStyle.minWidth = minWidth; + } + + const overlayRect = overlay.getBoundingClientRect(); const offset = options.offset(); const placement = resolvePlacement( @@ -201,14 +224,8 @@ export const createOverlayPosition = ( position: "fixed", top: `${round(clamp(top, viewportPadding, maxTop))}px`, left: `${round(clamp(left, viewportPadding, maxLeft))}px`, - "max-width": `${round(Math.max(0, viewportWidth - viewportPadding * 2))}px`, - ...(options.matchTriggerWidth?.() - ? { - "min-width": `${round( - Math.max(triggerRect.width, options.minWidth?.() ?? 0), - )}px`, - } - : {}), + "max-width": maxWidth, + ...(minWidth ? { "min-width": minWidth } : {}), }); };