Skip to content

fix(GestureMenu): Trace should above the GestureMenu blur#4466

Draft
cindmichelle wants to merge 44 commits into
cybersemics:mainfrom
cindmichelle:gesturemenu/trace-above-blur-layer
Draft

fix(GestureMenu): Trace should above the GestureMenu blur#4466
cindmichelle wants to merge 44 commits into
cybersemics:mainfrom
cindmichelle:gesturemenu/trace-above-blur-layer

Conversation

@cindmichelle

@cindmichelle cindmichelle commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes #3791

Summary

When performing a gesture, the TraceGesture (drawn stroke) appeared blurry over the gesture menu commands instead of staying sharp.

The root cause is that the gesturemenu's blur and TraceGesture lived in different stacking contexts, so z-index couldn't order them. react-native-web's creates its own stacking context (position: relative; z-index: 0), trapping TraceGesture inside it; the blur lived at root level, and since View is z-index: 0 in root, the entire View (trace included) always painted below the blur.

The fix works in two steps, since CSS stacking requires both to be true before it can order two elements.

  1. Move the blur inside View — render GestureContentBlur as a sibling of in AppComponent (it lands inside View via the children slot).
  2. Add a gestureContentBlur: 23 z-index token — orders it correctly between content (2) and gestureTrace (24).

Changes

  • GestureContentBlur.tsx — replaces the gesture menu's dedicated blur with the shared ProgressiveBlur component, so there's one blur implementation instead of two to maintain, and we inherit its Safari backdrop-filter workarounds. Sizes the blur height dynamically to the number of commands shown so it covers the menu footprint. Renders inside <View> via AppComponent's children slot.
  • constants.ts — pulls the gesture menu's sizing values (row height, spacing, padding) into one shared file, so both GestureMenu (which draws the menu) and GestureContentBlur (which calculates the blur height from them) use the same numbers.
  • AppComponent.tsx — mounts <GestureContentBlur /> as sibling of <Content/> on touch devices
  • MultiGesture.tsx — set zIndex: 'auto' on <View> to dissolve its stacking context
  • panda.config.ts — adds gestureContentBlur: 23 token between content (2) and gestureTrace (24)
  • GestureMenu.tsx / GestureMenuItem.tsx — moved under src/components/GestureMenu/; removed inline ProgressiveBlur

Notes

  • Dynamic blur height — rather than measuring the rendered menu with a ResizeObserver, the blur works out its own height with a formula:

    height = 2 × padding + header + commandCount × (rowLabel + rowGap) + bottomTail
    

    Every value in that formula comes from the shared constants.ts — the same numbers GestureMenu uses to lay itself out. This keeps the blur in sync with the menu: when a command is added or removed, the menu and the blur both grow by one row's worth of height, with no measuring and no extra render.

    Two things to note. The header value (GESTURE_MENU_HEADER_REM) is a best-effort estimate of the header area (label, divider, margin) rather than a real measurement. And because a selected command row is taller than a normal one (it shows an extra description), the formula underestimates the height when a row is expanded — but the gap stays small in practice, since the bottomTail padding already extends the blur beyond the command rows and absorbs most of the difference.

  • no longer forms a stacking context — setting zIndex: 'auto' on MultiGesture's was required so the blur and trace could be ordered against each other. This change came out of testing on Capacitor iOS and Safari, where the NavBar was appearing above the blur because of this same stacking context — which is what led us to this solution. Side effect: View's children are now ordered directly in the root stacking context instead of being isolated at z-index: 0, so any future z-index added inside View now competes at root level. Regression-checked the affected children — TraceGesture, ScrollZone, and Content still layer and render correctly, and ProgressiveBlur's existing consumers (Tip, Sidebar) are unaffected — with no visual change to any of them.

  • this PR is ready for visual review. The commit history looks a bit messy because this PR is branched off #4205, and since #4205 is based on a user-remote, I wasn't able to set up a proper stacked PR. It should still be fine to visually review this in parallel until #4205 is merged. This PR changes started from commit 06560eb and so on

Screenshots and Videos

Trace now above the blur

Platform Screenshot
Safari iOS
Capacitor iOS
Capacitor Android

Ensure no regression against it's original ux

Platform Recording
Safari iOS
ios.safari.MP4
Capacitor iOS
ios.capacitor.MP4
Capacitor Android
Android.cap.mp4

🤖 Generated with Claude Code

cindmichelle and others added 30 commits April 30, 2026 01:20
…ghlightColor props

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…el/Cheatsheet gap (cybersemics#3709)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eDiagram.onRef

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes regression from commit 120a77e where the per-segment rendering refactor split path handling into useGradient-dependent branches, causing two issues:

1. Arrowhead added to help gesture (rdld): The markerEnd condition lost the `path !== 'rdld'` guard, adding an unwanted arrowhead to the ? gesture in the test snapshot.

2. Straight lines instead of Bezier curves: The rdld Bezier path override was only added to the `!useGradient` branch, so callers using useGradient=true (default, e.g. CommandItem) rendered straight line segments instead of the smooth ? shape in the Help modal.

Both fixes restore the original behavior by re-adding the rdld special cases to the per-segment rendering path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cindmichelle and others added 14 commits June 20, 2026 03:47
Eliminate double negative by renaming to a positive boolean.
glow={true} (default) enables the glow, glow={false} disables it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
More descriptive name since it's specifically an item inside the
Gesture Menu, not a generic gesture item.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Clarifies that Cancel and Command Universe are always-visible commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…#3791)

Decouple the gesture menu's ProgressiveBlur from PopupBase and place it on a
new `gestureMenuBlur` z-index, just below `gestureTrace`, so the trace paints
above the blur — darkened by the menu overlay but no longer blurred.

Since the blur no longer inherits its height from the popup, derive its height
from the command count via shared rem constants; share the bottom-tail constant
with the FadeTransition wrapper's paddingBottom so the two stay in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the inline ProgressiveBlur overlay from GestureMenu along with the
gestureMenuBlur z-index token and the GESTURE_MENU_* height constants added in
ceb318e. The z-index approach cannot lift the trace above the blur because
the trace is trapped in react-native-web View's z-index:0 stacking context.

The blur will be reintroduced as a content overlay in AppComponent (Option E)
so it sits between the content and the trace. See
docs/superpowers/specs/2026-06-26-gesture-trace-above-blur-root-cause-and-decision.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Render the generic ProgressiveBlur as a sibling of <Content/> in
AppComponent, driven by gestureStore, so it lands inside MultiGesture's
<View> between the content and the trace.

Content is position:relative; z-index:content, so it paints in the
positive-z-index phase — above a z-index:auto overlay. Add a
gestureContentBlur z-index token (between content and gestureTrace) and
apply it via a wrapper so the blur paints above the content but below the
trace: content (2) -> gestureContentBlur (23) -> gestureTrace (24), all
within View's stacking context. The content behind the menu blurs while
the trace stays sharp.

See docs/superpowers/specs/2026-06-26-gesture-trace-above-blur-root-cause-and-decision.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ormula (cybersemics#3791)

Derive the gesture-menu blur height from commands.length and shared GESTURE_MENU_*
rem constants, publishing it to gestureStore; AppComponents content blur consumes it
and caps a top-anchored wrapper. Constants must track GestureMenuItem row styling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cindmichelle cindmichelle force-pushed the gesturemenu/trace-above-blur-layer branch from bff5266 to 245efba Compare July 2, 2026 10:25
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Vercel preview: https://em-5z6k68ofv-cybersemics.vercel.app

@fbmcipher

Copy link
Copy Markdown
Collaborator

First off, my visual review – looks great! Exactly what I was after. 🙏

My concern is architectural. Now that the blur lives outside the menu, it has to calculate the menu's height itself (commands.length × constants) instead of just inheriting it the way it used to. My worry is if someone ever needs to change the menu's composition (header spacing, a new row type, whatever), the calc won't necessarily follow, and the blur stops lining up. This kind of coupling gives some me pause, and it starts to blur the responsibilities of individual components. The blur really feels like it should live in the GestureMenu, not somewhere else.

Here's what I'm thinking... the blur used to size itself for free – height: 100% of the menu. We only lost that because it had to move out to sit under the trace. What if we leave the blur where it naturally sizes itself, and move the trace instead? We'd get the layering we want and the blur goes back to inheriting its height: no calc, nothing to drift, and no ResizeObserver needed (which I know is off the table on perf grounds anyway).

I know the catch is untangling TraceGesture from MultiGesture – you flagged the shared ref, and that's the real work here, I'm genuinely not pretending it's trivial. But it's also the thing Raine explicitly greenlit in the issue:

You can pull TraceGesture out of the MultiGesture component if needed. I'd rather a more decoupled hierarchy than portals.

And it delivers on the flexibility I'd asked for there too – being able to change the layer order later without surgery each time:

It would be great to implement this in a way that allows for future flexibility – in case we later decide that the TraceGesture needs to be layered differently.

What do you think? It might be worth a chat with AI to see how feasible this is. If this feels right, I'm happy to have a think with you about how to structure the refactor with you if that's useful. If there's a reason we shouldn't take this route please also lmk!

@cindmichelle

Copy link
Copy Markdown
Collaborator Author

@fbmcipher btw, after looking at #3801, I just realized that the GestureMenu background supposed to be full-screen height. However, I only able to find one design in Figma looks like this, could you confirm the Design 2 below is the final design?

Design 1 Design 2
image image

I also had a chat with Claude, and one concern about separating MultiGesture and TraceGesture is that we're splitting what is currently a cohesive component. Right now, MultiGesture is responsible for detecting the PanResponder, while TraceGesture is responsible for visualizing the gesture trace. If we move TraceGesture outside, we'd need to create eventNodeRef at the AppComponent level so both MultiGesture and TraceGesture can share the same ref.

And after spending more time understanding the MultiGesture component, I'm starting to think it might make more sense to re-parent GestureMenu under MultiGesture, since GestureMenu is should be part of the overall gesture UX. It's responsible for presenting the set of actions users can perform with gestures, so conceptually it feels closely tied to the gesture system itself.

The only downside I see is that it gives MultiGesture another responsibility, which could reduce its cohesion a bit. That said, I think the UX relationship between MultiGesture and GestureMenu makes this a reasonable tradeoff.

If we go with that reparenting GestureMenu approach, my plan would be to structure it like this. Based on Claude observations, this seems feasible and shouldn't introduce any regressions compared to what I've implemented so far.

MultiGesture
├── View (z-index: auto)
│   ├── GestureMenu (mounts when menu is active)
│   │   ├── PopupBase
│   │   └── ProgressiveBlur
│   ├── TraceGesture (fixed, z-index: gestureTrace)
│   ├── ScrollZone
│   └── div[eventNode]
│       ├── Content (z-index: content)
│       └── GestureContentBlur (z-index: gestureContentBlur)
└──

I think the re-parenting approach is a better fit than refactoring TraceGesture out of MultiGesture, but I'd like to hear your thoughts as well

@fbmcipher

Copy link
Copy Markdown
Collaborator

I also had a chat with Claude, and one concern about separating MultiGesture and TraceGesture is that we're splitting what is currently a cohesive component. Right now, MultiGesture is responsible for detecting the PanResponder, while TraceGesture is responsible for visualizing the gesture trace. If we move TraceGesture outside, we'd need to create eventNodeRef at the AppComponent level so both MultiGesture and TraceGesture can share the same ref.

I hear you here. There's something elegant in having the gesture handler (MultiGesture) and the visual (TraceGesture) co-located.

And after spending more time understanding the MultiGesture component, I'm starting to think it might make more sense to re-parent GestureMenu under MultiGesture, since GestureMenu is should be part of the overall gesture UX. It's responsible for presenting the set of actions users can perform with gestures, so conceptually it feels closely tied to the gesture system itself.

In principle this reparenting sounded like the right idea, and I nearly wrote a message agreeing to it. But look at the proposed hierarchy:

CleanShot 2026-07-04 at 03 33 12@2x

Each of the layers used to render the gesture menu is now in a different subtree. This makes the code hard to read and reason about and relate to the gesture menu visually. In this situation, sure we protect the coupling between MultiGesture and TraceGesture – but at the expense of the clarity of the gesture menu.

(AI's take, so take this with a pinch of salt: it also wouldn't actually fix the blur-height problem. The reparent only moves where the menu mounts — it leaves GestureContentBlur sitting in a different box from the command list. The blur can only size itself for free if it lives in the same box as the commands, so CSS hands it their height. Moving the mount point doesn't do that — so the blur still has nothing to inherit from, and still has to compute its height from the command count. Same formula, same drift. Which is exactly why I really don't want to keep manually calculating the blur height – refer to my comment above.)


Thanks for this discussion though – it's genuinely helping me pin down what we do want. The way I see it, our constraints are:

  • Don't manually calculate the blur's height — let the blur reflow naturally: it lives in the same box as the commands and takes their height via CSS (inset: 0), so there's no command-count formula and nothing to drift. Bonus: it's pure CSS — no ResizeObserver, no JS at all.
  • Colocate the gesture visuals – trace, blur and menu in one clear place. (This is your instinct, and I think it's right)
  • Keep the layer order flexible — remember I flagged wanting to easily re-layer the trace. From a software design perspective it is important (especially while we're testing a brand-new design for the first time) that we build our code with optionality so we can move the trace over/under later by changing basically one value, rather than a complete rearchitecture.

Both approaches below are really the same architecture: one gesture layer where trace / blur / glow / commands are flat siblings ordered by z-index tokens. That's what buys us both the reflow and the easy re-layering.

Unfortunately I think the only way to do this is to move TraceGesture out of the MultiGesture. I know you had objections with this approach – please do let me know once you've had a read.

1. Separate TraceGesture & MultiGesture, use ministore for the ref

Instead of a ref in AppComponent, MultiGesture publishes the eventNode into a properly-scoped gesture store, and TraceGesture reads it from there. It's genuinely not that unusual to use a store (or React.Context) to centralize a shared ref.

AppComponent
├─ MultiGesture                 (recognizer — unchanged)
│  └─ View
│     └─ div[eventNode]          → writes the node into gestureStore
│        └─ Content
│
└─ GestureLayer                  position: fixed  (one stacking context)
   ├─ blur           z-token     (reflows: inset:0 of the command box)
   ├─ glow / overlay  z-token
   ├─ TraceGesture    z-token     ← reads the node from gestureStore
   └─ commands        z-token

MultiGesture and GestureLayer are just siblings; the store is the (invisible) link.

Strengths

  • Matches the decentralized instinct — separate components, coordinated by a store.
  • Flattest tree — no wrapper; the two are siblings.
  • Fits em's grain — coordinating through ministores is the house pattern.
  • Sidesteps the AppComponent ref you didn't want.

Weaknesses

  • First store to hold a live DOM node in em — potentially defensible, but worth a comment explaining why.
  • The coupling is implicit — nothing in the tree shows the two are linked; you'd have to know the store connects them. Would probably want to document this as a comment too.

2. Common ancestor

A small, purpose-built coordinator owns the eventNode ref (a normal useRef) and hands it to both the recognizer and the gesture layer — not AppComponent, a dedicated coordinator whose whole job is exactly this.

AppComponent
└─ GestureCoordinator            renders a fragment · owns eventNodeRef = useRef()
   ├─ MultiGesture               (recognizer)          ← eventNodeRef
   │  └─ View
   │     └─ div  ref={eventNodeRef}
   │        └─ Content
   │
   └─ GestureLayer               position: fixed       ← eventNodeRef
      ├─ blur           z-token  (reflows: inset:0 of the command box)
      ├─ glow / overlay  z-token
      ├─ TraceGesture    z-token  ← reads eventNodeRef.current
      └─ commands        z-token

The coordinator owns the ref and hands it to both; it renders no DOM of its own, so it adds no wrapper element and no stacking context.

Strengths

  • Plain React ref — no store novelty, no DOM-in-a-store.
  • The coupling is explicit — the coordinator visibly wires the two together, so a future dev sees the relationship in the tree.
  • Textbook React ("lift the ref to the nearest common ancestor").

Weaknesses

  • One extra level of nesting — a touch less flat.
  • Slightly more centralized (both branches under one parent) — a hair against the decentralized instinct.

So, how do you feel about these two? I'm genuinely open here — if there's something I've missed, a cleaner third path, or a reason to lean a different way entirely, I'd really like to hear it. And feel free to run this past your agent too, so it can weigh my priorities against yours — I'd love to land somewhere that works well for both of us. 🙂

@cindmichelle

cindmichelle commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

@fbmcipher Apologies, I was a bit hallucinating when I created the proposed hierarchy. Now I understand why the previous hierarchy was hard to accept, since it was still separating the blur from the GestureMenu, which doesn't really solve the issue around the blur and the menu 🤦‍♀️

In the corrected proposed hierarchy, GestureContentBlur would replace the original ProgressiveBlur under GestureMenu, since they both serve the same purpose. So it should look like this:

image

With this proposed hierarchy, we no longer need to split the blur and GestureMenu, and we also don't need to refactor TraceGesture out of MultiGesture. We should still be able to render the trace above the blur as long as the z-index of TraceGesture > blur. This also keeps the layer ordering flexible—we'd only need to adjust the z-index values if we ever want to change it.

And thanks for the suggestions on how we could separate TraceGesture from MultiGesture without cluttering AppComponent ! That said, I'd still prefer to avoid refactoring TraceGesture out of MultiGesture if possible. Splitting the multi-gesture logic and its visual representation would make the debugging process for MultiGesture a bit harder for someone less familiar with the codebase. Especially since we're planning to move the trace to live under the menu, it could make that relationship harder to follow.

But I'd like to hear your thoughts on the corrected hierarchy proposal. Do you think it still makes sense? If not, I can go with the common ancestor approach instead.


By the way, regarding the blur height resizing, could you respond to my question from my earlier post? Asking this because if we decide not to make the blur height dynamic for the final design, I can remove that requirement from this PR implementation as well. The other issue mentions that we're planning to move away from the fixed full-height behavior.

@fbmcipher btw, after looking at #3801, I just realized that the GestureMenu background supposed to be full-screen height. However, I only able to find one design in Figma looks like this, could you confirm the Design 2 below is the final design?

image

@fbmcipher

Copy link
Copy Markdown
Collaborator

@cindmichelle Hey, no worries about the hallucination, it's easily done. Just be sure to rigorously test your agent's assumptions. I've noticed especially with issues of architecture/composition, agents can still really struggle – even high-powered ones like Opus/Fable.

LLMs are often falsely confident and we need to verify their results by asking them lots of questions, challenging their assumptions with our knowledge, and reasoning through its responses for ourselves.


GestureContentBlur would replace the original ProgressiveBlur under GestureMenu, since they both serve the same purpose.

To be clear, in your original post you said that GestureContentBlur uses ProgressiveBlur, so I'm guessing this is a wrapper for ProgressiveBlur that replaces the existing ProgressiveBlur, rather than a new blur implementation that replaces ProgressiveBlur entirely? Just want to be precise.


Of the two architectures, I honestly think either is fine as long as it meets our requirements. I was at first a little unsure about moving the entire GestureMenu into MultiGesture, but I see now that the ScrollZone is also already there, so agreed – it would be neat if all gesture-related UX was in one tree.

As long as we don't have to manually calculate the blur's height, and the layer order can be flexible, I'm happy with either.


Sorry, missed this in my last reply! We probably want to keep the Gesture Menu full height, since we changed the design in #3801.

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.

Position gesture trace above blur layer

2 participants