diff --git a/decisions/2026-08-08-glass-tint-and-per-node-glass-opt-in.md b/decisions/2026-08-08-glass-tint-and-per-node-glass-opt-in.md new file mode 100644 index 0000000..6f3a8fd --- /dev/null +++ b/decisions/2026-08-08-glass-tint-and-per-node-glass-opt-in.md @@ -0,0 +1,62 @@ +# Glass carries the node's background colour; `glass:` is a per-node override + +- Date: 2026-08-08 +- Status: accepted + +## Context + +`mobBoxBackground(node:)` had two branches: `useGlass` → `glassEffect(.clear, …)`, +otherwise → `background(colour, …)`. The glass branch never read +`node.backgroundColor`. Under any theme with `glass: true` (e.g. +`MobThemes.ObsidianGlass`) that discarded *every* Box background on iOS 26+. +Verified on an iPhone (iOS 26.5.2) and an iPhone 17 simulator: a +`background: :primary` chip rendered identically to a `:surface_raised` one, so +selected/active state disappeared, and semantic fills (warning, accent, avatar +variants) all collapsed to the same grey. Android ignores `glass:` and keeps its +solid fill, so the same app was legible there and not on iOS. + +Two separate mechanisms for glass had also accumulated. Master has a +theme-driven `BOOL useGlass` set by `Mob.Renderer.inject_theme_flags/3`. A stale +branch (`material-liquid-glass`, May 2026) added an independent per-node +`material: :glass` NSString on `MobNode` plus a `MobMaterialModifier`. + +## Decision + +1. Tint the glass with the node's background: `glassEffect(.clear.tint(fill), …)`. + `Glass.tint/1` takes an `Optional`, so a box with no resolvable + background still gets plain clear glass — one expression, no extra branch. + The pre-iOS-26 `.ultraThinMaterial` path puts the fill *behind* the material + so it reads as frosted colour rather than being painted over. + `Glass.clear` is kept as the base (the existing deliberate aesthetic); + `.regular` is the single-token knob if a tint reads too weakly. + +2. `inject_theme_flags/3` uses `Map.put_new/3` instead of `Map.put/3`. The theme + flag is a *default*; an explicit `glass:` prop on a Box wins in either + direction. `glass: false` is the escape hatch a glass theme needs for the one + surface where translucency costs legibility; `glass: true` opts a single Box + in without a glass theme. No new native surface — `mob_nif.m` already decodes + the `glass` prop into `useGlass`, and props are not whitelisted, so the + per-node value has always reached the device. Only the theme's unconditional + `put` prevented the override. + +3. `material-liquid-glass` is superseded, not merged. Its per-node opt-in is + delivered by (2) with zero added surface area, and its implementation had + since diverged from master: it branches from before `mobBoxBackground` + existed, calls `.glassEffect()` with no `in: shape` (so glass would ignore + `corner_radius`), applies glass *over* an already-painted solid background, + and uses `.regular` / `.regularMaterial` where master uses `.clear` / + `.ultraThinMaterial` — two conflicting glass aesthetics in one file. It also + introduces a second, stringly-typed vocabulary (`material:`) alongside the + existing typed flag, and its Android counterpart never landed. + +## Consequences + +- `Glass.tint/1` requires the iOS 26 SDK. That does not change the build floor: + `glassEffect` already did (issue #55 — `glassEffect` fails to compile under + Xcode 16.2, since `#available` gates runtime, not the SDK). Worth a follow-up: + a `#if compiler(>=6.2)` guard so older Xcode compiles instead of erroring. +- Swift rendering is not host-testable. The Elixir side is covered in + `test/mob/renderer_test.exs`: the glass flag must not displace `background` + on the wire, and `glass:` must override the theme in both directions. + The tint itself needs device/simulator verification. +- `MobNode.material` is not added; nothing in `mob` or `mob_new` references it. diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index 7612d08..d9b2d45 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -250,7 +250,18 @@ struct MobNodeView: View { // Flex Spacers inside then have nothing to expand into and // centering tricks (spacer / content / spacer) collapse. // Honors `fill_width: true` to match Android's row behaviour. - .ifLet(node.fillWidth ? () : nil) { view, _ in view.frame(maxWidth: .infinity) } + // + // `alignment: .leading` is load-bearing: .frame defaults to + // .center, so a fill_width row whose children don't span the + // full width floated to the middle on iOS while Compose's + // Row (horizontalArrangement = Start) left-aligned it. Every + // row-based component drifted — headers, checkbox and radio + // rows each centred independently and read as ragged. The + // column case above already passes .topLeading for the same + // reason. + .ifLet(node.fillWidth ? () : nil) { view, _ in + view.frame(maxWidth: .infinity, alignment: .leading) + } .padding(node.paddingEdgeInsets) .background(node.backgroundColor.map { Color($0) } ?? Color.clear) .ifLet(node.onTap) { view, tap in @@ -580,25 +591,38 @@ private extension View { radius > 0 ? AnyShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) : AnyShape(Rectangle()) + let fill = node.backgroundColor.map { Color($0) } if node.useGlass { - // Liquid Glass on iOS 26+; otherwise the closest visual approximation - // that ships in older system SDKs. + // The glass surface must still carry the node's `background:`. The BEAM + // only sets `useGlass` on boxes that asked for a background, so dropping + // the colour here made every glassy box collapse to the same neutral + // grey: selected/active chips became indistinguishable from unselected + // siblings, and semantic fills (warning, accent, avatar variants) all + // rendered identically. Android ignores `glass:` and keeps its solid + // fill, so the same app stayed legible there and not here. // // `Glass.clear` (vs `Glass.regular`) — the surface is noticeably // more transparent; what's behind shows through. Card-style // surfaces look "floating" rather than "frosted". Switch to // `.regular` if a tinted, opaque-leaning glass is wanted. if #available(iOS 26.0, *) { - self.glassEffect(.clear, in: shape) + // `Glass.tint` takes an Optional, so a box whose background failed + // to resolve still gets plain untinted clear glass. + self.glassEffect(.clear.tint(fill), in: shape) } else { + // Pre-26 approximation. A material blurs whatever is behind it, so + // the fill goes *behind* the material (a later `.background` sits + // further back) and reads as frosted colour rather than being + // painted over by the grey. self.background(.ultraThinMaterial, in: shape) + .background(fill ?? Color.clear, in: shape) } } else { // `in: shape` so the solid fill is clipped to the corner radius — without // it the fill is a plain rectangle and only the (separately-stroked) // border looks rounded, leaving square fill corners on non-glass boxes. - self.background(node.backgroundColor.map { Color($0) } ?? Color.clear, in: shape) + self.background(fill ?? Color.clear, in: shape) } } } diff --git a/lib/mob/renderer.ex b/lib/mob/renderer.ex index 3caf057..2ba04cc 100644 --- a/lib/mob/renderer.ex +++ b/lib/mob/renderer.ex @@ -274,8 +274,13 @@ defmodule Mob.Renderer do # 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. + # + # `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 - if Map.has_key?(props, :background), do: Map.put(props, :glass, true), else: props + 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 diff --git a/lib/mob/theme.ex b/lib/mob/theme.ex index ed681a3..3fd9f8e 100644 --- a/lib/mob/theme.ex +++ b/lib/mob/theme.ex @@ -119,6 +119,10 @@ defmodule Mob.Theme do # # Off by default; opt in via a preset (`MobThemes.ObsidianGlass`, the mob_themes package) or by # passing `glass: true` to `Mob.Theme.build/1`. + # + # This is a per-theme *default*. A `glass:` prop on an individual Box wins + # over it in either direction — `glass: false` keeps a solid fill under a + # glass theme, `glass: true` opts one Box in without one. glass: false ] diff --git a/test/mob/renderer_test.exs b/test/mob/renderer_test.exs index fd8d26a..bf6e6ec 100644 --- a/test/mob/renderer_test.exs +++ b/test/mob/renderer_test.exs @@ -1100,5 +1100,48 @@ defmodule Mob.RendererTest do tree = set_root_json() assert tree["props"]["glass"] == true end + + test "a glassy Box still ships its background colour" do + # The iOS side tints the glass with this colour. If the flag ever + # displaced the fill on the wire, every glassy box would render as the + # same untinted grey and selected/semantic states would be invisible. + Mob.Theme.set(glass: true) + + Renderer.render( + %{type: :box, props: %{background: 0xFF112233}, children: []}, + :ios, + MockNIF + ) + + tree = set_root_json() + assert tree["props"]["glass"] == true + assert tree["props"]["background"] == 0xFF112233 + end + + test "explicit glass: false on a Box survives a glass theme" do + Mob.Theme.set(glass: true) + + Renderer.render( + %{type: :box, props: %{background: :surface, glass: false}, children: []}, + :ios, + MockNIF + ) + + tree = set_root_json() + assert tree["props"]["glass"] == false + end + + test "explicit glass: true opts one Box in without a glass theme" do + Mob.Theme.set(%Mob.Theme{}) + + Renderer.render( + %{type: :box, props: %{background: :surface, glass: true}, children: []}, + :ios, + MockNIF + ) + + tree = set_root_json() + assert tree["props"]["glass"] == true + end end end