Skip to content

Responsive breakpoints: md:/lg:/xl: classes resolved against the live window width - #467

Merged
simonhamp merged 6 commits into
mainfrom
feat/responsive-breakpoints
Sep 19, 2026
Merged

simonhamp merged 6 commits into
mainfrom
feat/responsive-breakpoints

Conversation

@shanerbaner82

@shanerbaner82 shanerbaner82 commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Why

EDGE screens had no way to adapt to window width. On an iPad, and on Android tablets and foldables, a screen laid out for a phone stayed a phone layout, and "responsive" meant branching in PHP, which can't see the window and misses rotation and Split View.

What

Breakpoint prefixes

Tailwind's mobile-first prefixes, plus Material's window size classes:

Prefix From
sm: 640
medium: 600
md: 768
expanded: 840
lg: 1024
xl: 1280
2xl: 1536
{{-- Stacked on a phone, side by side on a tablet --}}
<native:column class="w-full gap-6 p-4 md:flex-row md:gap-8 lg:p-8">
    <native:column class="md:flex-1">...</native:column>
    <native:column class="md:flex-1">...</native:column>
</native:column>

<native:column class="hidden md:flex w-[280]">...</native:column>

<native:lazy-grid class="grid-cols-2 md:grid-cols-3 lg:grid-cols-4">...</native:lazy-grid>
  • Resolved on the device, against the window. PHP can't know the window width, and on iPad it changes under a running screen. Each node's breakpoints ship as one _variants JSON prop (the packed node struct is fixed, so props are the only extensible slot). NodeView on both platforms picks the widest breakpoint that fits the live window width, so a screen re-flows on rotation and Split View with no PHP round trip. Nodes without breakpoints pay one branch.
  • Each breakpoint builds on everything narrower. An entry is the difference between two complete builds of the node (everything narrower, then that plus this breakpoint's classes), made through a throwaway element of the node's type. So p-4 md:px-8 keeps its vertical padding, rounded-xl md:rounded-t-3xl keeps its bottom corners, and element props (tracking, leading, font family, object fit, grid columns) behave exactly as they do unprefixed. The throwaway build uses its own callback registry, so screen callback ids don't shift.
  • Combines with the other variants in either order: md:dark:, dark:md:, ios:md:.
  • Configurable via a new nativephp.breakpoints table, which also allows custom names ('tablet' => 700).
  • Min-width only. max-*: and min-[…]: are not supported.

Also in this PR

  • hidden, plus flex / inline-flex / block / inline-block / grid to show an element again, for the hidden md:flex pattern.
  • The hidden attribute now works. <native:column hidden> was documented but never reached the wire.
  • grid-cols-N sets a lazy grid's column count. An explicit :columns still wins.
  • text-7xl to text-9xl.

⚠️ iOS FlexContainer: re-measure after a resize (affects every app)

Worth a close look, because it changes behaviour outside breakpoints. Rotating or resizing the window on iOS left text wrapped at the old width until the next launch.

FlexContainer's sizeCache outlives a layout pass (a resize doesn't trigger updateCache), and a cache hit leaves each child's idealSize at whatever the last real measurement set. After a resize, a row measured .unspecified hits the cache, and placeSubviews lays its text out at the shrunk width from before. NativeTreeRenderer now bumps a static measurementGeneration when the window width changes, and each container empties its memo on its next call. This means one extra measurement pass per resize and nothing otherwise.

This bug is on main already. Breakpoints just make resizing common. Android doesn't have it.

Testing

  • pint, phpstan, and the full Pest suite: 1018 passing. tests/Unit/Edge/ResponsiveVariantsTest.php covers parsing, the wire format, building on the base, prefix order, display, element props, callback isolation and the hidden attribute.
  • iPad Pro 13" sim (iOS 27), on a real app's marketing page: portrait at 1032pt (lg) and landscape at 1376pt (xl, including xl:text-7xl), rotated portrait → landscape → portrait with no relaunch. The round trip matches a fresh launch in each orientation, which is how the FlexContainer fix was confirmed.
  • Android tablet emulator (960×600dp), same page: portrait at 600dp (below sm) and landscape at 960dp (sm and md), rotated both ways in one process.
  • Tested on device by eye: breakpoint layout, padding, font size and flex direction. Covered by unit tests only: hidden md:flex, per-corner radius, border width and grid-cols behind a breakpoint.

Notes

🤖 Generated with Claude Code

