diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,28 +1,5 @@
-# Changelog
-
-## 0.1.0.0
+## 0.1.0.1 -- 2026-09-18
 
-First release.
+* 
 
-- Immediate-mode views in `Eff '[Ui, IOE]`: widgets add layout nodes, read
-  the frame's input, and return results. State lives in hooks (`useInt`,
-  `useText`, `useState`), in a model passed through the view, or in a
-  reducer with `NanoUI.Emit`.
-- Widgets: buttons, checkboxes, radios, text inputs, a multi-line text area,
-  numeric fields, sliders, knobs, selects, combo boxes, sortable tables,
-  trees, tabs, menus, context menus, modals, floating windows, split panes,
-  pane grids, colour pickers, progress bars, sparklines, spinners, rich text
-  with links, SVG icons, and drag and drop. `customWidget` and a canvas API
-  draw anything else.
-- Row, column and grid layout with `Layout -> Layout` modifiers, and
-  scrollers with tunable wheel steps, smooth scrolling, and scroll commands.
-- Keyboard focus and navigation for every control.
-- Text fields with undo and redo, driven by `TextCommand` values. Shaped
-  text and mixed left-to-right and right-to-left lines when the backend
-  provides shaping.
-- Themes, including Base16 schemes, changed for part of a view with `styled`
-  and composable style modifiers. `disabledWhen` switches widgets off.
-- Eased and spring animation.
-- Frames run only when something needs redrawing, and each frame computes
-  its damage against the previous one.
-- `NanoUI.Testing` runs frames headlessly on scripted input.
+../../CHANGELOG.md
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,166 +1,1 @@
-# nano-ui
-
-An immediate-mode GUI toolkit for Haskell.
-
-A view is a function that runs every frame. Each widget is an ordinary effect
-that adds a layout node, reads this frame's input, and returns a result: `Bool`
-for a button, the new value for an input. There are no widget objects to keep
-and no callbacks to register. Widget state lives in a store keyed by each
-widget's position among its siblings (or by a key you give it with `withKey`),
-or in a model you pass through the view.
-
-```haskell
-{-# LANGUAGE OverloadedStrings #-}
-
-import Data.Text qualified as T
-import NanoUI
-import NanoUI.Backend.Sdl (defaultSdlOptions, runSdlApp)
-
-main :: IO ()
-main = runSdlApp defaultSdlOptions counter
-
-counter :: NanoUI ()
-counter = do
-  (n, setN) <- useInt 0
-  row $ do
-    whenM (button "-") (setN (n - 1))
-    label (T.pack (show n))
-    whenM (button "+") (setN (n + 1))
-```
-
-## Features
-
-- Text inputs and a multi-line text area, numeric fields, sliders, knobs,
-  selects, combo boxes, sortable tables, trees, tabs, menus, context menus,
-  modals, floating windows, pane grids, colour pickers, progress bars,
-  sparklines, and drag and drop. `customWidget` and a canvas API cover
-  anything else.
-- Row, column, and grid layout with scrolling. Layout options are
-  `Layout -> Layout` modifiers, as in `columnWith (gap 8 . padAll 12)`.
-- Scrollers take a wheel step and a glide time (`setScrollTuning`,
-  `setScrollStep`), and move from code: `scrollTo`, `scrollBy`, `scrollPages`,
-  and `scrollIntoView`. `getScrollMetrics` gives a virtualized list the
-  viewport and offset it needs to pick its rows.
-- Keyboard focus and navigation for every control.
-- Shaped text in the SDL backend, with fallback fonts for other scripts and
-  mixed left-to-right and right-to-left lines. `richText` wraps a paragraph
-  of mixed styles and links, as in
-  `richText ["Read ", strong "the guide", " or ", hyperlink "faq" "the FAQ"]`.
-- Text fields with undo and redo, driven by `TextCommand` values that code
-  can run too.
-- SVG icons (`loadSvg`, `svgIcon`) and a `spinner`.
-- Backends block on input and run a frame only when something needs
-  redrawing. Each frame computes its damage against the previous one.
-- State in local hooks (`useInt`, `useText`, `useState`), in your own model,
-  or in a reducer with `NanoUI.Emit`.
-- Eased and spring animation.
-- Themes, including ones built from Base16 schemes, changed for part of a
-  view with `styled` and composable modifiers, as in
-  `styled (primary . buttonStyle (cornerRadius 6)) (button "Save")`.
-  `disabledWhen` switches widgets off.
-- `NanoUI.Testing` runs frames headlessly on scripted input, for tests.
-
-## Examples
-
-Inputs are controlled: pass the current value and keep the result. A change you
-don't store is undone on the next frame.
-
-```haskell
-greeter :: NanoUI ()
-greeter = columnWith (gap 8 . padAll 16) $ do
-  (name, setName) <- useText "world"
-  (size, setSize) <- useFloat 16
-  (shout, setShout) <- useFlag False
-
-  heading "Greeter"
-  setName =<< textInput name
-  setSize =<< slider 10 48 size
-  setShout =<< checkbox "Shout" shout
-
-  let greeting = "Hello, " <> name <> "!"
-  labelWith (fontSize size) (if shout then T.toUpper greeting else greeting)
-```
-
-For an Elm-style update function, the widgets in `NanoUI.Emit` emit messages
-instead of returning values, and `runSdlAppReduce` folds them into the model:
-
-```haskell
-{-# LANGUAGE OverloadedStrings #-}
-
-import Data.Text qualified as T
-import NanoUI
-import NanoUI.Backend.Sdl (defaultSdlOptions, runSdlAppReduce)
-import NanoUI.Emit qualified as Emit
-
-data Msg = Increment | Decrement
-
-main :: IO ()
-main = runSdlAppReduce defaultSdlOptions update 0 view
-
-update :: Msg -> Int -> Int
-update Increment n = n + 1
-update Decrement n = n - 1
-
-view :: Int -> NanoUI ()
-view n = row $ do
-  Emit.button "-" Decrement
-  label (T.pack (show n))
-  Emit.button "+" Increment
-```
-
-## How it works
-
-`NanoUI` is `Eff '[Ui, IOE]` from
-[effectful](https://hackage.haskell.org/package/effectful), and widgets have
-types like `Ui :> es => Eff es Bool`, so a view can run in a larger effect
-stack. A frame:
-
-1. Resets the node and vertex arenas and runs the view. Widgets add layout
-   nodes and read and write the widget store.
-2. Solves layout.
-3. Resolves pointer, keyboard, and focus against the new geometry.
-4. Paints into pinned vertex and index buffers, in background, content,
-   overlay, and chrome layers.
-5. Computes damage against the previous frame and hands the draw list to the
-   backend.
-
-[docs/rendering-pipeline.svg](https://github.com/goolord/nano-ui/blob/main/docs/rendering-pipeline.svg) has the diagram.
-Per-frame code is profiled for allocation, and an
-[inspection-testing](https://hackage.haskell.org/package/inspection-testing)
-suite checks that the vertex writers compile without dictionaries or tuples.
-
-## Packages
-
-| Package | What it is |
-| --- | --- |
-| `nano-ui` | Widgets, layout, input handling, and the draw list |
-| `nano-ui-sdl` | Window backend on SDL3, with TrueType fonts, installed-font lookup, and native file dialogs |
-| `nano-ui-rgfw` | Window backend on RGFW and OpenGL 3.2, with a bundled bitmap font and no system dependencies beyond windowing |
-| `nano-ui-rgfw-bindings` | Haskell bindings to RGFW |
-| `nano-ui-diagrams` | Line, bar, scatter, and area charts, and drawing with [diagrams](https://diagrams.github.io/) |
-| `nano-ui-form` | Validated forms built on [ditto](https://hackage.haskell.org/package/ditto) |
-| `nano-ui-demo` | Example applications |
-
-## Running the demos
-
-You need GHC 9.10 or later and Cabal. The SDL backend also needs SDL3,
-SDL3_ttf, and pkg-config. `nix develop` sets these up.
-
-```sh
-cabal run nano-ui-sdl-demo       # widget and chart tour
-cabal run nano-ui-sdl-notepad    # text editor with menus and file dialogs
-cabal run nano-ui-sdl-logs       # streaming log viewer
-cabal run nano-ui-sdl-terminal   # terminal on /bin/sh (Linux and macOS)
-cabal run nano-ui-rgfw-demo      # the RGFW backend
-```
-
-## Documentation
-
-Start with the `NanoUI` module documentation: it explains how widgets return
-values, how inputs keep their state, and how layout modifiers compose.
-[docs/development.md](https://github.com/goolord/nano-ui/blob/main/docs/development.md) covers building, testing,
-profiling, and the layout of this repository.
-
-## License
-
-MIT
+../../README.md
diff --git a/lib/NanoUI.hs b/lib/NanoUI.hs
--- a/lib/NanoUI.hs
+++ b/lib/NanoUI.hs
@@ -508,6 +508,7 @@
   , alignCenter
   , alignTop
   , alignBottom
+  , alignBaseline
   , tight
   , percent
   , gridMinColW
@@ -637,6 +638,8 @@
   , rectUnion
   , v2Add
   , v2Sub
+  , onGrid
+  , roundHalfUp
 
     -- * Input
   , Input (..)
@@ -861,6 +864,7 @@
   , tinted
   , windowColor
   , windowStyle
+  , alignBaseline
   , alignBottom
   , alignCenter
   , alignEnd
@@ -943,11 +947,13 @@
   , defaultDamageSlop
   , haloDamageSlop
   , lerpColor
+  , onGrid
   , rectContains
   , rectInflate
   , rectIntersect
   , rectUnion
   , resolveDamageRect
+  , roundHalfUp
   , sliderDamageSlop
   , v2Add
   , v2Sub
diff --git a/lib/NanoUI/Context.hs b/lib/NanoUI/Context.hs
--- a/lib/NanoUI/Context.hs
+++ b/lib/NanoUI/Context.hs
@@ -518,7 +518,7 @@
   writeIORef (ctxHost ctx) (Map.insert k (toDyn val) m)
 
 -- | Set the device pixel scale used to snap geometry origins/endpoints to
--- whole pixels. The SDL backend calls this when the display scale is synced.
+-- whole pixels. The SDL backend calls this when the window pixel density is synced.
 {-# INLINE setDrawSnapScale #-}
 setDrawSnapScale :: Context -> Float -> IO ()
 setDrawSnapScale ctx s = Draw.setDrawSnapScale (ctxDrawArena ctx) s
diff --git a/lib/NanoUI/Draw.hs b/lib/NanoUI/Draw.hs
--- a/lib/NanoUI/Draw.hs
+++ b/lib/NanoUI/Draw.hs
@@ -36,6 +36,9 @@
   , pushRoundedRect
   , pushRoundedRectRaw
   , pushRoundedStroke
+  , pushRoundedStrokeRaw
+  , pushCircle
+  , pushCircleStroke
   , pushLine
   , pushStrokeAA
   , pushStroke
diff --git a/lib/NanoUI/Draw/Arena.hs b/lib/NanoUI/Draw/Arena.hs
--- a/lib/NanoUI/Draw/Arena.hs
+++ b/lib/NanoUI/Draw/Arena.hs
@@ -107,7 +107,7 @@
 
 -- | Device pixel scale used to snap primitive origins/endpoints to whole
 -- device pixels. A non-positive value disables snapping. The SDL backend keeps
--- this in sync with the display scale. Headless contexts and the RGFW backend
+-- this in sync with the window pixel density. Headless contexts and the RGFW backend
 -- leave it disabled.
 {-# INLINE setDrawSnapScale #-}
 setDrawSnapScale :: DrawArena -> Float -> IO ()
diff --git a/lib/NanoUI/Draw/Shapes.hs b/lib/NanoUI/Draw/Shapes.hs
--- a/lib/NanoUI/Draw/Shapes.hs
+++ b/lib/NanoUI/Draw/Shapes.hs
@@ -9,6 +9,9 @@
   , pushRoundedRect
   , pushRoundedRectRaw
   , pushRoundedStroke
+  , pushRoundedStrokeRaw
+  , pushCircle
+  , pushCircleStroke
   , pushLine
   , pushStrokeAA
   , pushStroke
@@ -246,14 +249,38 @@
             pokeCorner (vi3 + 2 * cornerV) (ii3 + 2 * cornerI) (x + w - rad) (y + h - rad) 2
             pokeCorner (vi3 + 3 * cornerV) (ii3 + 3 * cornerI) (x + rad) (y + h - rad) 3
 
-{-# NOINLINE pushRoundedStroke #-}
+-- | A filled circle. The centre snaps to the device pixel grid, not the
+-- bounding box's origin: snapping the origin rounds @cx - radius@, so two
+-- circles sharing a centre but not a radius would land up to a pixel apart.
+{-# INLINE pushCircle #-}
+pushCircle :: DrawArena -> Float -> Float -> Float -> Color -> IO ()
+pushCircle da cx cy radius col = do
+  s <- readIORef (daSnapScale da)
+  pushRoundedRectRaw da (circleBox (onGrid s cx) (onGrid s cy) radius) radius col
+
+-- | A circle's outline, its centre snapped as 'pushCircle' snaps it.
+{-# INLINE pushCircleStroke #-}
+pushCircleStroke :: DrawArena -> Float -> Float -> Float -> Float -> Color -> IO ()
+pushCircleStroke da cx cy radius bw col = do
+  s <- readIORef (daSnapScale da)
+  pushRoundedStrokeRaw da (circleBox (onGrid s cx) (onGrid s cy) radius) radius bw col
+
+circleBox :: Float -> Float -> Float -> Rect
+circleBox cx cy radius = Rect (cx - radius) (cy - radius) (2 * radius) (2 * radius)
+
+{-# INLINE pushRoundedStroke #-}
 pushRoundedStroke :: DrawArena -> Rect -> Float -> Float -> Color -> IO ()
-pushRoundedStroke da (Rect x y w h) radius bw col
+pushRoundedStroke da (Rect x y w h) radius bw col = do
+  s <- readIORef (daSnapScale da)
+  pushRoundedStrokeRaw da (Rect (onGrid s x) (onGrid s y) w h) radius bw col
+
+-- | 'pushRoundedStroke' without snapping the origin, for a rect already
+-- anchored to the grid; see 'pushRoundedRectRaw'.
+{-# NOINLINE pushRoundedStrokeRaw #-}
+pushRoundedStrokeRaw :: DrawArena -> Rect -> Float -> Float -> Color -> IO ()
+pushRoundedStrokeRaw da (Rect px py w h) radius bw col
   | w <= 0 || h <= 0 || bw <= 0 = pure ()
   | otherwise = do
-      s <- readIORef (daSnapScale da)
-      let !px = onGrid s x
-          !py = onGrid s y
       setTexture da glyphAtlasTextureId
       square <- readIORef (daSquareGeometry da)
       let !rad = min (max 0 radius) (min (w * 0.5) (h * 0.5))
@@ -365,7 +392,7 @@
 pushLine da x1 y1 x2 y2 thickness col = do
   square <- readIORef (daSquareGeometry da)
   let !r = thickness / 2
-      cap cx cy = pushRoundedRect da (Rect (cx - r) (cy - r) thickness thickness) r col
+      cap cx cy = pushCircle da cx cy r col
   if square
     then pushStroke da x1 y1 x2 y2 thickness col
     else
diff --git a/lib/NanoUI/Draw/Text.hs b/lib/NanoUI/Draw/Text.hs
--- a/lib/NanoUI/Draw/Text.hs
+++ b/lib/NanoUI/Draw/Text.hs
@@ -237,12 +237,10 @@
     emitOne (FillRect r c) = pushRect da r c
     emitOne (FillRoundedRect r radius c) = pushRoundedRect da r radius c
     emitOne (FillTriangle x0 y0 x1 y1 x2 y2 c) = pushFilledTriangle da x0 y0 x1 y1 x2 y2 c
-    emitOne (FillCircle cx cy radius c) =
-      pushRoundedRect da (Rect (cx - radius) (cy - radius) (2 * radius) (2 * radius)) radius c
+    emitOne (FillCircle cx cy radius c) = pushCircle da cx cy radius c
     emitOne (Stroke x0 y0 x1 y1 t c) = pushStroke da x0 y0 x1 y1 t c
     emitOne (StrokeRoundedRect r radius bw c) = pushRoundedStroke da r radius bw c
-    emitOne (StrokeCircle cx cy radius bw c) =
-      pushRoundedStroke da (Rect (cx - radius) (cy - radius) (2 * radius) (2 * radius)) radius bw c
+    emitOne (StrokeCircle cx cy radius bw c) = pushCircleStroke da cx cy radius bw c
     emitOne (StrokeLineAA x0 y0 x1 y1 bw c) = pushStrokeAA da x0 y0 x1 y1 bw c
     emitOne (FillQuadGradient r c0 c1 c2 c3) = pushQuadGradient da r c0 c1 c2 c3
     emitOne (DrawImageRect r tex u0 v0 u1 v1 c) = pushImage da r tex u0 v0 u1 v1 c
diff --git a/lib/NanoUI/Font.hs b/lib/NanoUI/Font.hs
--- a/lib/NanoUI/Font.hs
+++ b/lib/NanoUI/Font.hs
@@ -102,7 +102,7 @@
   { fmLineHeight :: {-# UNPACK #-} !Float
   , fmAscent :: {-# UNPACK #-} !Float
   -- | Device pixels per logical unit used to snap glyph quads to the pixel
-  -- grid. The SDL backend sets this to the display scale so text lands on
+  -- grid. The SDL backend sets this to the window pixel density so text lands on
   -- whole device pixels.
   , fmSnapScale :: {-# UNPACK #-} !Float
   , fmAdvance :: Char -> Float
diff --git a/lib/NanoUI/Frame.hs b/lib/NanoUI/Frame.hs
--- a/lib/NanoUI/Frame.hs
+++ b/lib/NanoUI/Frame.hs
@@ -48,7 +48,6 @@
   , stepScrollGlides
   , takeDamage
   , tickAnimations
-  , lookupCustomMeasure
   , hasCustomLayoutInputs
   , ensureMetricCaches
   , InteractionState (..)
@@ -124,7 +123,8 @@
   , openTextEditMenu
   )
 import NanoUI.Frame.Window
-  ( lookupWindowPos
+  ( contextMeasurers
+  , lookupWindowPos
   , lookupWindowSize
   , persistWindowPositions
   , updateWindowDrag
@@ -254,7 +254,7 @@
   when (movedResize || movedWindow) $
     placeWindows
       (ctxNodeArena ctx)
-      (ctxFontMetrics ctx)
+      (contextMeasurers ctx)
       w
       h
       (lookupWindowPos ctx)
@@ -360,29 +360,19 @@
 
 solvePlaceWindows :: Context -> Float -> Float -> IO ()
 solvePlaceWindows ctx w h = do
-  let fontResolver sz weight style var = do
-        (fm, _) <- ctxResolveFont ctx sz weight style var
-        pure (fm, ctxResolveMeasure ctx sz weight style var)
-  solveLayout
-    (ctxNodeArena ctx)
-    (ctxFontMetrics ctx)
-    (ctxMonoFontMetrics ctx)
-    (ctxMeasureText ctx)
-    fontResolver
-    (lookupCustomMeasure ctx)
-    w
-    h
-  placeModals (ctxNodeArena ctx) (ctxFontMetrics ctx) w h
+  let ms = contextMeasurers ctx
+  solveLayout (ctxNodeArena ctx) ms w h
+  placeModals (ctxNodeArena ctx) ms w h
   placeWindows
     (ctxNodeArena ctx)
-    (ctxFontMetrics ctx)
+    ms
     w
     h
     (lookupWindowPos ctx)
     (lookupWindowSize ctx)
   placePopups
     (ctxNodeArena ctx)
-    (ctxFontMetrics ctx)
+    ms
     w
     h
     (lookupPopupConfig ctx)
diff --git a/lib/NanoUI/Frame/Paint/Widgets.hs b/lib/NanoUI/Frame/Paint/Widgets.hs
--- a/lib/NanoUI/Frame/Paint/Widgets.hs
+++ b/lib/NanoUI/Frame/Paint/Widgets.hs
@@ -20,6 +20,7 @@
 import NanoUI.Context (Context (..), getStore)
 import NanoUI.Draw
   ( DrawArena (..)
+  , pushCircle
   , pushFilledTriangle
   , pushLine
   , pushRoundedRect
@@ -470,9 +471,10 @@
       y1 = by + box * 0.72
       x2 = bx + box * 0.78
       y2 = by + box * 0.28
-      capR = t / 2
-      cap cx cy =
-        pushRoundedRect da (Rect (cx - capR) (cy - capR) t t) capR markCol
+      -- Caps snap their centres, as the strokes snap their ends; snapping a
+      -- cap's corner lands it up to a pixel off the stroke at a fractional
+      -- scale.
+      cap cx cy = pushCircle da cx cy (t / 2) markCol
   pushStrokeAA da x0 y0 x1 y1 t markCol
   pushStrokeAA da x1 y1 x2 y2 t markCol
   cap x0 y0
diff --git a/lib/NanoUI/Frame/Spans.hs b/lib/NanoUI/Frame/Spans.hs
--- a/lib/NanoUI/Frame/Spans.hs
+++ b/lib/NanoUI/Frame/Spans.hs
@@ -67,10 +67,11 @@
   , getWidthSizing
   , isFloatingNode
   , isScrollNode
+  , hasCenteredLabel
   , isWidgetNode
   , parentIsRow
   )
-import NanoUI.Layout.Solve (findAncestorMaxW)
+import NanoUI.Layout.Solve (findAncestorMaxW, textWrapCap)
 import NanoUI.Style (AlignX (..), FontVariant (..), Style (..), Theme (..), themeAccent, themeMuted, themePanel)
 import NanoUI.Types (Color (..), Rect (..), lerpColor, onGrid, rectIntersect)
 import NanoUI.Widgets.ColorPicker (ColorPickerPart (..), colorPickerPartOf, colorPickerPartRect, colorPickerPreviewGeom)
@@ -225,10 +226,7 @@
                     measureW = fmap fst . measure
                     lineH = fmLineHeight fm
                     contentW = max 0 (w - 2 * ix)
-                    wrapCap
-                      | effMaxW < 1e8 = max 0 effMaxW
-                      | wTag == SizingGrow && w > 0 = w
-                      | otherwise = effMaxW
+                    wrapCap = textWrapCap effMaxW wTag w
                 tw <- measureW raw
                 if T.any (== '\n') raw || (not isRowChild && wrapCap < 1e8 && wrapCap + 0.5 < tw)
                   then do
@@ -306,13 +304,7 @@
 -- dimensions, but not the absolute node origin. Text
 -- fields / areas / colour pickers / sliders are data-dependent and stay out.
 cacheableWidgetLabel :: NodeType -> Bool
-cacheableWidgetLabel = \case
-  NodeButton -> True
-  NodeSelect -> True
-  NodeTree -> True
-  NodeCheckbox -> True
-  NodeRadio -> True
-  _ -> False
+cacheableWidgetLabel = hasCenteredLabel
 
 widgetTextPlacements ::
   Context -> NodeType -> NodeIdx -> Float -> Float -> Float -> Float -> IO [(T.Text, Float, Float, Float, Float)]
diff --git a/lib/NanoUI/Frame/Window.hs b/lib/NanoUI/Frame/Window.hs
--- a/lib/NanoUI/Frame/Window.hs
+++ b/lib/NanoUI/Frame/Window.hs
@@ -3,7 +3,8 @@
 -- | Floating window input: dragging by the title bar, edge and inner-east
 -- resizing, the resize cursor, and persisting window placement.
 module NanoUI.Frame.Window
-  ( lookupWindowPos
+  ( contextMeasurers
+  , lookupWindowPos
   , lookupWindowSize
   , persistWindowPositions
   , updateWindowDrag
@@ -31,6 +32,7 @@
   , Slot (..)
   , InteractionState (..)
   , modifyInteraction
+  , lookupCustomMeasure
   )
 import NanoUI.Font (ScrollBarSlot (..))
 import NanoUI.Frame.Hit (findNodeByWidgetId, nodeInSubtree, topmostOverlayAtMouse)
@@ -55,7 +57,7 @@
   , getRect
   , getWidgetId
   )
-import NanoUI.Layout.Solve (placeWindowNode, scrollBarSlotOf)
+import NanoUI.Layout.Solve (Measurers (..), placeWindowNode, scrollBarSlotOf)
 import NanoUI.Style (Padding (..))
 import NanoUI.Types (DamageBounds (..), Rect (..), V2 (..), haloDamageSlop, rectContains, rectInflate)
 
@@ -297,7 +299,7 @@
     Just idx -> do
       mpos <- lookupWindowPos ctx wid
       (x, y, _, _) <- getRect (ctxNodeArena ctx) idx
-      placeWindowNode (ctxNodeArena ctx) (ctxFontMetrics ctx) winW winH idx nw nh (const (fromMaybe (x, y) mpos))
+      placeWindowNode (ctxNodeArena ctx) (contextMeasurers ctx) winW winH idx nw nh (const (fromMaybe (x, y) mpos))
 
 -- | Resize edge under @mouse@ for the topmost window whose halo holds it,
 -- unless the halo is blocked or the pointer is on the title bar or one of its
@@ -430,3 +432,17 @@
     Just wid -> do
       mNode <- findNodeByWidgetId ctx wid
       maybe (pure False) (\wi -> nodeInSubtree ctx wi idx) mNode
+
+-- | How the context measures text and custom widgets, for the solve and for
+-- placing floating nodes after it.
+contextMeasurers :: Context -> Measurers
+contextMeasurers ctx =
+  Measurers
+    { msFm = ctxFontMetrics ctx
+    , msMonoFm = ctxMonoFontMetrics ctx
+    , msMeasure = ctxMeasureText ctx
+    , msResolveFont = \sz weight style var -> do
+        (fm, _) <- ctxResolveFont ctx sz weight style var
+        pure (fm, ctxResolveMeasure ctx sz weight style var)
+    , msLookupMeasure = lookupCustomMeasure ctx
+    }
diff --git a/lib/NanoUI/Layout/Arena.hs b/lib/NanoUI/Layout/Arena.hs
--- a/lib/NanoUI/Layout/Arena.hs
+++ b/lib/NanoUI/Layout/Arena.hs
@@ -8,6 +8,7 @@
   , NodeType (..)
   , NodeArenaArrays (..)
   , isWidgetNode
+  , hasCenteredLabel
   , isContainerNode
   , isScrollNode
   , isFloatingNode
@@ -188,6 +189,18 @@
     NodeColorPicker -> True
     NodeTree -> True
     NodeDrawing -> True
+    _ -> False
+
+-- | Widgets that paint one line of label text vertically centered in their box
+-- ('computeWidgetLabel'), which is also their baseline.
+hasCenteredLabel :: NodeType -> Bool
+hasCenteredLabel nt =
+  case nt of
+    NodeButton -> True
+    NodeSelect -> True
+    NodeTree -> True
+    NodeCheckbox -> True
+    NodeRadio -> True
     _ -> False
 
 isContainerNode :: NodeType -> Bool
diff --git a/lib/NanoUI/Layout/Solve.hs b/lib/NanoUI/Layout/Solve.hs
--- a/lib/NanoUI/Layout/Solve.hs
+++ b/lib/NanoUI/Layout/Solve.hs
@@ -3,6 +3,7 @@
 module NanoUI.Layout.Solve
   ( solveLayout
   , FontResolver
+  , Measurers (..)
   , placeModals
   , placeWindows
   , placePopups
@@ -10,9 +11,10 @@
   , placeWindowNode
   , scrollBarSlotOf
   , findAncestorMaxW
+  , textWrapCap
   ) where
 
-import Control.Monad (foldM, unless, when)
+import Control.Monad (foldM, forM, unless, when)
 import Data.IORef (readIORef)
 import Data.Maybe (fromMaybe)
 import Data.Primitive.PrimArray
@@ -49,6 +51,7 @@
   , sliderTrackHeight
   , sliderHandleDiameter
   , sliderHandleSlack
+  , centeredTextY
   )
 import NanoUI.Layout.Arena
   ( DirTag (..)
@@ -60,6 +63,7 @@
   , SizingTag (..)
   , arenaArrays
   , arenaCount
+  , hasCenteredLabel
   , withArenaArraysSnap
   , geomX
   , geomY
@@ -150,6 +154,7 @@
   , searchFieldReserveW
   , isTableHeaderStyle
   , isMenuItemStyle
+  , isCloseButtonStyle
   , tableHeaderDisplayText
   )
 import NanoUI.Frame.Scroll.Geometry
@@ -174,13 +179,21 @@
   , seLookupMeasure :: !(WidgetId -> IO (Maybe CustomMeasureFn))
   }
 
--- | Env for placing floating nodes after the solve: every text node uses the
--- default font, and there is no custom measurement.
-floatingEnv :: NodeArena -> FontMetrics -> IO SolveEnv
-floatingEnv na fm = do
+-- | How text and custom widgets are measured. The solve and the placement of
+-- floating nodes after it measure with the same, so a label placed in a modal
+-- wraps exactly as the solve measured it for the modal's size.
+data Measurers = Measurers
+  { msFm :: !FontMetrics
+  , msMonoFm :: !FontMetrics
+  , msMeasure :: !(Text -> IO (Float, Float))
+  , msResolveFont :: !FontResolver
+  , msLookupMeasure :: !(WidgetId -> IO (Maybe CustomMeasureFn))
+  }
+
+solveEnv :: NodeArena -> Measurers -> IO SolveEnv
+solveEnv na Measurers {msFm, msMonoFm, msMeasure, msResolveFont, msLookupMeasure} = do
   a <- arenaArrays na
-  let measure = measureTextIO fm
-  pure (SolveEnv na a fm fm measure (\_ _ _ _ -> pure (fm, measure)) (const (pure Nothing)))
+  pure (SolveEnv na a msFm msMonoFm msMeasure msResolveFont msLookupMeasure)
 
 -- | Strict accumulator for flow-child folds: a child count and two running
 -- sums or extents. The strict fields keep the folds unboxed.
@@ -253,25 +266,15 @@
 wrapsNarrower :: Bool -> Float -> Float -> Bool
 wrapsNarrower allowed wrapW lineW = allowed && wrapW + 0.5 < lineW && wrapW > 0
 
-solveLayout ::
-  NodeArena ->
-  FontMetrics ->
-  FontMetrics ->
-  (Text -> IO (Float, Float)) ->
-  FontResolver ->
-  (WidgetId -> IO (Maybe CustomMeasureFn)) ->
-  Float ->
-  Float ->
-  IO ()
-solveLayout na fm monoFm measure resolveFont lookupMeasure rootW rootH =
+solveLayout :: NodeArena -> Measurers -> Float -> Float -> IO ()
+solveLayout na ms rootW rootH =
   withArenaArraysSnap na $ do
-    a <- arenaArrays na
     count <- arenaCount na
     when (count > 0) $ do
-      let env = SolveEnv na a fm monoFm measure resolveFont lookupMeasure
+      env <- solveEnv na ms
       measurePass env count
       positionNodeA env 0 0 0 0 rootW rootH
-      quantizeResultsA a count (fmSnapScale fm)
+      quantizeResultsA (seArrays env) count (fmSnapScale (msFm ms))
 
 quantizeResultsA :: NodeArenaArrays -> Int -> Float -> IO ()
 quantizeResultsA a count s
@@ -354,6 +357,15 @@
       h = case hTag of SizingFixed -> hVal; _ -> clamp minH maxH mh
   setRect na idx 0 0 w h
 
+-- | The width a text node that is not a row's child wraps at, from its
+-- effective max width, width sizing and assigned width: 1e8 or more when
+-- nothing caps it ('collectNodeTextSpans').
+textWrapCap :: Float -> SizingTag -> Float -> Float
+textWrapCap effMaxW wTag w
+  | effMaxW < 1e8 = max 0 effMaxW
+  | wTag == SizingGrow && w > 0 = w
+  | otherwise = effMaxW
+
 findAncestorMaxW :: NodeArena -> NodeIdx -> IO Float
 findAncestorMaxW na idx = go idx 0
   where
@@ -606,11 +618,25 @@
         then do
           n <- loadChildrenScratch na idx (flowChildSize env False innerMaxW innerAvailH)
           foldChromeColumnScratch na n gap
-        else foldChildDimsFromParent na idx dir gap
+        else foldChildDimsFromParent env idx dir gap
+  -- A grow container with its own minimum width, whose width is assigned from
+  -- above, reports that minimum rather than its content: it shrinks that far
+  -- in a row that is short of space, so that is the least it needs. As with
+  -- CSS's min-width on a flex item, the explicit minimum replaces the
+  -- content-based one. Otherwise a 2D scroller, which lays its content out at
+  -- the width it reports, scrolls sideways for a long label in a cell that
+  -- would have fit. Without a minimum the content still counts, so a grow
+  -- wrapper around a wide table keeps its sideways scroll.
+  minAssigned <-
+    if wTag == SizingGrow && minW > 0 && not (isFloatingNode nt)
+      then growParent na idx
+      else pure False
   let w =
         case wTag of
           SizingFixed -> clamp minW maxW wVal
-          _ -> clamp minW maxW (contentW + padX)
+          _
+            | minAssigned -> clamp minW maxW 0
+            | otherwise -> clamp minW maxW (contentW + padX)
       h =
         case hTag of
           SizingFixed -> clamp minH maxH hVal
@@ -618,7 +644,7 @@
   setRect na idx 0 0 w h
 
 measureScrollContainer :: SolveEnv -> NodeIdx -> IO ()
-measureScrollContainer SolveEnv {seArena = na, seArrays = a} idx = do
+measureScrollContainer env@SolveEnv {seArena = na, seArrays = a} idx = do
   (pad, gap, dir) <- containerFlow a idx
   let padX = padL pad + padR pad
       padY = padT pad + padB pad
@@ -626,7 +652,7 @@
   (minW, minH, maxW, maxH) <- getMinMax na idx
   (wTag, wVal) <- getWidthSizing na idx
   (hTag, hVal) <- getHeightSizing na idx
-  (contentW, contentH) <- foldChildDimsFromParent na idx dir gap
+  (contentW, contentH) <- foldChildDimsFromParent env idx dir gap
   parent <- getParent na idx
   -- A modal's body scrolls like a window's: its bar sits just inside the
   -- panel's edge, out in the panel padding.
@@ -668,14 +694,20 @@
     else setNodeValue na idx (case dir of DirColumn -> contentH; DirRow -> contentW)
   setRect na idx 0 0 (clamp minW maxW viewportW) (clamp minH maxH viewportH)
 
-foldChildDimsFromParent :: NodeArena -> NodeIdx -> DirTag -> Float -> IO (Float, Float)
-foldChildDimsFromParent na idx dir gap = do
+foldChildDimsFromParent :: SolveEnv -> NodeIdx -> DirTag -> Float -> IO (Float, Float)
+foldChildDimsFromParent env@SolveEnv {seArena = na} idx dir gap = do
   FlowAcc count main cross <- foldFlowChildrenM na idx step (FlowAcc 0 0 0)
+  -- A row's baseline-aligned children stand on one line, so together they are
+  -- as tall as the most room any takes above it plus the most any takes below.
+  baseline <-
+    if dir == DirRow
+      then foldFlowChildrenM na idx baselineStep (0, 0)
+      else pure (0, 0)
   pure
     ( case dir of
         DirRow ->
           ( main + gap * fromIntegral (max 0 (count - 1))
-          , if count <= 0 then 0 else cross
+          , if count <= 0 then 0 else max cross (uncurry (+) baseline)
           )
         DirColumn ->
           ( if count <= 0 then 0 else main
@@ -689,6 +721,14 @@
         case dir of
           DirRow -> FlowAcc (count + 1) (main + w) (max cross h)
           DirColumn -> FlowAcc (count + 1) (max main w) (cross + h)
+    baselineStep acc@(above, below) ci = do
+      ay <- getAlignY na ci
+      if ay /= AlignBaseline
+        then pure acc
+        else do
+          (_, _, _, h) <- getRect na ci
+          b <- childBaseline env ci h
+          pure (max above b, max below (h - b))
 
 isChromeColumn :: NodeType -> DirTag -> Bool
 isChromeColumn nt dir =
@@ -1223,6 +1263,19 @@
 positionRowFromParent env@SolveEnv {seArena = na, seFm = fm} depth parent gap cx cy cw ch = do
   n <- loadChildrenScratch (seArena env) parent (flowChildSize env False cw ch)
   withAxisSnaps na depth n cw (gap * fromIntegral (max 0 (n - 1))) True $ \idxSnap outSnap -> do
+    -- The shared baseline sits as low as the deepest one among the children
+    -- aligned on it, so the child with the tallest ascent stays at the top.
+    let goBase !i !acc
+          | i >= n = pure acc
+          | otherwise = do
+              ci <- readPrimArray idxSnap i
+              ay <- getAlignY na ci
+              if ay /= AlignBaseline
+                then goBase (i + 1) acc
+                else do
+                  b <- childRowCrossSize na ci ch >>= childBaseline env ci
+                  goBase (i + 1) (max acc b)
+    rowBase <- goBase 0 0
     let goRow !i !cur !prev
           | i >= n = pure ()
           | otherwise = do
@@ -1232,7 +1285,10 @@
               -- Fit/fixed children keep content height. Only Grow/Percent eat `ch`.
               crossH <- childRowCrossSize na ci ch
               ay <- getAlignY na ci
-              let fy = alignY ay cy ch crossH
+              fy <-
+                if ay == AlignBaseline
+                  then (\b -> cy + rowBase - b) <$> childBaseline env ci crossH
+                  else pure (alignY ay cy ch crossH)
               positionNodeA env (depth + 1) ci x fy fw crossH
               -- A grow child that its max width stopped short of its share
               -- hands the rest to the siblings after it instead of leaving a
@@ -1583,10 +1639,91 @@
 alignY AlignTop cy _ _ = cy
 alignY AlignMiddle cy ch ih = cy + (ch - ih) / 2
 alignY AlignBottom cy ch ih = cy + ch - ih
+-- Only a row has a baseline to share; 'positionRowFromParent' places these.
+alignY AlignBaseline cy _ _ = cy
 
-placeModals :: NodeArena -> FontMetrics -> Float -> Float -> IO ()
-placeModals na fm winW winH = do
-  env <- floatingEnv na fm
+-- | Distance from the top of node @ci@, laid out @h@ tall, to its first
+-- baseline, as in CSS:
+--
+-- * text: its first line's, where paint puts it ('collectNodeTextSpans'). One
+--   line is centered in the box, and wrapped lines start at the top. Paint
+--   wraps at explicit newlines, and outside a row where the line overflows
+--   'textWrapCap'.
+-- * a widget with a label (a button, select, checkbox): the label's, which
+--   paint centers in the widget.
+-- * a container: the baseline its baseline-aligned children share if it is a
+--   row that has some, and otherwise its first child's.
+-- * anything else: its bottom edge.
+childBaseline :: SolveEnv -> NodeIdx -> Float -> IO Float
+childBaseline env@SolveEnv {seArena = na, seArrays = a, seFm = defaultFm, seResolveFont = resolveFont} ci h = do
+  nt <- getNodeType na ci
+  si <- getStyleIdx na ci
+  case nt of
+    NodeText -> do
+      raw <- getText na ci
+      if T.null raw
+        then pure h
+        else do
+          measurer@TextMeasurer {tmMetrics = fm} <- textNodeMeasurer env ci
+          rowChild <- parentIsRow na ci
+          wrapped <-
+            if T.any (== '\n') raw
+              then pure True
+              else
+                if rowChild
+                  then pure False
+                  else do
+                    (_, _, maxW, _) <- getMinMax na ci
+                    (wTag, _) <- getWidthSizing na ci
+                    (_, _, w, _) <- getRect na ci
+                    effMaxW <- if maxW < 1e8 then pure maxW else findAncestorMaxW na ci
+                    let cap = textWrapCap effMaxW wTag w
+                    (tw, _) <- measureFontLine measurer raw
+                    pure (cap < 1e8 && cap + 0.5 < tw)
+          pure (textBaseline fm (if wrapped then fmLineHeight fm else h))
+    _
+      | hasCenteredLabel nt && not (nt == NodeButton && isCloseButtonStyle si) -> do
+          -- Widget labels take the node's font size in the default face
+          -- ('resolveFontFor').
+          size <- getNodeFontSize na ci
+          let weight = textNodeFontWeight 0
+              style = textNodeFontStyle 0
+              variant = textNodeFontVariant 0
+          fm <-
+            if isDefaultNodeFont size weight style variant
+              then pure defaultFm
+              else fst <$> resolveFont size weight style variant
+          pure (textBaseline fm h)
+      | isContainerNode nt -> do
+          -- Children are linked last first, so consing them up as they are
+          -- visited leaves the list in child order.
+          kids <- foldFlowChildrenM na ci (\acc k -> pure (k : acc)) []
+          case kids of
+            [] -> pure h
+            first : _ -> do
+              (pad, _, dir) <- containerFlow a ci
+              let innerH = max 0 (h - padT pad - padB pad)
+                  heightOf k = (\(_, _, _, kh) -> kh) <$> getRect na k
+              grouped <-
+                if dir /= DirRow
+                  then pure []
+                  else
+                    fmap concat . forM kids $ \k -> do
+                      ay <- getAlignY na k
+                      if ay /= AlignBaseline then pure [] else (: []) <$> (heightOf k >>= childBaseline env k)
+              (padT pad +) <$> case grouped of
+                _ : _ -> pure (maximum grouped)
+                [] -> do
+                  fh <- heightOf first
+                  ay <- if dir == DirRow then getAlignY na first else pure AlignTop
+                  (alignY ay 0 innerH fh +) <$> childBaseline env first fh
+      | otherwise -> pure h
+  where
+    textBaseline fm boxH = centeredTextY fm 0 boxH (fmLineHeight fm) + fmAscent fm
+
+placeModals :: NodeArena -> Measurers -> Float -> Float -> IO ()
+placeModals na ms winW winH = do
+  env <- solveEnv na ms
   let margin = windowMargin
   forNodes_ na $ \idx -> do
     nt <- getNodeType na idx
@@ -1602,13 +1739,13 @@
 
 placeWindows ::
   NodeArena ->
-  FontMetrics ->
+  Measurers ->
   Float ->
   Float ->
   (WidgetId -> IO (Maybe (Float, Float))) ->
   (WidgetId -> IO (Maybe (Float, Float))) ->
   IO ()
-placeWindows na fm winW winH lookupPos lookupSize = do
+placeWindows na ms winW winH lookupPos lookupSize = do
   let margin = windowMargin
   forNodes_ na $ \idx -> do
     nt <- getNodeType na idx
@@ -1617,13 +1754,13 @@
       (_, _, iw, ih) <- getRect na idx
       (w0, h0) <- fromMaybe (min iw winW, min ih winH) <$> lookupSize wid
       mpos <- lookupPos wid
-      placeWindowNode na fm winW winH idx w0 h0 $ \w -> fromMaybe (winW - w - margin, margin) mpos
+      placeWindowNode na ms winW winH idx w0 h0 $ \w -> fromMaybe (winW - w - margin, margin) mpos
 
 -- | Lay out window @idx@ at size @w0 h0@, clamped to its min and max size and
 -- the screen, with its origin, given that size, clamped on screen. Fit sizing
 -- caps at intrinsic size; floating windows use an explicit frame size.
-placeWindowNode :: NodeArena -> FontMetrics -> Float -> Float -> NodeIdx -> Float -> Float -> (Float -> (Float, Float)) -> IO ()
-placeWindowNode na fm winW winH idx w0 h0 originFor = do
+placeWindowNode :: NodeArena -> Measurers -> Float -> Float -> NodeIdx -> Float -> Float -> (Float -> (Float, Float)) -> IO ()
+placeWindowNode na ms winW winH idx w0 h0 originFor = do
   (minW, minH, maxW, maxH) <- getMinMax na idx
   let w = clamp minW (min maxW winW) w0
       h = clamp minH (min maxH winH) h0
@@ -1631,7 +1768,7 @@
       x = clamp 0 (max 0 (winW - w)) x0
       y = clamp 0 (max 0 (winH - h)) y0
   setRect na idx x y w h
-  env <- floatingEnv na fm
+  env <- solveEnv na ms
   (pad, gap, dir) <- containerFlow (seArrays env) idx
   positionChildren env 0 idx dir gap pad x y w h
 
@@ -1722,13 +1859,13 @@
 
 placePopups ::
   NodeArena ->
-  FontMetrics ->
+  Measurers ->
   Float ->
   Float ->
   (WidgetId -> IO (Maybe (PopupAnchor, PopupPlacement, Float))) ->
   IO ()
-placePopups na fm winW winH lookupAnchor = do
-  env <- floatingEnv na fm
+placePopups na ms winW winH lookupAnchor = do
+  env <- solveEnv na ms
   let margin = windowMargin
   forNodes_ na $ \idx -> do
     nt <- getNodeType na idx
diff --git a/lib/NanoUI/SIMD.hs b/lib/NanoUI/SIMD.hs
--- a/lib/NanoUI/SIMD.hs
+++ b/lib/NanoUI/SIMD.hs
@@ -1,11 +1,5 @@
-{-# LANGUAGE CPP #-}
-
 -- | Vertex and index writers for the draw buffers. Each vertex is written with
 -- two 128-bit GHC SIMD stores (FloatX4#) instead of eight scalar stores.
---
--- GHC's native code generator compiles these primops from 9.12, and only on
--- x86-64. Other compilers and architectures use scalar stores that write the
--- same bytes.
 module NanoUI.SIMD
   ( pokeVertexSIMD
   , pokeQuadSIMD
@@ -13,10 +7,8 @@
   , concentricOffsetsSIMD
   ) where
 
-import Foreign.Storable (pokeByteOff)
-import Data.Word (Word8)
-#if __GLASGOW_HASKELL__ >= 912 && defined(x86_64_HOST_ARCH)
 import GHC.Ptr (Ptr (..))
+import Foreign.Storable (pokeByteOff)
 import GHC.Exts
   ( Float (F#)
   , Int (I#)
@@ -28,10 +20,7 @@
   )
 import GHC.Word (Word32 (W32#))
 import GHC.IO (IO (..))
-#else
-import Data.Word (Word32)
-import Foreign.Ptr (Ptr)
-#endif
+import Data.Word (Word8)
 
 -- | Writes one 32-byte Vertex (8 floats) into memory using two 128-bit SIMD stores
 -- instead of 8 scalar stores.
@@ -48,7 +37,6 @@
   Float ->
   Float ->
   IO ()
-#if __GLASGOW_HASKELL__ >= 912 && defined(x86_64_HOST_ARCH)
 pokeVertexSIMD (Ptr addr#) (I# byteOff#) (F# px#) (F# py#) (F# r#) (F# g#) (F# b#) (F# a#) (F# u#) (F# v#) = IO $ \s0 ->
   -- Offsets are recomputed inline (the address add is a single lea) so the
   -- simplified body stays free of let bindings; the inspection test guards
@@ -60,17 +48,6 @@
           case writeFloatOffAddrAsFloatX4# (plusAddr# addr# byteOff#) 0# v0# s0 of
             s1 -> case writeFloatOffAddrAsFloatX4# (plusAddr# (plusAddr# addr# byteOff#) 16#) 0# v1# s1 of
               s2 -> (# s2, () #)
-#else
-pokeVertexSIMD p off px py r g b a u v = do
-  pokeByteOff p off px
-  pokeByteOff p (off + 4) py
-  pokeByteOff p (off + 8) r
-  pokeByteOff p (off + 12) g
-  pokeByteOff p (off + 16) b
-  pokeByteOff p (off + 20) a
-  pokeByteOff p (off + 24) u
-  pokeByteOff p (off + 28) v
-#endif
 
 -- | Vectorized Quad Poking: writes 4 vertices (128 bytes total) and 6 indices (24 bytes total)
 -- with SIMD vector stores.
@@ -106,7 +83,6 @@
 -- Six indices form the same two triangles for both solid and gradient quads.
 {-# INLINE pokeQuadIndicesSIMD #-}
 pokeQuadIndicesSIMD :: Ptr Word8 -> Int -> Word32 -> IO ()
-#if __GLASGOW_HASKELL__ >= 912 && defined(x86_64_HOST_ARCH)
 pokeQuadIndicesSIMD (Ptr addr#) offset@(I# offset#) baseIdx = do
   let !(W32# b0#) = baseIdx
       !(W32# b1#) = baseIdx + 1
@@ -117,15 +93,6 @@
       s1 -> (# s1, () #)
   pokeByteOff (Ptr addr#) (offset + 16) (baseIdx + 2)
   pokeByteOff (Ptr addr#) (offset + 20) (baseIdx + 3)
-#else
-pokeQuadIndicesSIMD p offset baseIdx = do
-  pokeByteOff p offset baseIdx
-  pokeByteOff p (offset + 4) (baseIdx + 1)
-  pokeByteOff p (offset + 8) (baseIdx + 2)
-  pokeByteOff p (offset + 12) baseIdx
-  pokeByteOff p (offset + 16) (baseIdx + 2)
-  pokeByteOff p (offset + 20) (baseIdx + 3)
-#endif
 
 -- | Vectorized Quad with 4 distinct corner colors (top-left, top-right, bottom-right, bottom-left)
 {-# INLINE pokeQuadGradientSIMD #-}
diff --git a/lib/NanoUI/Style.hs b/lib/NanoUI/Style.hs
--- a/lib/NanoUI/Style.hs
+++ b/lib/NanoUI/Style.hs
@@ -107,6 +107,7 @@
   , alignCenter
   , alignTop
   , alignBottom
+  , alignBaseline
   ) where
 
 import Data.Bits ((.&.), (.|.))
@@ -127,7 +128,10 @@
 data AlignX = AlignStart | AlignCenter | AlignEnd
   deriving (Eq, Show, Enum, Bounded)
 
-data AlignY = AlignTop | AlignMiddle | AlignBottom
+-- | 'AlignBaseline' lines a row's text children up on their first baseline, and
+-- sits any other child on it by its bottom edge. Outside a row it is
+-- 'AlignTop'.
+data AlignY = AlignTop | AlignMiddle | AlignBottom | AlignBaseline
   deriving (Eq, Show, Enum, Bounded)
 
 data Padding = Padding
@@ -378,6 +382,12 @@
 
 alignBottom :: Layout -> Layout
 alignBottom l = l {layoutAlignY = AlignBottom}
+
+-- | Sit on the row's shared text baseline, so labels of different sizes read
+-- as one line of type. 'alignBottom' lines up their boxes instead, and a larger
+-- font's deeper descent lifts its baseline above the smaller one's.
+alignBaseline :: Layout -> Layout
+alignBaseline l = l {layoutAlignY = AlignBaseline}
 
 data Style = Style
   { styleBg :: {-# UNPACK #-} !Color
diff --git a/lib/NanoUI/Types.hs b/lib/NanoUI/Types.hs
--- a/lib/NanoUI/Types.hs
+++ b/lib/NanoUI/Types.hs
@@ -15,6 +15,7 @@
   , clamp
   , clamp01
   , onGrid
+  , roundHalfUp
   , lerpColor
   , colorLuminance
   , contrastRatio
@@ -116,14 +117,27 @@
 -- | Round a logical coordinate onto the device-pixel grid implied by draw
 -- scale @s@ (device px = logical * s). Every layer that positions pixels --
 -- the layout solve, text pens, scroll offsets, paint and glyph rasterization --
--- must route its coordinates through this single function, with ties rounding
--- to even (@round@), so geometry can never dephase from text. An identity when
+-- must route its coordinates through this single function (backends through
+-- 'roundHalfUp'), so geometry can never dephase from text. An identity when
 -- @s <= 0@ (no scaling).
 {-# INLINE onGrid #-}
 onGrid :: Float -> Float -> Float
 onGrid s v
-  | s > 0 = fromIntegral (round (v * s) :: Int) / s
+  | s > 0 = fromIntegral (roundHalfUp (v * s)) / s
   | otherwise = v
+
+-- | Round to the nearest integer, ties up: the device-pixel rounding shared by
+-- 'onGrid' and the backends. Not ties-to-even (@round@): at a fractional scale
+-- (125%: a 20px row is 25 device px) a column of rows can all sit on half
+-- pixels, and ties-to-even would alternate them down and up, leaving uneven
+-- gaps. Compares the exact fractional part rather than @floor (r + 0.5)@,
+-- whose addition itself rounds: it lifts the float just below 0.5 to 1 and
+-- odd integers past 2^23 up by one.
+{-# INLINE roundHalfUp #-}
+roundHalfUp :: Float -> Int
+roundHalfUp r =
+  let f = floor r
+   in if r - fromIntegral f >= 0.5 then f + 1 else f
 
 rgbToHsv :: Color -> (Float, Float, Float)
 rgbToHsv c =
diff --git a/lib/NanoUI/Widgets/Custom.hs b/lib/NanoUI/Widgets/Custom.hs
--- a/lib/NanoUI/Widgets/Custom.hs
+++ b/lib/NanoUI/Widgets/Custom.hs
@@ -239,7 +239,7 @@
 drawText :: V2 -> AlignX -> AlignY -> Text -> Color -> CanvasM ()
 drawText (V2 x y) alignX alignY txt col =
   let ax = case alignX of AlignStart -> 0; AlignCenter -> 0.5; AlignEnd -> 1
-      ay = case alignY of AlignTop -> 1; AlignMiddle -> 0.5; AlignBottom -> 0
+      ay = case alignY of AlignTop -> 1; AlignMiddle -> 0.5; AlignBottom -> 0; AlignBaseline -> -1
    in emitOp (DrawText x y ax ay txt col)
 
 -- -----------------------------------------------------------------------------
diff --git a/lib/NanoUI/Widgets/Menu.hs b/lib/NanoUI/Widgets/Menu.hs
--- a/lib/NanoUI/Widgets/Menu.hs
+++ b/lib/NanoUI/Widgets/Menu.hs
@@ -21,12 +21,12 @@
 import Data.IntMap.Strict qualified as IM
 import Data.Text (Text)
 import Effectful (Eff, type (:>))
-import NanoUI.Context (getStore, intKey, modifyStore)
-import NanoUI.Font (menuItemPadX, menuItemRowH, menuMinW, menuOuterPad, menuSepH)
+import NanoUI.Context (Context (..), getStore, intKey, modifyStore)
+import NanoUI.Font (menuItemPadX, menuItemRowH, menuMinW, menuOuterPad, menuSepH, widgetContentInset)
 import NanoUI.Input (inputMousePos, inputMouseReleased)
 import NanoUI.Monad (Ui, askContext, askDefaultLayout, askInput, nextId, uiIO)
 import NanoUI.Store (WidgetStore (..), slotKey, Slot (..))
-import NanoUI.Style (Layout (..), defaultLayout, fillW, fixedH, fontMuted, gap, minW, padXY, tight)
+import NanoUI.Style (Layout (..), Padding (..), defaultLayout, fillW, fixedH, fontMuted, gap, minW, padXY, tight)
 import NanoUI.Types (PopupAnchor (..), PopupPlacement (..), V2 (..))
 import NanoUI.WidgetText (buttonFlagMenu, buttonFlagMenuBar)
 import NanoUI.Widgets.Combinators (buttonStyled)
@@ -121,13 +121,23 @@
 
 -- | Render a menu row, returning its full 'Response'. A disabled row is a
 -- muted label, not a disabled button: hover tracking does not know a button's
--- enabled flag and would still highlight it. Its response never reports
--- interaction.
+-- enabled flag and would still highlight it. Text nodes ignore padding, so the
+-- label sits in a container that reproduces an enabled row's geometry: the
+-- 'menuItemRowH' height and 'menuMinW' width, the label inset 'menuItemPadX'
+-- plus the button's content inset, and the same total horizontal gutter the
+-- solver reserves for menu buttons. Its response never reports interaction.
 menuItemWith :: Ui :> es => MenuItem -> Eff es Response
 menuItemWith (MenuItem lbl hint enabled)
   | enabled = buttonStyled text 0 menuRowLayout buttonFlagMenu
   | otherwise = do
-      resp <- labelEx (tight . fillW . fontMuted $ defaultLayout) text
+      ctx <- askContext
+      let (ix, _) = widgetContentInset (ctxFontMetrics ctx)
+          padLeft = menuItemPadX + ix
+          padRight = max 0 (2 * (menuOuterPad + menuItemPadX) - padLeft)
+          rowLayout = (minW menuMinW defaultLayout) {layoutPadding = Padding padLeft padRight 0 0}
+      (_, resp) <-
+        containerResponse NodeContainer rowLayout $
+          labelEx (fixedH menuItemRowH . tight . fontMuted $ defaultLayout) text
       pure
         resp
           { rawRespHovered = False
diff --git a/nano-ui.cabal b/nano-ui.cabal
--- a/nano-ui.cabal
+++ b/nano-ui.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.4
 name:               nano-ui
-version:            0.1.0.0
+version:            0.1.0.1
 synopsis:           Immediate-mode GUI toolkit for Haskell
 description:
     Widgets, layout, and input handling for immediate-mode interfaces. A view
@@ -11,19 +11,11 @@
 author:             goolord
 maintainer:         zacharyachurchill@gmail.com
 category:           Graphics
-homepage:           https://github.com/goolord/nano-ui
-bug-reports:        https://github.com/goolord/nano-ui/issues
 build-type:         Simple
-tested-with:        GHC ==9.10.3 || ==9.14.1
 extra-doc-files:
     CHANGELOG.md
     README.md
 
-source-repository head
-    type:     git
-    location: https://github.com/goolord/nano-ui.git
-    subdir:   packages/nano-ui
-
 common extensions
     default-language: GHC2024
     default-extensions:
@@ -61,7 +53,7 @@
     import:           warnings
     ghc-options:      -Wunused-packages
     build-depends:
-        base >=4.20 && <4.23,
+        base ^>=4.22.0.0,
         nano-ui
 
 library
@@ -167,20 +159,20 @@
         NanoUI.Widgets.Select
         NanoUI.Widgets.Slider
     build-depends:
-        base >=4.20 && <4.23,
-        bytestring >=0.11 && <0.13,
-        colonnade >=1.2 && <1.3,
-        containers >=0.6.7 && <0.9,
-        effectful-core >=2.5 && <2.8,
-        ghc-compact >=0.1 && <0.2,
-        hashable >=1.4 && <1.6,
-        hashtables >=1.3 && <1.5,
-        hexml >=0.3.4 && <0.4,
-        primitive >=0.8 && <0.10,
-        text >=2.0 && <2.2,
-        text-short >=0.1.5 && <0.2,
-        unordered-containers >=0.2.19 && <0.3,
-        vector >=0.13 && <0.14
+        base ^>=4.22.0.0,
+        bytestring ^>=0.12,
+        colonnade ^>=1.2,
+        containers ^>=0.8,
+        effectful-core ^>=2.6,
+        ghc-compact ^>=0.1,
+        hashable ^>=1.5,
+        hashtables ^>=1.4,
+        hexml ^>=0.3.5,
+        primitive ^>=0.9,
+        text ^>=2.1,
+        text-short ^>=0.1.6,
+        unordered-containers ^>=0.2,
+        vector ^>=0.13
     hs-source-dirs:   lib
 
 benchmark nano-ui-id-bench
@@ -189,7 +181,7 @@
     type:             exitcode-stdio-1.0
     main-is:          IdBench.hs
     build-depends:
-        tasty-bench >=0.3 && <0.6
+        tasty-bench ^>=0.4
     hs-source-dirs:   benchmark
 
 test-suite text-buffer-spec
@@ -197,8 +189,8 @@
     type:             exitcode-stdio-1.0
     main-is:          NanoUI/TextBufferSpec.hs
     build-depends:
-        hspec >=2.10 && <2.12,
-        text >=2.0 && <2.2
+        hspec ^>=2.11,
+        text ^>=2.1
     hs-source-dirs:   test
 
 test-suite nano-ui-test
@@ -240,10 +232,10 @@
     build-depends:
         bytestring,
         containers,
-        effectful >=2.5 && <2.8,
+        effectful ^>=2.6,
         nothunks,
-        primitive >=0.8 && <0.10,
-        text >=2.0 && <2.2
+        primitive ^>=0.9,
+        text ^>=2.1
     hs-source-dirs:   test/integration
 
 executable nano-ui-profile
@@ -252,8 +244,8 @@
     main-is:          Profile.hs
     hs-source-dirs:   examples
     build-depends:
-        primitive >=0.8 && <0.10,
-        text >=2.0 && <2.2
+        primitive ^>=0.9,
+        text ^>=2.1
 
 -- Compile-time verification of optimization invariants (inspection-testing).
 -- Assertions fail the build if GHC stops inlining or starts allocating in the
diff --git a/test/integration/Cases/ContextMenu.hs b/test/integration/Cases/ContextMenu.hs
--- a/test/integration/Cases/ContextMenu.hs
+++ b/test/integration/Cases/ContextMenu.hs
@@ -1,6 +1,7 @@
 module Cases.ContextMenu
   ( runContextMenuOpenTest
   , runContextMenuScrollPosTest
+  , runContextMenuDisabledRowTest
   ) where
 
 import Control.Monad (void)
@@ -106,3 +107,28 @@
           _ <- runFrame ctx inp0 ui
           spansAfter <- collectOverlayTextSpans ctx inp0
           assert failed (not (any (\(_, txt, _, _, _) -> "Scroll Cut" `T.isInfixOf` txt) spansAfter))
+
+-- | A disabled row lines up with the enabled rows around it: its label starts
+-- at the same x and it takes the same row height.
+runContextMenuDisabledRowTest :: Context -> IORef Int -> IO ()
+runContextMenuDisabledRowTest ctx failed = do
+  let inp0 = withInput 640 480
+      ui = column $ do
+        btn <- button' "Target Button"
+        _ <- contextMenu btn $ do
+          _ <- menuItem "Row Cut"
+          menuItemDisabled "Row Paste"
+          menuItem "Row Undo"
+        pure (btn, ())
+  (btnWarm, _) <- warmup2 ctx inp0 ui
+  let (inpRightDown, inpRightUp) = rightClickPair inp0 (centerOf btnWarm)
+  _ <- runFrame ctx inpRightDown ui
+  _ <- runFrame ctx inpRightUp ui
+  _ <- runFrame ctx inp0 ui
+  spans <- collectOverlayTextSpans ctx inp0
+  let find t = [r | (r, txt, _, _, _) <- spans, txt == t]
+  case (find "Row Cut", find "Row Paste", find "Row Undo") of
+    ([cut], [paste], [undo]) -> do
+      assert failed (abs (rectX paste - rectX cut) < 0.5)
+      assert failed (abs ((rectY paste - rectY cut) - (rectY undo - rectY paste)) < 0.5)
+    _ -> assert failed False
diff --git a/test/integration/Cases/Grid.hs b/test/integration/Cases/Grid.hs
--- a/test/integration/Cases/Grid.hs
+++ b/test/integration/Cases/Grid.hs
@@ -3,6 +3,7 @@
   , runNestedGridTest
   , runStaleFontColorTest
   , runFontCompositionTest
+  , runAlignBaselineTest
   ) where
 
 import Control.Monad (void)
@@ -113,4 +114,33 @@
       assertEq failed (textNodeFontWeight b) WeightBold
       assertEq failed (textNodeFontStyle i) FontStyleItalic
       assertEq failed (textNodeTextDecoration u) DecorationUnderline
+    _ -> assert failed False
+
+-- | Labels of different sizes, a button and a padded column holding a label all
+-- share one baseline on a baseline-aligned row: the button by its label and the
+-- column by its first child, not its larger last one. The row is tall enough
+-- to hold them, so what follows starts below all of them. The test font's
+-- ascent is 0.8 of its line height.
+runAlignBaselineTest :: Context -> IORef Int -> IO ()
+runAlignBaselineTest ctx failed = do
+  let ui = column $ do
+        rowWith (tight . gap 8) $ do
+          void $ labelWith (tight . alignBaseline . fontSize 32) "Big"
+          void $ labelWith (tight . alignBaseline) "small"
+          void $ buttonWith alignBaseline "Go"
+          columnWith (tight . alignBaseline . padXY 0 5) $ do
+            void $ label "nested"
+            void $ labelWith (tight . fontSize 24) "second"
+        void $ label "after"
+  _ <- runFrame ctx (withInput 400 200) ui
+  spans <- collectTextSpans ctx
+  case mapM (`spanOf` spans) ["Big", "small", "Go", "nested", "second", "after"] of
+    Just [(big, _), (small, _), (go, _), (nested, _), (second, _), (after, _)] -> do
+      let baseline r = rectY r + 0.8 * rectH r
+          near r = abs (baseline r - baseline big) < 0.5
+      assert failed (near small)
+      assert failed (near go)
+      assert failed (near nested)
+      assert failed (rectY small > rectY big)
+      assert failed (rectY after >= maximum [rectY r + rectH r | r <- [big, small, go, second]])
     _ -> assert failed False
diff --git a/test/integration/Cases/HostDraw.hs b/test/integration/Cases/HostDraw.hs
--- a/test/integration/Cases/HostDraw.hs
+++ b/test/integration/Cases/HostDraw.hs
@@ -1,6 +1,7 @@
 module Cases.HostDraw
   ( runSquareGeometryTest
   , runExternalTextTest
+  , runConcentricCirclesTest
   ) where
 
 import Control.Monad (forM, void)
@@ -10,7 +11,7 @@
 import Foreign.Ptr (Ptr)
 import Foreign.Storable (peekByteOff)
 import NanoUI
-import NanoUI.Context (setDrawExternalText, setDrawSquareGeometry)
+import NanoUI.Context (setDrawExternalText, setDrawSnapScale, setDrawSquareGeometry)
 import NanoUI.Testing
 import NanoUI.Testing.Assert (assert, assertEq, withInput)
 
@@ -67,3 +68,44 @@
   assertEq failed (drawVertexCount dLong) (drawVertexCount dShort)
   assert failed (any (\(_, t, _, _, _) -> t == "abcdefghijklmnop") spans)
   setDrawExternalText ctx False
+
+-- | Circles sharing a centre stay concentric at a fractional centre, filled
+-- or stroked, whatever their radii (regression: the bounding box's origin
+-- was snapped to the pixel grid, so @cx - radius@ rounded differently per
+-- radius and a small disc drawn over a larger one sat off-centre).
+runConcentricCirclesTest :: Context -> IORef Int -> IO ()
+runConcentricCirclesTest ctx failed = do
+  let inp = withInput 200 100
+      ui = void $ customWidget defaultCustomWidgetSpec
+        { widgetLayout = fixedWH 120 60 defaultLayout
+        , widgetDraw = \_ r -> runCanvas $ do
+            let fill = V2 (rectX r + 20.3) (rectY r + 20.3)
+                ring = V2 (rectX r + 60.7) (rectY r + 20.2)
+            drawCircle fill 6 (colorRGBA 255 0 0 255)
+            drawCircle fill 4.5 (colorRGBA 0 255 0 255)
+            drawStrokeCircle ring 6 1.5 (colorRGBA 0 0 255 255)
+            drawCircle ring 2.5 (colorRGBA 255 255 0 255)
+        }
+  setDrawSnapScale ctx 1
+  (_, _, dd, _) <- runFrame ctx inp ui
+  setDrawSnapScale ctx 0
+  verts <- vertexColours dd
+  let centreOf rgb = case [(x, y) | (x, y, c) <- verts, c == rgb] of
+        [] -> Nothing
+        ps ->
+          let xs = map fst ps
+              ys = map snd ps
+           in Just ((minimum xs + maximum xs) / 2, (minimum ys + maximum ys) / 2)
+      concentric a b = case (centreOf a, centreOf b) of
+        (Just (ax, ay), Just (bx, by)) -> abs (ax - bx) < 1e-3 && abs (ay - by) < 1e-3
+        _ -> False
+  assert failed (concentric (1, 0, 0) (0, 1, 0))
+  assert failed (concentric (0, 0, 1) (1, 1, 0))
+
+-- | Position and colour, without alpha, of every vertex.
+vertexColours :: DrawData -> IO [(Float, Float, (Float, Float, Float))]
+vertexColours dd =
+  withForeignPtr (drawVertices dd) $ \vp ->
+    forM [0 .. drawVertexCount dd - 1] $ \i -> do
+      let at o = peekByteOff vp (i * vertexSize + o) :: IO Float
+      (,,) <$> at 0 <*> at 4 <*> ((,,) <$> at 8 <*> at 12 <*> at 16)
diff --git a/test/integration/Cases/Modal.hs b/test/integration/Cases/Modal.hs
--- a/test/integration/Cases/Modal.hs
+++ b/test/integration/Cases/Modal.hs
@@ -4,6 +4,7 @@
   , runModalOverlayTest
   , runModalFitsTextTest
   , runModalFractionalScaleNoScrollTest
+  , runModalFillLabelFitsTest
   ) where
 
 import Control.Monad (forM_, when)
@@ -169,3 +170,39 @@
       whole = [r | (r, t, _, _, _) <- spans, t == sentence]
   assertEq failed (length whole) 1
   forM_ whole $ \(Rect _ _ tw _) -> assertGt failed (dw + 0.5) tw
+
+-- A modal fits a body with a filling label set in a smaller font than the
+-- base, with its last row in view (regression: placing the modal measured
+-- every label in the base font, so the smaller label, sized for its own font,
+-- wrapped onto a second line the modal had not measured; the modal scrolled
+-- and clipped its buttons).
+runModalFillLabelFitsTest :: Context -> IORef Int -> IO ()
+runModalFillLabelFitsTest _ failed = forM_ [12, 17] $ \base -> do
+  ctx <- (`withFontMetrics` monospaceMetrics base) <$> newContext
+  -- Wide enough for the label on one line in its own font, but not in the
+  -- base font.
+  let inp = withInput 2000 800
+      body =
+        columnWith (gap 14 . minW 560 . \l -> l {layoutPadding = Padding 0 0 0 0}) $ do
+          labelWith (tight . fillW) "How cabal should log in to Hackage for uploads:"
+          _ <- radio ["cabal's config file (no login found in it)", "A username and password", "An API token"] (0 :: Int)
+          labelWith (tight . fillW . fontSize 14) "Kept in memory for this session only. The password goes to cabal on its standard input."
+          separator
+          rowWith (fillW . gap 8 . alignMid . tight) $ do
+            flex
+            _ <- button "Cancel"
+            button' "Use this login"
+      ui = modal True "Hackage login" body
+  (dlg, mOk) <- warmup2 ctx inp ui
+  spans0 <- collectOverlayTextSpans ctx inp
+  let wheel = inp {inputMousePos = centerOf dlg, inputScroll = V2 0 3}
+  _ <- runFrame ctx wheel ui
+  spans1 <- collectOverlayTextSpans ctx wheel
+  assert failed (not (null (spanYOf "An API token" spans0)))
+  assertEq failed (spanYOf "An API token" spans1) (spanYOf "An API token" spans0)
+  case mOk of
+    Nothing -> assert failed False
+    Just ok -> do
+      let Rect _ dy _ dh = respRect dlg
+          Rect _ by _ bh = respRect ok
+      assertGt failed (dy + dh + 0.5) (by + bh)
diff --git a/test/integration/Cases/Scroll.hs b/test/integration/Cases/Scroll.hs
--- a/test/integration/Cases/Scroll.hs
+++ b/test/integration/Cases/Scroll.hs
@@ -17,6 +17,7 @@
   , runScrollMetricsTest
   , runScrollIntoViewTest
   , runScrollGlideClampTest
+  , runScroll2DGrowMinWidthTest
   ) where
 
 import Control.Monad (forM, forM_, replicateM, replicateM_, void, when)
@@ -720,3 +721,29 @@
         Just m -> do
           assertGt failed (v2Y (scrollRange m)) 0
           assert failed (off <= v2Y (scrollRange m) + 0.5)
+
+-- A grow cell with its own minimum width counts as that minimum in a 2D
+-- scroller, not as its widest label: the row fits once the window clears the
+-- cell's minimum and the fixed cells beside it. Without a minimum, a grow
+-- wrapper's content still counts, so wide content keeps its sideways scroll.
+runScroll2DGrowMinWidthTest :: Context -> IORef Int -> IO ()
+runScroll2DGrowMinWidthTest ctx failed = do
+  let longName = T.replicate 12 (T.pack "long name ")
+      rows cell =
+        scrollArea2D (fillW . fixedH 120) $
+          columnWith (fillW . tight) $
+            replicateM_ 3 $
+              rowWith (fillW . gap 10 . tight) $ do
+                void cell
+                rowWith (fixedW 80 . tight) (label (T.pack "size"))
+      minCell = columnWith (grow . minW 100 . tight) (label longName)
+      rangeX inp ui = do
+        (wid, ()) <- warmup2 ctx inp ui
+        maybe (-1) (v2X . scrollRange) <$> getScrollMetrics ctx wid
+  -- The row's least width is 100 + 10 + 80 = 190: fits at 400, not at 150.
+  wide <- rangeX (withInput 400 200) (rows minCell)
+  assertEq failed wide 0
+  narrow <- rangeX (withInput 150 200) (rows minCell)
+  assertGt failed narrow 0
+  wrapped <- rangeX (withInput 400 200) (rows (columnWith (grow . tight) (rowWith (fixedW 600 . tight) (label (T.pack "wide")))))
+  assertGt failed wrapped 0
diff --git a/test/integration/Main.hs b/test/integration/Main.hs
--- a/test/integration/Main.hs
+++ b/test/integration/Main.hs
@@ -85,9 +85,11 @@
   , TestSpec "grid-nested" False runNestedGridTest
   , TestSpec "stale-font-color" False runStaleFontColorTest
   , TestSpec "font-composition" True runFontCompositionTest
+  , TestSpec "align-baseline" True runAlignBaselineTest
   -- Drawing
   , TestSpec "draw-square-geometry" False runSquareGeometryTest
   , TestSpec "draw-external-text" False runExternalTextTest
+  , TestSpec "draw-concentric-circles" False runConcentricCirclesTest
   , TestSpec "drawing" False runDrawingTest
   , TestSpec "image" False runImageTest
   , TestSpec "rich-text-wrap" False runRichTextWrapTest
@@ -222,6 +224,7 @@
   , TestSpec "scroll-button-click" False runScrollButtonClickTest
   , TestSpec "scroll-scrolled-out" False runScrolledOutImmunityTest
   , TestSpec "scroll-lockstep-probe" False runScrollLockstepProbeTest
+  , TestSpec "scroll-2d-grow-min-width" False runScroll2DGrowMinWidthTest
   , TestSpec "page-scroll-backdrop-coverage" False runPageScrollBackdropCoverageTest
   , TestSpec "scroll-2d-pad-fill-overflow" True run2DPadFillOverflowTest
   , TestSpec "scroll-2d-pad-overflow-scrolls" True run2DPadOverflowScrollsTest
@@ -258,6 +261,7 @@
   , TestSpec "modal-no-phantom-scroll" False runModalNoPhantomScrollTest
   , TestSpec "modal-close-damage" False runModalCloseDamageTest
   , TestSpec "modal-fractional-scale-no-scroll" False runModalFractionalScaleNoScrollTest
+  , TestSpec "modal-fill-label-fits" False runModalFillLabelFitsTest
   , TestSpec "window-overlay" False runWindowOverlayTest
   , TestSpec "overlay-sibling-state" False runOverlaySiblingStateTest
   , TestSpec "overlay-click-through" False runOverlayClickThroughTest
@@ -276,6 +280,7 @@
   -- Context menus and tooltips
   , TestSpec "context-menu-open" False runContextMenuOpenTest
   , TestSpec "context-menu-scroll-pos" False runContextMenuScrollPosTest
+  , TestSpec "context-menu-disabled-row" False runContextMenuDisabledRowTest
   , TestSpec "release-elsewhere" False runReleaseElsewhereTest
   , TestSpec "right-release-elsewhere" False runRightReleaseElsewhereTest
   , TestSpec "release-returns" False runReleaseReturnsTest
