Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions decisions/2026-08-09-glass-theme-marks-surfaces-not-every-box.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# A glass theme marks surfaces, not every box with a background

- Date: 2026-08-09
- Status: accepted

## Context

`Mob.Renderer.inject_theme_flags/3` set `glass: true` on every `:box` that had a
`background:`. On iOS each of those becomes an independent `glassEffect`
surface, and each surface samples and blurs its own backdrop every frame. A
card gallery therefore rendered dozens of live sampling surfaces — visibly
chunky scrolling on an A15 under `MobThemes.ObsidianGlass`, and fine under every
non-glass theme.

"Has a background" was never the definition of a surface; it was the cheapest
thing to test for. Boxes carry backgrounds for lots of reasons that have nothing
to do with floating above content.

## Decision

The theme default now applies to a `:box` that

1. has a `background:`,
2. **has children**, and
3. is **not already inside a glass surface**.

(1) is unchanged — no fill, nothing to swap.

(2) Childless boxes are decoration: a status dot, a colour swatch, a bar, a
rule. They read as a solid shape at their size, so a backdrop sample buys
nothing visible and costs a pass.

(3) Glass inside glass samples glass. The inner surface blurs an
already-blurred backdrop, which costs a second pass and muddies the surface the
outer one just established. The outermost surface on a branch wins.

Nesting is tracked by threading `in_glass` through the recursive `prepare/4`
context, set from the node's *final* `glass` value — so a box made glassy by an
explicit prop suppresses its descendants too, and `glass: false` on a card hands
glass to the first box below it.

The rule deliberately does **not** key off corner radius or off which colour
token the background resolves to. Radius would silently strip glass from
square-edged sheets; keying off `:surface`-family tokens would undo
`2026-08-08-glass-tint-and-per-node-glass-opt-in`, whose point is that a
`background: :primary` chip must stay visibly primary *through* the glass.

`Map.put_new/3` is retained, so the escape hatch is untouched in both
directions: `glass: false` keeps a solid fill where translucency costs
legibility, and `glass: true` opts a box in where this rule says no (a childless
badge, a nested chip). Covered in `test/mob/renderer_test.exs`.

## Consequences

- **Visible change under existing glass themes.** Childless decorative boxes and
nested chips go solid. That is the intent — they were sampling backdrops to
look like solid shapes — but an app that liked a glassy nested chip has to ask
for it with `glass: true`.
- One existing test fixture (`a glassy Box still ships its background colour`)
was a childless box; it grew a child so it still describes a surface. Its
assertion — the flag must not displace `background` on the wire — is unchanged.
- `inject_theme_flags/3` becomes `/4` (it needs the node's children). Private.
- Android still ignores the flag entirely, so nothing changes there.
- The remaining surfaces batch into one render pass via the root
`GlassEffectContainer` (`2026-08-09-one-glass-effect-container-at-the-root`),
and only near-viewport ones are realized at all
(`2026-08-09-lazy-scroll-children`). The three compound; this one is the only
part testable on the host.
61 changes: 61 additions & 0 deletions decisions/2026-08-09-one-glass-effect-container-at-the-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# One `GlassEffectContainer`, at the root of the node tree

- Date: 2026-08-09
- Status: accepted

## Context

`MobRootView.swift` has a single `.glassEffect()` call site (`mobBoxBackground`)
and used none of iOS 26's batching APIs. Every glassy Box was therefore an
independent surface doing its own backdrop sample + blur every frame. On a
61-card gallery under `MobThemes.ObsidianGlass` that is dozens of samples per
frame on an A15 — visibly chunky scrolling under glass themes only.

The SDK surface (iPhoneOS26.4.sdk, `SwiftUICore.swiftinterface`) is:

GlassEffectContainer<Content>(spacing: CGFloat? = nil, content: () -> Content) // 9045
func glassEffectUnion(id: (some Hashable & Sendable)?, namespace: Namespace.ID) // 9880
func glassEffectID(_ id: (some Hashable & Sendable)?, in: Namespace.ID) // 17315

## Decision

**Exactly one container, wrapping the whole node tree**, applied in
`MobRootView.body` via `MobGlassBatch`.

Placement is the whole decision. Mob renders a recursive node tree, so the
tempting spot — next to the `glassEffect` call in `MobBox` — is the one that
cannot work: batching happens *within* a container, so a container per glass
node batches one surface each, and nesting containers re-splits the batch. The
container has to sit above every glass node that could share a pass, which in a
tree with no fixed depth means the root. Fix A (lazy `:scroll`) is what keeps
that batch small: only near-viewport cards are realized, so the root container
merges ~6 live surfaces, not 61.

`spacing: 0`, not the `nil` default: spacing is the distance at which sibling
glass shapes merge into one shape. We want the shared render pass, not the
morphing — a card grid must keep its card edges.

`glassEffectID` / `glassEffectUnion` are deliberately **not** used. They are for
morph transitions and for fusing shapes into one surface; both would change how
the theme looks, and neither is needed for the batching win.

The container is applied only when the current tree actually contains a glassy
node (`mobTreeHasGlass`, one walk per root push — the tree was just decoded from
JSON anyway). A container wrapping a tree with no glass in it is overhead
charged to every non-glass theme, i.e. every theme that scrolls fine today.

## Consequences

- iOS 26+ only; `#available` keeps iOS 17–25 on the untouched
`.ultraThinMaterial` path, which has no container to join.
- The container wraps the node view *outside* its `.frame(maxWidth:
.infinity, maxHeight: .infinity)`, so its single child already fills the
screen and the container's own layout cannot move anything.
- During a nav transition the outgoing and incoming trees are separate views and
so get a container each — transient, one extra pass for the duration of a
push/pop.
- Not verifiable from the host: that SwiftUI actually collapses the passes, and
the frame-time delta. Checked with `swiftc -typecheck` against
iPhoneOS26.4.sdk (including the `if active, #available(iOS 26.0, *)` form) and
`swiftlint`. Needs an on-device before/after — Instruments' Animation Hitches
/ SwiftUI template on the 61-card gallery under ObsidianGlass.
38 changes: 38 additions & 0 deletions ios/MobRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,39 @@
}
}

