From 56175e5560a69a9e23a4c6f6f8027505ae187f44 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Fri, 14 Aug 2026 14:39:08 -0400 Subject: [PATCH] Make dark-theme canvas rendering match light where the drag artifact lives A full audit of every difference between the two themes established that no code executes differently by theme - the connection-lag-while- dragging artifact is present in both, and dark merely renders it visibly. Three rendering-relevant asymmetries did that, and this change removes each: 1. Connection stroke: dark painted a bright line on a near-black canvas at roughly 2.07:1 contrast, versus light's soft 1.68:1 - and at stroke-width 1.5 the line falls into the renderer's hairline anti-aliasing regime below about 67 percent zoom, whose stepping is visibly harsher for light-on-dark than dark-on-light. A dedicated per-theme stroke token now reproduces light's contrast ratio on the dark canvas (dark #444444; light keeps its #AFAFAF appearance), stroke-width goes to 2 to stay out of hairline mode, and geometricPrecision pins high-quality anti-aliasing. 2. Shadows: dark's alphas (0.40/0.45/0.55) were 2.2x denser than light's (0.18/0.203/0.248) - the only structural asymmetry in the entire token palette - crisping the dragged card's silhouette and amplifying any visible misstep. Dark now uses light's values. 3. Page root background: html/body/#root had no background of their own, so pre-paint clears resolved to the embedding window's backdrop instead of the theme's canvas color. The root now paints the theme's window color opaquely in both themes. Also corrects the #323 composition flag, which the audit found never reached the browser: pywebview's Windows backend passes its own --disable-features switch and duplicate switches resolve last-one-wins, so the flag was silently discarded. Both feature names now travel in one merged list, so whichever list wins carries both. Co-Authored-By: Claude Fable 5 --- graphlink_desktop.py | 34 +++++++++++++-------------- web_ui/src/app/styles.css | 26 ++++++++++++++++++-- web_ui/src/lib/tokens/gl-vars-dev.css | 9 ++++--- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/graphlink_desktop.py b/graphlink_desktop.py index 0d7c521..9a91c3d 100644 --- a/graphlink_desktop.py +++ b/graphlink_desktop.py @@ -207,25 +207,25 @@ def main() -> int: return 1 logger.info("backend healthy at %s", base_url) - # Canvas drag-lag fix (dark-theme "connection detaches from the node" - # report): recent WebView2 builds hand independently-composited page - # layers to Windows' compositor to assemble on screen. A node card being - # dragged lives on its own such layer (Chromium promotes it once its - # position changes every frame), while the connection lines are redrawn - # into a different one - and Windows can apply the card's movement one - # display frame before the freshly drawn connection arrives, so the line - # visibly steps out of sync with the node it is attached to. Everything - # the renderer itself reports (layout geometry, its own frame captures) - # looks perfectly attached, because the divergence happens below the - # renderer, at window composition. Disabling delegated composition makes - # the renderer assemble the complete frame itself before handing one - # finished image to Windows. Appended in front of any flags the user - # already set in this variable; unknown feature names are ignored by - # WebView2, so this is inert on runtimes that predate the feature. - _composition_flags = "--disable-features=DelegatedCompositing" + # Canvas drag fix, corrected delivery (full audit, 2026-08-14): recent + # WebView2 builds hand independently-composited page layers to Windows' + # compositor to assemble on screen; a dragged node card and the redrawn + # connection lines can land one display frame apart. The first attempt + # at disabling that (#323) passed --disable-features=DelegatedCompositing + # through this environment variable - and the audit found it NEVER took + # effect: pywebview's own Windows backend passes + # --disable-features=ElasticOverscroll in the browser arguments it + # builds (site-packages/webview/platforms/edgechromium.py), duplicate + # --disable-features switches resolve last-one-wins, so one of the two + # lists was silently discarded. The reliable delivery is to put BOTH + # feature names in ONE merged list here: whichever --disable-features + # value wins, it now carries DelegatedCompositing (and keeps pywebview's + # ElasticOverscroll intent). Unknown feature names are ignored by + # runtimes that predate them, so this stays inert where not applicable. + _composition_flags = "--disable-features=ElasticOverscroll,DelegatedCompositing" _existing_args = os.environ.get("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "") os.environ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = ( - f"{_composition_flags} {_existing_args}".strip() + f"{_existing_args} {_composition_flags}".strip() ) import webview # pywebview - the native (non-Qt, non-browser) window diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index 40f29bb..562e884 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -43,6 +43,13 @@ body, #root { height: 100%; margin: 0; + /* Theme-matched page background (dark drag-parity audit): with no + explicit background the page root stays transparent, so any region the + renderer clears or presents before content paint resolves to the + embedding window's own backdrop rather than the theme's canvas color. + Painting the root opaquely in the theme's window color makes both + themes behave identically at that boundary. */ + background-color: var(--gl-surface-window); } .app-shell { @@ -403,13 +410,28 @@ body, font-size: var(--gl-node-font-size, 11px); } +/* Dark-theme drag-parity fix (full audit, 2026-08-14): the connection line + used to be painted at stroke-width 1.5 in --gl-surface-border-strong - + #505050 on dark's #1E1E1E canvas (a BRIGHT light-on-dark hairline, + ~2.07:1) vs #AFAFAF on light's #E1E1E1 (a soft dark-on-light line, + ~1.68:1). A sub-2px stroke drops into the renderer's hairline + anti-aliasing regime below ~67% zoom, whose stepping is visibly harsher + for light-on-dark than dark-on-light - so the identical drag produced a + line that visibly crawled/stepped against the node in dark while reading + as smooth in light. Three equalizers: a dedicated per-theme stroke token + (--gl-graph-edge-stroke: dark #444444 reproduces light's contrast RATIO + on the dark canvas; light keeps #AFAFAF unchanged), stroke-width 2 to + stay out of hairline mode at working zooms, and geometricPrecision to + pin high-quality anti-aliasing rather than pixel snapping. */ .scene-canvas .react-flow__edge-path { - stroke: var(--gl-surface-border-strong, var(--gl-surface-text-muted)); - stroke-width: 1.5; + stroke: var(--gl-graph-edge-stroke, var(--gl-surface-border-strong, var(--gl-surface-text-muted))); + stroke-width: 2; + shape-rendering: geometricPrecision; } .scene-canvas .react-flow__edge.selected .react-flow__edge-path { stroke: var(--gl-surface-text-primary); + shape-rendering: geometricPrecision; } .scene-minimap { diff --git a/web_ui/src/lib/tokens/gl-vars-dev.css b/web_ui/src/lib/tokens/gl-vars-dev.css index 6a05e26..a873f1d 100644 --- a/web_ui/src/lib/tokens/gl-vars-dev.css +++ b/web_ui/src/lib/tokens/gl-vars-dev.css @@ -118,6 +118,7 @@ --gl-frame-purple: #7c7c7c; --gl-frame-red: #7c7c7c; --gl-frame-yellow: #8e8e8e; + --gl-graph-edge-stroke: #444444; --gl-graph-node-badge-fill: #484848; --gl-graph-node-body-end: #292929; --gl-graph-node-body-start: #303030; @@ -149,9 +150,9 @@ --gl-semantic-status-info: #828282; --gl-semantic-status-success: #838383; --gl-semantic-status-warning: #919191; - --gl-shadow-1: 0 1px 3px rgba(0, 0, 0, 0.40); - --gl-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.45); - --gl-shadow-3: 0 8px 28px rgba(0, 0, 0, 0.55); + --gl-shadow-1: 0 1px 3px rgba(0, 0, 0, 0.18); + --gl-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.203); + --gl-shadow-3: 0 8px 28px rgba(0, 0, 0, 0.248); --gl-space-1: 4px; --gl-space-2: 8px; --gl-space-3: 12px; @@ -252,6 +253,7 @@ --gl-frame-purple: #838383; --gl-frame-red: #838383; --gl-frame-yellow: #717171; + --gl-graph-edge-stroke: #AFAFAF; --gl-graph-node-badge-fill: #B7B7B7; --gl-graph-node-body-end: #D6D6D6; --gl-graph-node-body-start: #CFCFCF; @@ -382,6 +384,7 @@ --gl-frame-purple: #838383; --gl-frame-red: #838383; --gl-frame-yellow: #717171; + --gl-graph-edge-stroke: #AFAFAF; --gl-graph-node-badge-fill: #B7B7B7; --gl-graph-node-body-end: #D6D6D6; --gl-graph-node-body-start: #CFCFCF;