shanerbaner82 and others added 4 commits September 16, 2026 20:48
sm:/md:/lg:/xl:/2xl: (plus medium:/expanded: for the Apple and Material
window size classes) parse like Tailwind, mobile-first, and compose with
ios:/android:/dark: in either order. PHP can't resolve them — it doesn't
know the window width, and on iPad it changes under a running screen — so
each breakpoint's classes ship as a layout/style/prop delta in one
_variants JSON prop (the packed node struct is fixed; props are the only
extensible slot). The native NodeView on both platforms folds the deltas
over the base at decode time and picks the widest fit against the live
window width, resolving one level of children too so containers see the
same layout their child will render with. Re-flows on rotation and Split
View with no PHP round-trip; nodes without variants pay one branch.

grid-cols-N drives the lazy grid's column count from a class so
md:grid-cols-2 lg:grid-cols-3 re-tracks it per breakpoint. Thresholds are
configurable via nativephp.breakpoints.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNpmttr74cEGMszwgHfR61
A breakpoint's classes were built into layout / style / props on their
own, so any value the builders assemble from sibling keys came out wrong:
`p-4 md:px-8` shipped [0, 32, 0, 32] and lost the vertical padding,
`rounded-xl md:rounded-t-3xl` squared the bottom corners, and
`md:border-2` vanished without a colour. Each entry is now the difference
between two complete builds of the node (everything narrower, then that
plus this breakpoint) through a throwaway element of the node's type, so
a prefixed class reaches the wire exactly as the unprefixed one does.
That also carries the element props the old hand-kept map dropped:
tracking, leading, text transform, italic, underline, font family,
object fit.

`dark:md:` now parses to the same shape as `md:dark:` instead of burying
the breakpoint under `dark`, where nothing read it. `hidden` and the
display utilities map to display, so `hidden md:flex` works, and
`text-7xl` to `text-9xl` join the font scale.

On iOS, rotating or resizing the window left text wrapped at the old
width. FlexContainer's size memo outlives a layout pass (a resize never
triggers updateCache), and a hit leaves `idealSize` at whatever the last
real measurement set, so a row measured `.unspecified` after a resize hit
the cache and was placed at its old shrunk width. The root renderer now
bumps a measurement generation when the window width changes and the
memo empties on the next call. This predates breakpoints; they just make
resizing matter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`<native:column hidden>` is documented under Visibility but never reached
the wire — nothing read the attribute. It now sets display none in both
layout paths, winning over any display class the way the HTML attribute
does, so `:hidden="$collapsed"` works alongside `hidden md:flex`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apple's size classes are compact and regular; medium and expanded are
Material's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: efcd5b8f-18bd-4d29-b62e-789be5243587

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@simonhamp

Copy link
Copy Markdown
Member

We tested the FlexContainer counter on its own on an iPad Pro 13" sim: main, this branch, and this branch with only the counter removed. After rotating, every screen matched a fresh launch pixel for pixel in all three builds, with and without breakpoints. We couldn't reproduce the bug on main and found no value in the counter.

simonhamp and others added 2 commits September 19, 2026 15:19
A child with xl:hidden inside a row showed up on iOS as an empty bar
in the hidden child's background colour, covering its visible sibling.
Plain `hidden` did the same with no breakpoint involved, so this is not
a variant resolution problem. FlexContainer already reads the resolved
layout and rebuilds its cache when the width crosses a breakpoint.

The cause is how display none was drawn. FlexContainer leaves a hidden
child out of flow and never places it, so SwiftUI centres it at the
container's size. The only thing hiding it was an opacity(0) inside
NodeLayoutModifier. NodeStyleModifier adds the background and border
after that, outside the opacity, so they stayed visible and painted
over the siblings. The opacity now sits at the end of ResolvedNodeView
so it covers the style layer too.

Android skipped hidden flow children already, so the row case never
happened there. Hidden absolute children were still composed, and
their background showed at their fixed size because nodeLayout's
size(0) and alpha(0) sit inside nodeStyle. The absolute loop in
FlexContainer now skips them like the flow loops do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	resources/xcode/NativePHP/NativeRender/FlexContainer.swift
@simonhamp

Copy link
Copy Markdown
Member

Pushed two commits to this branch.

41e41d1 fixes hidden on iOS. It only faded the content, so the node's background and border still drew. FlexContainer never places a hidden child, so SwiftUI sized that leftover background to the whole row and drew it over the siblings. xl:hidden in a row showed an empty coloured bar. The opacity now wraps the whole node in ResolvedNodeView. On Android the same kind of thing happened for a hidden absolute child (its bg-* still drew a dot), so ComposeFlexLayout now skips hidden children in the absolute loop too. Checked on an iPad sim and an Android emulator, before and after rotation. Rows without a hidden child are pixel-identical.

55b30b5 merges main. The only conflict was FlexContainer.CacheData, where #480 added measuredKey next to our generation. They're independent, so both stay. Pest passes and the iOS build runs.

@simonhamp
simonhamp merged commit febc630 into main Sep 19, 2026
6 checks passed
@simonhamp
simonhamp deleted the feat/responsive-breakpoints branch September 19, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants