Skip to content
Open
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
62 changes: 62 additions & 0 deletions decisions/2026-08-08-glass-tint-and-per-node-glass-opt-in.md
Original file line number Diff line number Diff line change
@@ -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<Color>`, 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.
34 changes: 29 additions & 5 deletions ios/MobRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,18 @@
// 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
Expand Down Expand Up @@ -580,25 +591,38 @@
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)
}
}
}
Expand Down Expand Up @@ -953,7 +977,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 980 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
7 changes: 6 additions & 1 deletion lib/mob/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/mob/theme.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
]

Expand Down
43 changes: 43 additions & 0 deletions test/mob/renderer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading