From c15c38ff8edf3bdee9abab6f46c723b67a1f3aee Mon Sep 17 00:00:00 2001 From: Tobias Horak Date: Mon, 7 Sep 2026 17:30:08 +0200 Subject: [PATCH 01/39] jpro-sticky: withhold the pin animation where scroll timelines are unsupported On engines without scroll-driven animations -- every Firefox, and Safari before 26 -- emitting the pin rule anyway is worse than emitting nothing. The engine drops the unknown `animation-timeline`, `animation-duration:auto` then resolves to 0s, and `animation-fill-mode: both` snaps the node to the `to` keyframe. Since that keyframe sits at the release limit (a document height down for an unbounded pin), every sticky and fixed node is parked off-screen and the page loses its header, toast, bar and overlay entirely. Feature-detect the timeline and, when it is missing, emit only the pointer-events half of the rule. The server-side pin in sync() then drives the node on its own: refreshed at browserViewport() cadence rather than per compositor frame, so lower fidelity, but on screen and correct. Covered by StickyNoScrollTimelineTest, which drives the example in Firefox (the one engine Playwright ships that lacks the feature) and asserts pinned nodes are on screen. Without the guard it fails with the header parked at 5951px. Reported in #127. --- jpro-sticky/build.gradle | 12 ++- .../platform/sticky/impl/WebScrollImpl.java | 16 ++++ .../sticky/StickyNoScrollTimelineTest.java | 90 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyNoScrollTimelineTest.java diff --git a/jpro-sticky/build.gradle b/jpro-sticky/build.gradle index 4c4d2bd2..1fc951e8 100644 --- a/jpro-sticky/build.gradle +++ b/jpro-sticky/build.gradle @@ -10,7 +10,7 @@ dependencies { test { // The Playwright tests need a Chromium (installPlaywright provides it) and the example's port. - dependsOn 'installPlaywright' + dependsOn 'installPlaywright', 'installPlaywrightFirefox' if (System.getProperty('jpro.test.port') != null) { systemProperty 'jpro.test.port', System.getProperty('jpro.test.port') } @@ -27,6 +27,16 @@ tasks.register('installPlaywright', JavaExec) { args = ['install', '--with-deps', 'chromium'] } +// StickyNoScrollTimelineTest covers the engines that lack scroll-driven animations, and Firefox is +// the one such engine Playwright ships. +tasks.register('installPlaywrightFirefox', JavaExec) { + group = 'verification' + description = 'Install the Firefox browser used by StickyNoScrollTimelineTest' + classpath = sourceSets.test.runtimeClasspath + mainClass = 'com.microsoft.playwright.CLI' + args = ['install', '--with-deps', 'firefox'] +} + publishing { publications { mavenJava(MavenPublication) { diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java index 2d9afbfb..fa66edea 100644 --- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java +++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java @@ -32,6 +32,11 @@ * It builds only on the JPro Viewport API ({@link WebAPI#browserViewport()} / * {@link WebAPI#documentBounds()}) and needs no core change. *