// ── Glass batching ───────────────────────────────────────────────────────────
// Each `.glassEffect()` samples and blurs whatever is behind it. SwiftUI runs
// that once per call site unless the surfaces share a GlassEffectContainer, in
// which case it renders them in a single pass — the difference between 1 and N
// backdrop samples per frame on a screen full of glassy cards.
//
// Batching happens *within* one container, so a container per glass node would
// achieve nothing and nesting containers re-splits the batch. There is exactly
// one, wrapping the whole node tree in MobRootView.
private struct MobGlassBatch: ViewModifier {
let active: Bool

func body(content: Content) -> some View {
if active, #available(iOS 26.0, *) {
// spacing: 0 — spacing is the distance at which sibling glass shapes
// merge into a single blob. We want the shared render pass, not the
// morphing; a card grid must keep its card edges.
GlassEffectContainer(spacing: 0) { content }
} else {
// Also the pre-26 path: `.ultraThinMaterial` has no container to
// join, so the fallback in mobBoxBackground is untouched.
content
}
}
}

// Walked once per root push rather than wrapping unconditionally: a container
// around a tree with no glass in it is overhead charged to every non-glass
// theme — i.e. every theme that scrolls fine today.
private func mobTreeHasGlass(_ node: MobNode) -> Bool {
node.useGlass || node.childNodes.contains(where: mobTreeHasGlass)
}

private func boxAlignmentFromString(_ s: String) -> Alignment {
switch s {
case "center": return .center
Expand Down Expand Up @@ -977,7 +1010,7 @@
// no manual frame management required.
private class CameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var cameraLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }

Check warning on line 1013 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions / Native formatters (clang-format + swiftlint)

Force casts should be avoided (force_cast)
}

