From 13a1660501b138acf5a3246a3e8298f0bb11be62 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 9 Aug 2026 20:07:21 -0600 Subject: [PATCH 1/2] iOS: batch glass surfaces in a single GlassEffectContainer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `.glassEffect()` samples and blurs its backdrop; SwiftUI runs that once per call site unless the surfaces share a GlassEffectContainer, which renders them in one pass. Mob had no container at all, so a glass theme cost one backdrop sample per glassy Box per frame — dozens on a card gallery, which is what makes scrolling chunky under ObsidianGlass and no other theme. Placement is the decision: batching happens within a container, so a container per glass node would batch one surface each and nesting would re-split the batch. There is exactly one, wrapping the whole node tree in MobRootView. `spacing: 0` because spacing is the distance at which sibling glass shapes merge into one shape — we want the shared pass, not the morphing. `glassEffectID`/`glassEffectUnion` are not used; both change how the theme looks and neither is needed for the batching. The container is applied only when the tree actually contains a glassy node, so non-glass themes pay nothing. iOS 17-25 keeps the untouched `.ultraThinMaterial` path. Rationale in decisions/2026-08-09-one-glass-effect-container-at-the-root.md. API signatures read from iPhoneOS26.4.sdk SwiftUICore.swiftinterface; verified with `swiftc -typecheck` and swiftlint. Whether the passes actually collapse needs a device. Co-Authored-By: Claude Opus 5 --- ...-one-glass-effect-container-at-the-root.md | 61 +++++++++++++++++++ ios/MobRootView.swift | 38 ++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 decisions/2026-08-09-one-glass-effect-container-at-the-root.md diff --git a/decisions/2026-08-09-one-glass-effect-container-at-the-root.md b/decisions/2026-08-09-one-glass-effect-container-at-the-root.md new file mode 100644 index 0000000..9d7d1c6 --- /dev/null +++ b/decisions/2026-08-09-one-glass-effect-container-at-the-root.md @@ -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(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. diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index d9b2d45..af0b3c5 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -627,6 +627,39 @@ private extension View { } } +// ── 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 @@ -1348,6 +1381,7 @@ public struct MobRootView: View { // 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() {} @@ -1362,6 +1396,9 @@ public struct MobRootView: View { // .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 { @@ -1402,6 +1439,7 @@ public struct MobRootView: View { // 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= navVersion= From d02c21d4972696cdd2813fe0446dd5a700667fbf Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 9 Aug 2026 20:10:47 -0600 Subject: [PATCH 2/2] Glass themes mark surfaces, not every box with a background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inject_theme_flags` set `glass: true` on every Box with a `background:`. Each one is an independent backdrop-sampling surface on iOS, so a card gallery rendered dozens of them per frame — the reason scrolling is chunky under ObsidianGlass and fine under every other theme. The theme default now needs a Box to have a background, to have children, and to not already sit inside a glass surface. Childless boxes are decoration (a dot, a swatch, a bar) that reads as a solid shape, so the sample buys nothing; glass inside glass blurs an already-blurred backdrop, costing a second pass and muddying the surface the outer one established. Nesting is tracked by threading `in_glass` through `prepare/4`, keyed on the node's final `glass` value so an explicitly-glassy box suppresses its descendants too and `glass: false` on a card hands glass to the box below it. `Map.put_new/3` is retained: `glass: false` still keeps a solid fill, and `glass: true` still opts a box in where this rule says no. Both directions, plus the nesting and childless rules, are covered in renderer_test.exs. One existing fixture was a childless box and grew a child so it still describes a surface; its assertion (the flag must not displace `background`) is unchanged. Rules and the rejected alternatives (corner radius, surface-token families) in decisions/2026-08-09-glass-theme-marks-surfaces-not-every-box.md. Co-Authored-By: Claude Opus 5 --- ...lass-theme-marks-surfaces-not-every-box.md | 68 ++++++++ lib/mob/renderer.ex | 31 +++- lib/mob/theme.ex | 7 +- test/mob/renderer_test.exs | 160 +++++++++++++++++- 4 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 decisions/2026-08-09-glass-theme-marks-surfaces-not-every-box.md diff --git a/decisions/2026-08-09-glass-theme-marks-surfaces-not-every-box.md b/decisions/2026-08-09-glass-theme-marks-surfaces-not-every-box.md new file mode 100644 index 0000000..0ab1f9c --- /dev/null +++ b/decisions/2026-08-09-glass-theme-marks-surfaces-not-every-box.md @@ -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. diff --git a/lib/mob/renderer.ex b/lib/mob/renderer.ex index 2ba04cc..9fd7d86 100644 --- a/lib/mob/renderer.ex +++ b/lib/mob/renderer.ex @@ -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() @@ -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 @@ -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) diff --git a/lib/mob/theme.ex b/lib/mob/theme.ex index 3fd9f8e..0b194b9 100644 --- a/lib/mob/theme.ex +++ b/lib/mob/theme.ex @@ -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 diff --git a/test/mob/renderer_test.exs b/test/mob/renderer_test.exs index bf6e6ec..a247ade 100644 --- a/test/mob/renderer_test.exs +++ b/test/mob/renderer_test.exs @@ -1108,7 +1108,11 @@ defmodule Mob.RendererTest do Mob.Theme.set(glass: true) Renderer.render( - %{type: :box, props: %{background: 0xFF112233}, children: []}, + %{ + type: :box, + props: %{background: 0xFF112233}, + children: [%{type: :text, props: %{text: "card"}, children: []}] + }, :ios, MockNIF ) @@ -1143,5 +1147,159 @@ defmodule Mob.RendererTest do tree = set_root_json() assert tree["props"]["glass"] == true end + + # Each glassy Box is an independent backdrop-sampling surface on iOS, so + # the theme default is deliberately narrower than "every box with a + # background": content-bearing, outermost-in-its-branch surfaces only. + test "a childless Box is decoration, not a surface — keeps its solid fill" do + Mob.Theme.set(glass: true) + + Renderer.render( + %{type: :box, props: %{background: :primary, corner_radius: 4}, children: []}, + :ios, + MockNIF + ) + + tree = set_root_json() + refute Map.has_key?(tree["props"], "glass") + assert tree["props"]["background"] == 0xFF2196F3 + end + + test "a Box nested in a glassy Box does not stack a second glass surface" do + Mob.Theme.set(glass: true) + + Renderer.render( + %{ + type: :box, + props: %{background: :surface}, + children: [ + %{ + type: :box, + props: %{background: :primary}, + children: [%{type: :text, props: %{text: "chip"}, children: []}] + } + ] + }, + :ios, + MockNIF + ) + + tree = set_root_json() + assert tree["props"]["glass"] == true + [chip] = tree["children"] + refute Map.has_key?(chip["props"], "glass") + assert chip["props"]["background"] == 0xFF2196F3 + end + + test "nesting is tracked through non-box wrappers" do + Mob.Theme.set(glass: true) + + Renderer.render( + %{ + type: :box, + props: %{background: :surface}, + children: [ + %{ + type: :column, + props: %{}, + children: [ + %{ + type: :box, + props: %{background: :surface}, + children: [%{type: :text, props: %{text: "row"}, children: []}] + } + ] + } + ] + }, + :ios, + MockNIF + ) + + tree = set_root_json() + assert tree["props"]["glass"] == true + [column] = tree["children"] + [inner] = column["children"] + refute Map.has_key?(inner["props"], "glass") + end + + test "sibling surfaces each stay glassy — the rule is nesting, not a quota" do + Mob.Theme.set(glass: true) + + Renderer.render( + %{ + type: :column, + props: %{}, + children: [box_with_background(), box_with_background()] + }, + :ios, + MockNIF + ) + + tree = set_root_json() + assert Enum.map(tree["children"], & &1["props"]["glass"]) == [true, true] + end + + test "glass: false on a card hands glass to the box below it" do + Mob.Theme.set(glass: true) + + Renderer.render( + %{ + type: :box, + props: %{background: :surface, glass: false}, + children: [box_with_background()] + }, + :ios, + MockNIF + ) + + tree = set_root_json() + assert tree["props"]["glass"] == false + [inner] = tree["children"] + assert inner["props"]["glass"] == true + end + + test "explicit glass: true wins where the surface rule says no" do + Mob.Theme.set(glass: true) + + Renderer.render( + %{ + type: :box, + props: %{background: :surface}, + children: [ + %{type: :box, props: %{background: :primary, glass: true}, children: []} + ] + }, + :ios, + MockNIF + ) + + tree = set_root_json() + assert tree["props"]["glass"] == true + # childless AND nested — excluded twice over, and still opted in + [dot] = tree["children"] + assert dot["props"]["glass"] == true + end + + test "a Box glassy by prop rather than by rule still suppresses glass below it" do + Mob.Theme.set(glass: true) + + # No background, so the theme rule would never have marked this one — the + # glass comes from the prop, and the nesting check has to see it anyway. + Renderer.render( + %{ + type: :box, + props: %{glass: true}, + children: [box_with_background()] + }, + :ios, + MockNIF + ) + + tree = set_root_json() + assert tree["props"]["glass"] == true + [inner] = tree["children"] + refute Map.has_key?(inner["props"], "glass") + end end end