+ * Engines without scroll-driven animations (Firefox, Safari < 26) get no animation + * rule at all, and the server-side pin alone drives the node: correct, but refreshed at + * {@link WebAPI#browserViewport()} cadence rather than per compositor frame. The rule must be withheld + * rather than emitted and ignored -- see {@code render()} in {@link #installCompositor}. + *

* When running as a desktop application the {@link WebAPI} consumer never fires, so installation * is a no-op and the node keeps its normal flow positioning. *

@@ -357,8 +362,19 @@ private void installCompositor(double x, double natTop, double y0, double relLim // geometry-sig change) just updates them and rewrites the sheet. " st.x = " + x + "; st.natTop = " + natTop + "; st.inset = " + y0 + "; st.relServer = " + relLimitServer + "; st.hostOffsetY = " + hostOffsetY + ";\n" + " st.pe = " + mouseTransparent + ";\n" + + // engines without scroll-driven animations must get NO animation rule at all (see render). + " st.sda = CSS.supports('animation-timeline','scroll()');\n" + " st.render = function(){\n" + " if(st.jid == null) return;\n" + + // no scroll timeline (Firefox, Safari < 26): emit only the pointer-events half. Emitting the + // animation anyway is worse than useless -- the engine drops the unknown animation-timeline, + // 'animation-duration:auto' then resolves to 0s, and 'animation-fill-mode:both' snaps the node + // to the 'to' keyframe, parking it a document-height below the viewport (invisible). Without + // the rule the server-side pin from sync() drives the node: lower fidelity, but correct. + " if(!st.sda){\n" + + " st.style.textContent = st.pe ? ('[jpro-id=\"' + st.jid + '\"]{pointer-events:none;}') : '';\n" + + " return;\n" + + " }\n" + // document extent (scrollHeight), NOT scroll max (scrollHeight - clientHeight): the // latter folds in viewport height, leaving the unbounded range stale on resize. " var docExtent = document.documentElement.scrollHeight;\n" + diff --git a/jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyNoScrollTimelineTest.java b/jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyNoScrollTimelineTest.java new file mode 100644 index 00000000..c6d20961 --- /dev/null +++ b/jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyNoScrollTimelineTest.java @@ -0,0 +1,90 @@ +package one.jpro.platform.sticky; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import one.jpro.platform.playwright.JProPlaywrightTest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression guard for issue #127 on engines without scroll-driven animations. + * + *

Firefox does not support {@code animation-timeline: scroll()} (nor does Safari < 26), and the + * rule {@code WebScrollImpl} emits is actively harmful there if it is emitted anyway: the engine drops + * the unknown {@code animation-timeline}, {@code animation-duration: auto} resolves to {@code 0s}, and + * {@code animation-fill-mode: both} snaps the node to the {@code to} keyframe — parking every pinned + * element a document-height below the viewport, i.e. invisible. This test drives the real app in + * Firefox and asserts pinned elements are on screen and pinned by the server-side fallback. + * + *

Requires a Firefox installed via {@code ./gradlew :jpro-sticky:installPlaywrightFirefox}. + */ +public class StickyNoScrollTimelineTest extends JProPlaywrightTest { + + private static final File LOGS_DIR = new File(projectRoot(), "jpro-sticky/example/logs"); + + /** How far a pinned element may sit below the viewport top before we call it "not pinned". */ + private static final double PINNED_MAX_TOP_PX = 120; + + private static Playwright ffPlaywright; + private static Browser firefox; + + @BeforeAll + static void startAll() throws Exception { + startServer(LOGS_DIR, gradleCommand(":jpro-sticky:example:jproStart")); + ffPlaywright = Playwright.create(); + firefox = ffPlaywright.firefox().launch(new BrowserType.LaunchOptions().setHeadless(true)); + } + + @AfterAll + static void stopAll() throws Exception { + if (firefox != null) firefox.close(); + if (ffPlaywright != null) ffPlaywright.close(); + stopServer(gradleCommand(":jpro-sticky:example:jproStop")); + } + + @Test + @DisplayName("#127: without scroll-timeline support, pinned nodes stay on screen (not parked off-page)") + void pinnedNodesStayOnScreenWithoutScrollTimelineSupport() { + Page page = firefox.newContext( + new Browser.NewContextOptions().setViewportSize(420, 860)).newPage(); + page.navigate(BASE_URL); + // Firefox boots the app slower than the shared waitForRunning budget allows; wait plainly. + page.locator("#jpro-sticky-header").waitFor( + new Locator.WaitForOptions().setTimeout(180_000)); + page.waitForTimeout(3000); + + // Precondition: this really is an engine without scroll-driven animations, so the guard is + // what is under test rather than the compositor path. + assertTrue(Boolean.FALSE.equals(page.evaluate( + "() => CSS.supports('animation-timeline','scroll()')")), + "expected Firefox to lack scroll-driven animations; if it gained them, " + + "this test no longer covers the unsupported path"); + + page.evaluate("() => window.scrollBy(0, 400)"); + page.waitForTimeout(1500); + + // The bug parked pinned nodes ~a document height down (~6300px). Assert they are pinned near + // the viewport top instead -- the server-side fallback's job. + for (String id : new String[]{"#jpro-sticky-header", "#jpro-toast", "#jpro-bottom-bar", + "#jpro-fab", "#jpro-overlay"}) { + double top = page.locator(id).boundingBox().y; + assertTrue(top < page.viewportSize().height, + id + " is parked off-screen at top=" + top + " (issue #127)"); + } + + double headerTop = page.locator("#jpro-sticky-header").boundingBox().y; + assertTrue(headerTop < PINNED_MAX_TOP_PX, + "sticky header should be pinned near the viewport top, was at " + headerTop); + + page.context().close(); + } +} From 91335bad76f8b2380f3f51e381ee3f59dd7eab99 Mon Sep 17 00:00:00 2001 From: Tobias Horak Date: Tue, 8 Sep 2026 00:05:08 +0200 Subject: [PATCH 02/39] jpro-sticky: bind the pin to the element, not to its jpro-id jpro-id is a per-view transport index whose counter restarts when a reconnect builds a new view, so the cached id in the injected style rule did not merely go stale -- it silently retargeted an unrelated node. Measured against the example app by closing the websocket to force a real reconnect: five of six rules matched nothing, and the sixth (a fullscreen overlay) retargeted the bottom bar and hauled it from top:855 to top:0, taking the overlay's pointer-events with it. Keep the @keyframes in the injected sheet but bind the animation inline on the element itself. A running animation outranks inline declarations in the cascade, so it still overrides the renderer's own inline transform, and the renderer writes styles one property at a time so the binding survives its re-renders. An element reference cannot collide with another node: it only goes stale, which isConnected detects. A 500ms heartbeat rebinds when the peer detaches, covering the case where a re-install wins the race against JPro's DOM rebuild and resolves the outgoing element while it is still connected. After this the stale entries are inert (elConnected:false) and every pinned element holds its position across a reconnect. Note: full recovery still needs a fresh element reference from the server, since the resolver closes over a per-view JS value slot. Tracked separately. --- .../platform/sticky/impl/WebScrollImpl.java | 137 ++++++++++++------ 1 file changed, 89 insertions(+), 48 deletions(-) diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java index fa66edea..4be7dca4 100644 --- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java +++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java @@ -35,7 +35,7 @@ * Engines without scroll-driven animations (Firefox, Safari < 26) get no animation * rule at all, and the server-side pin alone drives the node: correct, but refreshed at * {@link WebAPI#browserViewport()} cadence rather than per compositor frame. The rule must be withheld - * rather than emitted and ignored -- see {@code render()} in {@link #installCompositor}. + * rather than emitted and ignored -- see {@code apply()} in {@link #installCompositor}. *

* When running as a desktop application the {@link WebAPI} consumer never fires, so installation * is a no-op and the node keeps its normal flow positioning. @@ -323,12 +323,12 @@ private double releaseLimit(double nodeH) { } /** - * (Re-)installs the scroll-timeline animation that pins the node. The animation is realised - * entirely inside an injected {@code @@ -198,6 +201,13 @@ a `ScrollPane` doesn't need this.) ``` +**Keep `` overflow visible.** Any element whose overflow is not `visible` is a scroll +container, whether or not it can actually scroll, and a sticky node pins against the nearest one. A +`` that clips therefore pins every node to a viewport that never moves. The trap is that +setting one axis is enough: `overflow-x: hidden` alone makes `overflow-y` compute to `auto`. Put the +horizontal clip on `` instead, as above. The library detects and lifts such a clip at runtime, +but it logs nothing and the page is better off without one. + ### Stacking order When pinned nodes overlap, fixed paints above sticky. Within one mode, the node whose position you @@ -215,8 +225,9 @@ scrim.setViewOrder(1); **Pinned nodes are reparented.** While a node is fixed (web and desktop) or page-level sticky on the web, it is moved into an overlay, leaving a placeholder in its original layout slot. So -`node.getParent()` and scene-graph lookups see it relocated until you clear the position. A sticky -node inside a `ScrollPane` is the exception: it stays in place. +`node.getParent()` and scene-graph lookups see it relocated until you clear the position. On the web +it lands one level deeper still, inside a pane of its own (see below). A sticky node inside a +`ScrollPane` is the exception: it stays in place. By default the overlay sits at the scene root. A node moved there loses any CSS or context scoped to its former ancestors, such as route styles or a popup container. To keep those, register an ancestor @@ -226,6 +237,12 @@ pane as an overlay host, and the node reparents into the nearest one above it in Scroll.registerOverlayHost(popupContainer); ``` +**On the web, the browser owns the pin.** The node is mounted inside a pane sized to the range it +should travel, and an injected rule makes that pane's element `position: sticky` at the anchor's +inset. Sticky clamps to its containing block, so the release point is the pane's own end and no code +runs per scroll event. Fixed is the same pin over a pane as long as the document: a viewport-anchored +node fits the viewport, so that end stays out of reach and the pin never releases. + **On the web, the stuck flip can trail the visuals.** `:stuck` and `stuckProperty` track the browser-viewport sync cadence, so they update up to one sync interval after the node pins. Inside a `ScrollPane` the flip is exact. From f520515a147aaf577daf188a818e1b7abe357416 Mon Sep 17 00:00:00 2001 From: Tobias Horak Date: Mon, 21 Sep 2026 16:23:47 +0200 Subject: [PATCH 29/39] jpro-sticky: decide the pin's scrollport on the vertical axis alone A clip only has to touch one axis to make an element a scrollport, so a body clipped horizontally owns the pin and cannot move it. Testing scrollWidth alongside scrollHeight let that case skip the unclip, and the early return then stopped the walk before the ancestors above it. Warn instead when lifting a clip that was really holding content back. --- jpro-sticky/README.md | 6 ++++-- .../one/jpro/platform/sticky/impl/WebScrollImpl.java | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/jpro-sticky/README.md b/jpro-sticky/README.md index f4032a7c..5b57a76e 100644 --- a/jpro-sticky/README.md +++ b/jpro-sticky/README.md @@ -205,8 +205,10 @@ a `ScrollPane` doesn't need this.) container, whether or not it can actually scroll, and a sticky node pins against the nearest one. A `` that clips therefore pins every node to a viewport that never moves. The trap is that setting one axis is enough: `overflow-x: hidden` alone makes `overflow-y` compute to `auto`. Put the -horizontal clip on `` instead, as above. The library detects and lifts such a clip at runtime, -but it logs nothing and the page is better off without one. +horizontal clip on `` instead, as above: `` is where the viewport's own scrolling comes +from, so clipping it creates no inner scrollport. The library lifts such a clip off `` at +runtime so the pin still works, and warns on the browser console if that clip was holding back real +horizontal overflow, since lifting it can surface a scrollbar. ### Stacking order diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java index a21ac911..697f4029 100644 --- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java +++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java @@ -424,9 +424,9 @@ private void installSticky(double y0, double nodeW, double nodeH) { " while(p && p !== document.documentElement && n++ < 64){\n" + " var cs = getComputedStyle(p);\n" + " if(/auto|scroll|hidden/.test(cs.overflowX) || /auto|scroll|hidden/.test(cs.overflowY)){\n" + - " if(p.scrollHeight > p.clientHeight + 1 || p.scrollWidth > p.clientWidth + 1){\n" + - // an embedded jpro tag can sit inside a real scroller. that one owns the pin, but the - // offset was resolved against the browser viewport, so the two disagree. + // only vertical scrollability decides: an embedded jpro tag can sit inside a real + // scroller, and that one owns the pin while the offset came from the browser viewport. + " if(p.scrollHeight > p.clientHeight + 1){\n" + " if(!st.warnedScroller){ st.warnedScroller = true;\n" + " console.warn('[jpro-sticky] ' + '" + jsKey + "' + ': an ancestor of this pin'\n" + " + ' scrolls, so the pin holds against it and not against the page. The'\n" + @@ -434,6 +434,12 @@ private void installSticky(double y0, double nodeW, double nodeH) { " + \" scroller's own position.\"); }\n" + " return;\n" + " }\n" + + // a clip that was really holding back wider content, so lifting it can surface a + // horizontal scrollbar. the pin is worth more than the clip, but say so. + " if(p.scrollWidth > p.clientWidth + 1 && !st.warnedClip){ st.warnedClip = true;\n" + + " console.warn('[jpro-sticky] ' + '" + jsKey + "' + ': lifting a horizontal clip'\n" + + " + ' that made this element a scrollport the pin could not move in. Put the'\n" + + " + ' clip on instead of to keep it.'); }\n" + " if(!p.hasAttribute('data-jpro-sticky-ov')){\n" + " p.setAttribute('data-jpro-sticky-ov', p.style.overflow || '');\n" + " }\n" + From bc28c94a69c08f193ae1862ff1140e19bd142528 Mon Sep 17 00:00:00 2001 From: Tobias Horak Date: Mon, 21 Sep 2026 17:18:14 +0200 Subject: [PATCH 30/39] jpro-sticky: retire the scroll-timeline framing from the Firefox test Nothing emits animation-timeline any more, so the class name, the docs and the CSS.supports precondition all described machinery that is gone. The precondition would also have failed the test outright once Firefox ships scroll-driven animations. Keep the coverage, which is the engine #127 was reported against, and tighten the comments the rewrite left behind. --- jpro-sticky/README.md | 18 ++++++------- jpro-sticky/build.gradle | 7 +++-- .../platform/sticky/impl/OverlayMount.java | 7 +++-- .../platform/sticky/impl/WebScrollImpl.java | 23 +++++++--------- ...melineTest.java => StickyFirefoxTest.java} | 27 +++++-------------- 5 files changed, 30 insertions(+), 52 deletions(-) rename jpro-sticky/src/test/java/one/jpro/platform/sticky/{StickyNoScrollTimelineTest.java => StickyFirefoxTest.java} (62%) diff --git a/jpro-sticky/README.md b/jpro-sticky/README.md index 5b57a76e..74e13be5 100644 --- a/jpro-sticky/README.md +++ b/jpro-sticky/README.md @@ -203,12 +203,10 @@ a `ScrollPane` doesn't need this.) **Keep `` overflow visible.** Any element whose overflow is not `visible` is a scroll container, whether or not it can actually scroll, and a sticky node pins against the nearest one. A -`` that clips therefore pins every node to a viewport that never moves. The trap is that -setting one axis is enough: `overflow-x: hidden` alone makes `overflow-y` compute to `auto`. Put the -horizontal clip on `` instead, as above: `` is where the viewport's own scrolling comes -from, so clipping it creates no inner scrollport. The library lifts such a clip off `` at -runtime so the pin still works, and warns on the browser console if that clip was holding back real -horizontal overflow, since lifting it can surface a scrollbar. +`` that clips therefore pins every node to a viewport that never moves, and one axis is enough +to do it: `overflow-x: hidden` alone makes `overflow-y` compute to `auto`. Put the horizontal clip on +`` instead, as above; clipping the root creates no inner scrollport. The library lifts such a +clip off `` at runtime, and warns on the console when it was holding back real overflow. ### Stacking order @@ -240,10 +238,10 @@ Scroll.registerOverlayHost(popupContainer); ``` **On the web, the browser owns the pin.** The node is mounted inside a pane sized to the range it -should travel, and an injected rule makes that pane's element `position: sticky` at the anchor's -inset. Sticky clamps to its containing block, so the release point is the pane's own end and no code -runs per scroll event. Fixed is the same pin over a pane as long as the document: a viewport-anchored -node fits the viewport, so that end stays out of reach and the pin never releases. +should travel, and an injected rule makes the node itself `position: sticky` at the anchor's inset. +Sticky clamps to its containing block, which is that pane, so the release point is the pane's end and +no code runs per scroll event. Fixed is the same pin over a pane as long as the document: a +viewport-anchored node fits the viewport, so that end stays out of reach and the pin never releases. **On the web, the stuck flip can trail the visuals.** `:stuck` and `stuckProperty` track the browser-viewport sync cadence, so they update up to one sync interval after the node pins. Inside a diff --git a/jpro-sticky/build.gradle b/jpro-sticky/build.gradle index 1fc951e8..36f481b7 100644 --- a/jpro-sticky/build.gradle +++ b/jpro-sticky/build.gradle @@ -14,7 +14,7 @@ test { if (System.getProperty('jpro.test.port') != null) { systemProperty 'jpro.test.port', System.getProperty('jpro.test.port') } - // installPlaywright already provisioned the browser — don't re-download during the run. + // installPlaywright already provisioned the browser, so skip the download during the run. environment 'PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD', '1' } @@ -27,11 +27,10 @@ tasks.register('installPlaywright', JavaExec) { args = ['install', '--with-deps', 'chromium'] } -// StickyNoScrollTimelineTest covers the engines that lack scroll-driven animations, and Firefox is -// the one such engine Playwright ships. +// StickyFirefoxTest drives the engine issue #127 was reported against. tasks.register('installPlaywrightFirefox', JavaExec) { group = 'verification' - description = 'Install the Firefox browser used by StickyNoScrollTimelineTest' + description = 'Install the Firefox browser used by StickyFirefoxTest' classpath = sourceSets.test.runtimeClasspath mainClass = 'com.microsoft.playwright.CLI' args = ['install', '--with-deps', 'firefox'] diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java index b9cf6037..af54a7ee 100644 --- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java +++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java @@ -60,10 +60,9 @@ Region mount(double reservedHeight) { * As {@link #mount(double)}, but optionally puts the node inside a per-pin range pane rather than * straight into the overlay. * - * @param withRange when {@code true}, the node is mounted inside a {@link #range()} pane that the caller - * sizes to the pin's scroll span. The web sticky path needs it: {@code position: sticky} - * clamps to its containing block, so the span has to be a real box in the DOM, and it has - * to come from a node JPro renders itself rather than an element injected underneath it. + * @param withRange when {@code true}, the node is mounted inside a {@link #range()} pane that the + * caller sizes to the pin's scroll span. {@code position: sticky} clamps to its + * containing block, so the web path needs that span as a real box in the DOM. */ Region mount(double reservedHeight, boolean withRange) { final Parent parent = node.getParent(); diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java index 697f4029..16e02805 100644 --- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java +++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java @@ -44,7 +44,7 @@ * ancestor {@code transform} displaces it; that shift is measured in the browser, not baked, and * subtracted from the offset. And sticky holds against the nearest scroll container, which an * ancestor becomes merely by having a non-visible {@code overflow} ({@code body} usually does), so - * one that cannot scroll at all is cleared, where it changes nothing else. + * one that cannot scroll is cleared. *

* The server keeps the node at the position it appears at, because picking is a scene pick and a * node parked at the span's origin is not where the click lands. The renderer writes that offset out @@ -299,16 +299,13 @@ private void sync() { node.setLayoutX(local.getX()); node.setLayoutY(local.getY()); } else { - // the span runs from the node's flow top to its release point, and position:sticky clamps to it, - // so the release needs no code. unbounded pins run to the end of the document. - // FIXED spans the document from its very top: a viewport-anchored node always fits the - // viewport, so the span's end is out of reach and the pin never releases. + // the span runs from the node's flow top to its release point. unbounded pins, FIXED + // included, run to the end of the document. final double docH = root.getLayoutBounds().getHeight(); final double spanTop = fixed ? 0 : natTop; final double relLimit = fixed ? (docH - nodeH) : (relLimitServer >= 0 ? relLimitServer : Math.max(natTop, docH - nodeH)); - // FIXED rides its span because a viewport-anchored node fits the viewport, so the span's end - // stays out of reach. A node taller than the viewport breaks that and drifts near the end. + // a fixed node taller than the viewport can reach its span's end, so it drifts there. if (fixed && viewportH > 0 && y0 + nodeH > viewportH + STUCK_EPS) { LOGGER.warn("jpro-sticky[{}]: fixed node is taller than the viewport ({} + {} > {});" + " the pin will drift near the end of the document.", jsKey, y0, nodeH, viewportH); @@ -317,8 +314,8 @@ private void sync() { range.setLayoutX(span.getX()); range.setLayoutY(span.getY()); range.resize(nodeW, Math.max(nodeH, (relLimit - spanTop) + nodeH)); - // picking is a scene pick on the server, so the node has to sit where it appears, exactly as - // it does without a span. the sticky rule drops the transform that produces on its element. + // picking is a scene pick, so the node sits where it appears. the sheet drops the + // transform that produces. node.setLayoutX(local.getX() - span.getX()); node.setLayoutY(local.getY() - span.getY()); } @@ -333,13 +330,11 @@ private void sync() { } } - // only what the sheet bakes in: the span and the node's position are server-side layout, - // which re-runs on its own. + // only what the sheet bakes in. the span and the node's position are server-side layout. final String sig = y0 + "|" + nodeW + "|" + nodeH; - // NaN/Infinity are valid JS literals, so a non-finite value here would install cleanly and then - // fail silently: the emitted declaration is rejected by the CSS parser, leaving a dead pin and - // nothing in any log. Refuse the install instead. + // NaN/Infinity are valid JS literals, so a non-finite value installs cleanly and then dies in + // the CSS parser, leaving a dead pin and nothing in any log. refuse the install instead. if (!allFinite(y0, nodeW, nodeH)) { LOGGER.warn("jpro-sticky[{}]: skipping install, non-finite geometry (y0={}, w={}, h={})", jsKey, y0, nodeW, nodeH); diff --git a/jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyNoScrollTimelineTest.java b/jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyFirefoxTest.java similarity index 62% rename from jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyNoScrollTimelineTest.java rename to jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyFirefoxTest.java index c6d20961..b3a2e37f 100644 --- a/jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyNoScrollTimelineTest.java +++ b/jpro-sticky/src/test/java/one/jpro/platform/sticky/StickyFirefoxTest.java @@ -16,18 +16,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Regression guard for issue #127 on engines without scroll-driven animations. - * - *

Firefox does not support {@code animation-timeline: scroll()} (nor does Safari < 26), and the - * rule {@code WebScrollImpl} emits is actively harmful there if it is emitted anyway: the engine drops - * the unknown {@code animation-timeline}, {@code animation-duration: auto} resolves to {@code 0s}, and - * {@code animation-fill-mode: both} snaps the node to the {@code to} keyframe — parking every pinned - * element a document-height below the viewport, i.e. invisible. This test drives the real app in - * Firefox and asserts pinned elements are on screen and pinned by the server-side fallback. + * Regression guard for issue #127, which was reported against Firefox: every pinned node sat a + * document height below the viewport, out of sight. This drives the real app in Firefox and asserts + * the pinned elements are on screen, with the sticky header at its pin. * *

Requires a Firefox installed via {@code ./gradlew :jpro-sticky:installPlaywrightFirefox}. */ -public class StickyNoScrollTimelineTest extends JProPlaywrightTest { +public class StickyFirefoxTest extends JProPlaywrightTest { private static final File LOGS_DIR = new File(projectRoot(), "jpro-sticky/example/logs"); @@ -52,8 +47,8 @@ static void stopAll() throws Exception { } @Test - @DisplayName("#127: without scroll-timeline support, pinned nodes stay on screen (not parked off-page)") - void pinnedNodesStayOnScreenWithoutScrollTimelineSupport() { + @DisplayName("#127: pinned nodes stay on screen in Firefox (not parked off-page)") + void pinnedNodesStayOnScreenInFirefox() { Page page = firefox.newContext( new Browser.NewContextOptions().setViewportSize(420, 860)).newPage(); page.navigate(BASE_URL); @@ -62,18 +57,10 @@ void pinnedNodesStayOnScreenWithoutScrollTimelineSupport() { new Locator.WaitForOptions().setTimeout(180_000)); page.waitForTimeout(3000); - // Precondition: this really is an engine without scroll-driven animations, so the guard is - // what is under test rather than the compositor path. - assertTrue(Boolean.FALSE.equals(page.evaluate( - "() => CSS.supports('animation-timeline','scroll()')")), - "expected Firefox to lack scroll-driven animations; if it gained them, " - + "this test no longer covers the unsupported path"); - page.evaluate("() => window.scrollBy(0, 400)"); page.waitForTimeout(1500); - // The bug parked pinned nodes ~a document height down (~6300px). Assert they are pinned near - // the viewport top instead -- the server-side fallback's job. + // the bug parked pinned nodes about a document height down (~6300px). for (String id : new String[]{"#jpro-sticky-header", "#jpro-toast", "#jpro-bottom-bar", "#jpro-fab", "#jpro-overlay"}) { double top = page.locator(id).boundingBox().y; From 34158446780fa271c4143034d372dffd7caf5673 Mon Sep 17 00:00:00 2001 From: Tobias Horak Date: Mon, 21 Sep 2026 18:10:59 +0200 Subject: [PATCH 31/39] jpro-sticky: say what the script reads instead of calling it baked Leftover wording from the keyframe design, where the geometry really was written into the rule at install time. Also drops a comment still crediting the deleted transform rewriting for why the shift is read live. --- .../jpro/platform/sticky/impl/WebScrollImpl.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java index 16e02805..b6a94024 100644 --- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java +++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java @@ -41,8 +41,8 @@ * is out of reach and the pin never releases. *

* Two corrections the sheet carries. Sticky resolves in layout space, so an - * ancestor {@code transform} displaces it; that shift is measured in the browser, not baked, and - * subtracted from the offset. And sticky holds against the nearest scroll container, which an + * ancestor {@code transform} displaces it; the script reads that shift off the DOM and subtracts it, + * because the server cannot see what the renderer wrote. And sticky holds against the nearest scroll container, which an * ancestor becomes merely by having a non-visible {@code overflow} ({@code body} usually does), so * one that cannot scroll is cleared. *

@@ -330,7 +330,7 @@ private void sync() { } } - // only what the sheet bakes in. the span and the node's position are server-side layout. + // only the values the sheet contains. the span and the node's position are server-side layout. final String sig = y0 + "|" + nodeW + "|" + nodeH; // NaN/Infinity are valid JS literals, so a non-finite value installs cleanly and then dies in @@ -385,8 +385,8 @@ private double releaseLimit(double nodeH) { * that outranks inline. Assumes an svg scale of 1 (true for native-scrolling pages). *

* {@code y0} is the viewport pin line, and {@code nodeW}/{@code nodeH} the resolved box. Nothing - * else is baked: the span's extent and the node's own offset are server-side layout, and the - * browser derives the release from the span. The scroll position never enters the sheet. + * else is written into the sheet: the span's extent and the node's own offset are server-side + * layout, and the browser derives the release from the span. The scroll position never enters it. *

* Readiness race. The element reference ({@code jpro.getValue(n)}) throws until * JPro's render pulse has registered the node, so it resolves inside a retry loop guarded by @@ -452,8 +452,8 @@ private void installSticky(double y0, double nodeW, double nodeH) { " c = p; p = p.parentElement;\n" + " }\n" + " return null; };\n" + - // sticky resolves in layout space, so only an ancestor transform displaces it. measured - // rather than baked: the fixed path rewrites those transforms to left / top as it runs. + // sticky resolves in layout space, so only an ancestor transform displaces it. read on + // every render, since the renderer can write or clear a transform at any time. " st.shift = function(el){\n" + " var y = 0, p = el.parentElement, n = 0;\n" + " while(p && p !== document.documentElement && n++ < 64){\n" + From b7327c43cb27dc6420743e386c1ca43bba907727 Mon Sep 17 00:00:00 2001 From: Tobias Horak Date: Mon, 21 Sep 2026 18:44:21 +0200 Subject: [PATCH 32/39] jpro-sticky: report the scroll container instead of rewriting it Rewriting the host page's overflow was wrong three ways. It read computed style alone, so it lifted a body clip that was already handed to the viewport and working; it replaced the clip with visible rather than clip, so a real horizontal clip started scrolling; and it defeated a body{overflow:hidden} scroll lock, the standard modal pattern, for as long as any pin existed. The walk now names the scrollport on the console and touches nothing, skipping body while html is visible. The example's own index.html was the page that needed the fix: it clipped html and body together, which is what stops the hand-off. It now matches what the README tells users. Also restores the span's top to the re-install signature. It moves the ancestor transform the script measures, so without it a pin whose span shifts keeps a stale offset until the heartbeat catches it half a second later. Narrowing the signature had dropped it. Tightens isOverlay to the span's own style class rather than any grandchild, and documents that a web pin drops the node's own transform properties, which JPro fuses with the layout position. --- jpro-sticky/README.md | 20 +++-- .../src/main/resources/jpro/html/index.html | 11 ++- .../platform/sticky/impl/OverlayMount.java | 2 +- .../platform/sticky/impl/StickyOverlay.java | 6 +- .../platform/sticky/impl/WebScrollImpl.java | 88 +++++++++---------- 5 files changed, 69 insertions(+), 58 deletions(-) diff --git a/jpro-sticky/README.md b/jpro-sticky/README.md index 74e13be5..40d3dd52 100644 --- a/jpro-sticky/README.md +++ b/jpro-sticky/README.md @@ -201,12 +201,14 @@ a `ScrollPane` doesn't need this.) ``` -**Keep `` overflow visible.** Any element whose overflow is not `visible` is a scroll -container, whether or not it can actually scroll, and a sticky node pins against the nearest one. A -`` that clips therefore pins every node to a viewport that never moves, and one axis is enough -to do it: `overflow-x: hidden` alone makes `overflow-y` compute to `auto`. Put the horizontal clip on -`` instead, as above; clipping the root creates no inner scrollport. The library lifts such a -clip off `` at runtime, and warns on the console when it was holding back real overflow. +**Put a horizontal clip on ``, not on both.** Any element whose overflow is not `visible` is a +scroll container, whether or not it can actually scroll, and a sticky node pins against the nearest +one. `` is a special case: it hands its overflow to the viewport as long as `` is +`visible`, so `body { overflow-x: hidden }` on its own is harmless. Clip `` *as well* and that +hand-off stops, `` becomes a scroll container that never scrolls, and every pin holds against +it instead of the page. One axis is enough to trigger it, since `overflow-x: hidden` makes +`overflow-y` compute to `auto`. The library never edits your styles; it names the scroll container it +found on the browser console. ### Stacking order @@ -243,6 +245,12 @@ Sticky clamps to its containing block, which is that pane, so the release point no code runs per scroll event. Fixed is the same pin over a pane as long as the document: a viewport-anchored node fits the viewport, so that end stays out of reach and the pin never releases. +**On the web, a pinned node's own transforms are dropped.** JPro fuses a node's layout position with +its `scaleX`/`rotate`/`translateX` into one CSS `transform`, and the pin has to clear that transform +to place the box itself. So those properties have no visual effect on a web-pinned node, while the +same node still honours them on the desktop. Anchors are unaffected; only the JavaFX transform +properties are. + **On the web, the stuck flip can trail the visuals.** `:stuck` and `stuckProperty` track the browser-viewport sync cadence, so they update up to one sync interval after the node pins. Inside a `ScrollPane` the flip is exact. diff --git a/jpro-sticky/example/src/main/resources/jpro/html/index.html b/jpro-sticky/example/src/main/resources/jpro/html/index.html index 53b7aeb4..21a335dc 100644 --- a/jpro-sticky/example/src/main/resources/jpro/html/index.html +++ b/jpro-sticky/example/src/main/resources/jpro/html/index.html @@ -9,12 +9,17 @@ - + diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java index af54a7ee..e6d0fe47 100644 --- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java +++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/OverlayMount.java @@ -103,7 +103,7 @@ Region mount(double reservedHeight, boolean withRange) { r.setManaged(false); // the span is a positioning box, never a hit target: picking stays with the node inside it. r.setPickOnBounds(false); - r.getStyleClass().add("jpro-sticky-range"); + r.getStyleClass().add(StickyOverlay.RANGE_STYLE_CLASS); r.getChildren().add(node); StickyOverlay.insertSorted(overlay, r, stackOrder); this.range = r; diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StickyOverlay.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StickyOverlay.java index 6f5dfca6..73fe1b71 100644 --- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StickyOverlay.java +++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/StickyOverlay.java @@ -49,6 +49,9 @@ public final class StickyOverlay { /** {@code id} stamped on every overlay {@link Group}, lets {@link #isOverlay} spot a mounted node. */ private static final String OVERLAY_ID = "jpro-sticky-overlay"; + /** Style class of the per-pin span the web path mounts a node into (see {@link OverlayMount#mount}). */ + static final String RANGE_STYLE_CLASS = "jpro-sticky-range"; + /** * {@code viewOrder} for the overlay {@link Group} itself (negative = in front), so it paints above the * host's other children regardless of child-list order. Needed when the host is a routing container @@ -218,7 +221,8 @@ static boolean isOverlay(Node parent) { return true; } // a web pin sits one level deeper, inside its own span (see OverlayMount#mount). - return parent != null && parent.getParent() instanceof Group + return parent != null && parent.getStyleClass().contains(RANGE_STYLE_CLASS) + && parent.getParent() instanceof Group && OVERLAY_ID.equals(parent.getParent().getId()); } diff --git a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java index b6a94024..b2983331 100644 --- a/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java +++ b/jpro-sticky/src/main/java/one/jpro/platform/sticky/impl/WebScrollImpl.java @@ -40,11 +40,15 @@ * the length of the document: a viewport-anchored node always fits the viewport, so that span's end * is out of reach and the pin never releases. *

- * Two corrections the sheet carries. Sticky resolves in layout space, so an - * ancestor {@code transform} displaces it; the script reads that shift off the DOM and subtracts it, - * because the server cannot see what the renderer wrote. And sticky holds against the nearest scroll container, which an - * ancestor becomes merely by having a non-visible {@code overflow} ({@code body} usually does), so - * one that cannot scroll is cleared. + * The correction the sheet carries. Sticky resolves in layout space, so an ancestor + * {@code transform} displaces it; the script reads that shift off the DOM and subtracts it, because + * the server cannot see what the renderer wrote. + *

+ * Sticky also holds against the nearest scroll container, which an ancestor becomes merely by having + * a non-visible {@code overflow}. That is the host page's business, not this class's, so a pin only + * reports the one it found on the console. {@code body} is the case worth knowing: it hands its + * overflow to the viewport while {@code html} is {@code visible}, and becomes a scroll container in + * its own right only once {@code html} clips too. *

* The server keeps the node at the position it appears at, because picking is a scene pick and a * node parked at the span's origin is not where the click lands. The renderer writes that offset out @@ -295,6 +299,8 @@ private void sync() { } final Point2D local = overlay.sceneToLocal(x, serverY); final Pane range = mount.range(); + // FIXED spans the document from its top, STICKY from the node's flow top. + final double spanTop = fixed ? 0 : natTop; if (range == null) { node.setLayoutX(local.getX()); node.setLayoutY(local.getY()); @@ -302,7 +308,6 @@ private void sync() { // the span runs from the node's flow top to its release point. unbounded pins, FIXED // included, run to the end of the document. final double docH = root.getLayoutBounds().getHeight(); - final double spanTop = fixed ? 0 : natTop; final double relLimit = fixed ? (docH - nodeH) : (relLimitServer >= 0 ? relLimitServer : Math.max(natTop, docH - nodeH)); // a fixed node taller than the viewport can reach its span's end, so it drifts there. @@ -330,14 +335,15 @@ private void sync() { } } - // only the values the sheet contains. the span and the node's position are server-side layout. - final String sig = y0 + "|" + nodeW + "|" + nodeH; + // what the sheet contains, plus the span's top: moving the span moves the very ancestor + // transform the script measures, so a stale offset outlives the change without it. + final String sig = y0 + "|" + nodeW + "|" + nodeH + "|" + spanTop; // NaN/Infinity are valid JS literals, so a non-finite value installs cleanly and then dies in // the CSS parser, leaving a dead pin and nothing in any log. refuse the install instead. - if (!allFinite(y0, nodeW, nodeH)) { - LOGGER.warn("jpro-sticky[{}]: skipping install, non-finite geometry (y0={}, w={}, h={})", - jsKey, y0, nodeW, nodeH); + if (!allFinite(y0, nodeW, nodeH, spanTop)) { + LOGGER.warn("jpro-sticky[{}]: skipping install, non-finite geometry (y0={}, w={}, h={}, spanTop={})", + jsKey, y0, nodeW, nodeH, spanTop); return; } @@ -412,37 +418,34 @@ private void installSticky(double y0, double nodeW, double nodeH) { " st.sel = '[data-jpro-sticky-el=\"" + jsKey + "\"]';\n" + " st.inset = " + y0 + ";\n" + " st.rangeId = 'jpro-" + RANGE_ID_PREFIX + jsKey + "';\n" + - // sticky holds against the nearest scroll container, and an element that cannot scroll is - // still one, so the pin would hold against a viewport that never moves. body is often one. - " st.unclip = function(el){\n" + + // sticky holds against the nearest scroll container, and an element is one merely by + // having a non-visible overflow. report it rather than touch the host page's styles. + " st.scrollport = function(el){\n" + + " var de = document.documentElement, dcs = getComputedStyle(de);\n" + + " var rootVisible = dcs.overflowX === 'visible' && dcs.overflowY === 'visible';\n" + " var p = el.parentElement, n = 0;\n" + - " while(p && p !== document.documentElement && n++ < 64){\n" + + " while(p && p !== de && n++ < 64){\n" + " var cs = getComputedStyle(p);\n" + " if(/auto|scroll|hidden/.test(cs.overflowX) || /auto|scroll|hidden/.test(cs.overflowY)){\n" + - // only vertical scrollability decides: an embedded jpro tag can sit inside a real - // scroller, and that one owns the pin while the offset came from the browser viewport. - " if(p.scrollHeight > p.clientHeight + 1){\n" + - " if(!st.warnedScroller){ st.warnedScroller = true;\n" + - " console.warn('[jpro-sticky] ' + '" + jsKey + "' + ': an ancestor of this pin'\n" + - " + ' scrolls, so the pin holds against it and not against the page. The'\n" + - " + ' offset is measured from the browser viewport and may be off by the'\n" + - " + \" scroller's own position.\"); }\n" + - " return;\n" + - " }\n" + - // a clip that was really holding back wider content, so lifting it can surface a - // horizontal scrollbar. the pin is worth more than the clip, but say so. - " if(p.scrollWidth > p.clientWidth + 1 && !st.warnedClip){ st.warnedClip = true;\n" + - " console.warn('[jpro-sticky] ' + '" + jsKey + "' + ': lifting a horizontal clip'\n" + - " + ' that made this element a scrollport the pin could not move in. Put the'\n" + - " + ' clip on instead of to keep it.'); }\n" + - " if(!p.hasAttribute('data-jpro-sticky-ov')){\n" + - " p.setAttribute('data-jpro-sticky-ov', p.style.overflow || '');\n" + - " }\n" + - // both axes together: visible on one computes back to auto while the other clips. - " p.style.setProperty('overflow', 'visible', 'important');\n" + + // body hands its overflow to the viewport while html is visible, so it is not the + // scrollport then. only a clipping html stops that and makes body a real one. + " if(p !== document.body || !rootVisible) return p;\n" + " }\n" + " p = p.parentElement;\n" + - " } };\n" + + " }\n" + + " return null; };\n" + + " st.checkPort = function(el){\n" + + " if(st.warnedPort) return;\n" + + " var p = st.scrollport(el); if(!p) return;\n" + + " st.warnedPort = true;\n" + + " var name = p.tagName.toLowerCase() + (p.id ? '#' + p.id : '');\n" + + " console.warn('[jpro-sticky] ' + '" + jsKey + "' + ': ' + name + ' is the nearest'\n" + + " + ' scroll container, so it owns this pin. '\n" + + " + (p.scrollHeight > p.clientHeight + 1\n" + + " ? 'The offset is measured from the browser viewport, not from it, so the pin'\n" + + " + ' line may be off by that element\\'s own position.'\n" + + " : 'It cannot scroll, so the pin will not move. Give it overflow:visible, or put'\n" + + " + ' the clip on so keeps handing its overflow to the viewport.')); };\n" + // sticky clamps to its own parent, so the rule has to land on the span's child, not on // the inner element getElement() resolves to (that one is only as tall as the node). " st.target = function(el){\n" + @@ -466,7 +469,7 @@ private void installSticky(double y0, double nodeW, double nodeH) { // the range pane is the containing block, so the browser releases the pin at its bottom. " st.render = function(){\n" + " if(!st.el) return;\n" + - " st.unclip(st.el);\n" + + " st.checkPort(st.el);\n" + " st.lastShift = st.shift(st.el);\n" + " st.style.textContent = st.sel + '{position:sticky !important;'\n" + " + 'top:' + (st.inset - st.lastShift) + 'px !important;'\n" + @@ -571,15 +574,6 @@ private static void removeStickyStyle(WebAPI webapi, String jsKey) { " st.el = null;\n" + " if(st.style && st.style.parentNode) st.style.parentNode.removeChild(st.style);\n" + " delete reg['" + jsKey + "'];\n" + - // shared ancestors, so this can only go back once the last pin is gone. found by attribute - // rather than from a list, which would retain the ancestors of every route already left. - " if(Object.keys(reg).length === 0){\n" + - " document.querySelectorAll('[data-jpro-sticky-ov]').forEach(function(p){\n" + - " var was = p.getAttribute('data-jpro-sticky-ov');\n" + - " if(was) p.style.overflow = was; else p.style.removeProperty('overflow');\n" + - " p.removeAttribute('data-jpro-sticky-ov');\n" + - " });\n" + - " }\n" + "})();"); } } From 34b71569f10a9eadad35097732c6a5106447e770 Mon Sep 17 00:00:00 2001 From: Florian Kirmaier Date: Mon, 21 Sep 2026 19:03:08 +0200 Subject: [PATCH 33/39] jpro-sticky: bake the span's scene y into the sticky rule The ancestor transforms sticky ignores sum to the span's scene y, which the server already has. Write it into the rule instead of measuring it in the browser; the heartbeat now only re-binds after a DOM rebuild. Example and README use overflow-x: clip. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018JuPMUKNXzA35Hjuahzvvn --- jpro-sticky/README.md | 15 ++++---- .../src/main/resources/jpro/html/index.html | 8 ++--- .../jpro/platform/sticky/impl/ScrollImpl.java | 2 +- .../platform/sticky/impl/WebScrollImpl.java | 36 +++++++------------ 4 files changed, 25 insertions(+), 36 deletions(-) diff --git a/jpro-sticky/README.md b/jpro-sticky/README.md index 40d3dd52..18f153d9 100644 --- a/jpro-sticky/README.md +++ b/jpro-sticky/README.md @@ -190,7 +190,7 @@ a `ScrollPane` doesn't need this.)