private struct MobCameraPreviewView: UIViewRepresentable {
Expand Down Expand Up @@ -1348,6 +1381,7 @@
// SwiftUI observation, which doesn't carry the animation context and
// produces a default crossfade instead of the .move transition).
@State private var currentNavVersion: Int = 0
@State private var rootHasGlass: Bool = false

public init() {}

Expand All @@ -1362,6 +1396,9 @@
// .onChange(of: model.rootVersion) below.
.id(currentNavVersion)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// Outside the frame so the container's own layout can't
// move anything: its single child already fills the screen.
.modifier(MobGlassBatch(active: rootHasGlass))
.transition(navTransition(currentTransition))
} else {
ZStack {
Expand Down Expand Up @@ -1402,6 +1439,7 @@
// Capture transition before the animation block so the modifier
// sees the right value when the new view is inserted.
currentTransition = t
rootHasGlass = newRoot.map(mobTreeHasGlass) ?? false
// Log every nav transition so log-tail-based checks can verify
// the animation fired without resorting to video recording.
// Format: [MobNav] transition=<push|pop|reset|none> navVersion=<n>
Expand Down
31 changes: 22 additions & 9 deletions lib/mob/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ defmodule Mob.Renderer do
radii: Theme.radius_map(theme),
type_scale: theme.type_scale,
flags: Theme.flags_map(theme),
platform: platform
platform: platform,
in_glass: false
}

nif.clear_taps()
Expand Down Expand Up @@ -256,12 +257,13 @@ defmodule Mob.Renderer do
defp prepare(%{type: type, props: props, children: children}, nif, platform, ctx) do
defaults = Map.get(@component_defaults, type, %{})
with_defaults = Map.merge(defaults, props)
with_theme_flags = inject_theme_flags(type, with_defaults, ctx)
with_theme_flags = inject_theme_flags(type, with_defaults, children, ctx)
child_ctx = %{ctx | in_glass: ctx.in_glass or with_theme_flags[:glass] == true}

%{
"type" => Atom.to_string(type),
"props" => prepare_props(with_theme_flags, nif, platform, ctx),
"children" => Enum.map(children, &prepare(&1, nif, platform, ctx))
"children" => Enum.map(children, &prepare(&1, nif, platform, child_ctx))
}
end

Expand All @@ -271,19 +273,30 @@ defmodule Mob.Renderer do
# today; Android receives it but ignores it (Material 3 doesn't have a
# first-class glassy surface yet).
#
# A node is "surface-style" if it has a `background:` set — that's what
# the user perceives as a card / sheet. Other nodes (text, scroll, etc.)
# pass through untouched.
# A node is "surface-style" if it's a box that has a `background:` AND holds
# content AND isn't already inside a glass surface. Every glass surface costs
# its own backdrop sample per frame on iOS, so the rule has to be narrower
# than "has a background" — that marked every box in the tree, and a screen
# of cards became dozens of live sampling surfaces.
#
# Childless boxes are decoration — a dot, a swatch, a colour bar, a rule.
# They read as a solid shape, so a backdrop sample buys nothing.
#
# Boxes nested in a glass surface sample glass, not content: the second blur
# adds cost and mud where the first already established the depth. The
# outermost surface in a branch wins; `glass: false` on it hands glass to the
# layer below (that box becomes the first glass surface on its path).
#
# `put_new`, not `put`: the theme supplies a *default*, so an explicit
# `glass:` on the node wins. That's the escape hatch a glass theme needs —
# `glass: false` keeps a solid fill on the one box (a selected row, a
# warning banner) where translucency would cost legibility.
defp inject_theme_flags(:box, props, %{flags: %{glass: true}}) do
# warning banner) where translucency would cost legibility, and `glass: true`
# opts a box back in when the rule above says no.
defp inject_theme_flags(:box, props, [_ | _], %{flags: %{glass: true}, in_glass: false}) do
if Map.has_key?(props, :background), do: Map.put_new(props, :glass, true), else: props
end

defp inject_theme_flags(_type, props, _ctx), do: props
defp inject_theme_flags(_type, props, _children, _ctx), do: props

defp prepare_props(props, nif, platform, ctx) do
# 1. Merge any %Mob.Style{} under the :style key (inline props win)
Expand Down
7 changes: 5 additions & 2 deletions lib/mob/theme.ex
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,11 @@ defmodule Mob.Theme do
radius_pill: 100,

# ── Material / effect flags ────────────────────────────────────────────
# When true, surface-style nodes (currently `Box` with a `background:` set)
# render with a translucent material instead of a solid fill:
# When true, surface-style nodes render with a translucent material instead
# of a solid fill. A node qualifies if it's a `Box` that has a `background:`,
# holds content, and isn't already inside a glass surface — each glass
# surface costs a backdrop sample per frame, so decoration (childless boxes)
# and glass-on-glass are excluded. Rendering:
#
# * iOS 26+: Liquid Glass via `.glassEffect()`
# * iOS 17–25: graceful fallback to `.ultraThinMaterial` background
Expand Down
Loading
Loading