# Liquid Glass surfaces

The Action view (`src/design/actioning-screen.tsx`) has a **Liquid Glass header
and footer** (`expo-glass-effect`, iOS 26). The glass is **scroll-driven**: each
bar is fully transparent at the scroll boundary where nothing sits behind it
(header at the top, footer at the bottom), and fades in as cards slide underneath.
The goal is a soft transition with no hard cut-off, while keeping the content
behind the bars partly visible.

## Layout

For content to scroll *behind* the bars, the list is a full-height scroll view and
the bars are **absolutely-positioned overlays** (`headerBar` / `footerBar`), not
siblings sandwiching it:

- Each bar's height is measured with `onLayout` and fed back as the scroll view's
  `contentContainerStyle` `paddingTop` / `paddingBottom`, so the first and last
  card sit clear of the glass at rest. The heights are seeded with estimates to
  avoid a first-frame jump.
- The bar container is transparent; the glass (or fallback) is a layer *behind* the
  always-opaque content, so the wordmark, badges, and action buttons never dim —
  only the glass fades.
- `GlassBar` falls back to a solid background-coloured bar where Liquid Glass isn't
  available (`isLiquidGlassAvailable()`), keeping the layout and measured insets
  identical.

## The opacity-animation workaround

**You cannot fade a `GlassView`'s opacity directly** — setting `opacity` to 0 on a
`GlassView` (or any parent) stops the effect from rendering at all. The documented
workaround (see the [expo-glass-effect docs](https://docs.expo.dev/versions/latest/sdk/glass-effect/#opacity-animation-workaround))
is:

1. Animate a **wrapper view's** opacity (not the GlassView's).
2. Toggle the GlassView's `glassEffectStyle` to `'none'` below a small threshold
   (so it's genuinely off when fully faded out, where the opacity-0 rule would
   otherwise apply).

We drive both from a single Reanimated derived value per bar (`useAnimatedStyle`
for the wrapper opacity, `useAnimatedProps` for `glassEffectStyle`), using
`Animated.createAnimatedComponent(GlassView)`.

## Scroll → opacity

A `useAnimatedScrollHandler` tracks `scrollY`; `onContentSizeChange` / `onLayout`
track content and viewport height (needed for the footer). Two derived values:

- **Header** keys off scroll offset — `0` at the top, ramping to fully on.
- **Footer** keys off distance-from-bottom (`contentHeight − viewportHeight −
  scrollY`) — `0` at the bottom, on otherwise. Short lists (content < viewport)
  resolve to `0`, so both bars stay transparent.

`GLASS_RAMP` (px) is the single tuning knob: how far content slides behind a bar
before its glass is fully on. Lower = snappier; higher = a softer fade.

## Light vs dark

The `regular` glass material shifts the colour of what's behind it — brightening
the dark theme into a distracting band, and cooling the warm cream of the light
one. Both bars are therefore given a `tintColor` of the current theme background
(`palette.background` at ~55% alpha, e.g. `#15121C8C` / `#FFF6E98C`), so the frost
reads as a quiet, background-coloured pane in either mode. The tint's alpha is the
dial if the glass ever looks too heavy or too sheer.

## React Compiler gotcha — use `.set()`, not `.value =`, in JS callbacks

This project has the **React Compiler** on. Writing `sharedValue.value = x` inside
a plain JS callback (e.g. `onLayout`, `onContentSizeChange`, an effect) trips the
`react-hooks/immutability` lint rule. Use Reanimated 4's **`sharedValue.set(x)`**
method there instead. Inside worklets (the scroll handler, derived values),
`.value` reads/writes are fine.
