diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -66,9 +66,9 @@
     , ("region", fromList (["INLAND","NEAR BAY","INLAND","NEAR OCEAN","ISLAND","INLAND","NEAR BAY","INLAND"] :: [Text]))
     ]
 
-income = F.col @Double "income"
-value  = F.col @Double "value"
-region = F.col @Text   "region"
+income = F.double "income"
+value  = F.double "value"
+region = F.text   "region"
 
 -- show a Vega-Lite spec without its (verbose) inlined data.
 -- Returns String so scripths prints it raw rather than show-escaped.
diff --git a/dataframe-viz.cabal b/dataframe-viz.cabal
--- a/dataframe-viz.cabal
+++ b/dataframe-viz.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.4
 name:               dataframe-viz
-version:            1.3.2.1
+version:            1.4.0.0
 synopsis:           Visualisation/plotting helpers for the dataframe ecosystem.
 description:
     Display harness plus terminal and web plotters. Built on top of
@@ -37,7 +37,7 @@
     build-depends:      base >= 4 && < 5,
                         aeson >= 0.11.0.0 && < 3,
                         containers >= 0.6.7 && < 0.10,
-                        dataframe-core >= 2.6 && < 2.7,
+                        dataframe-core >= 2.7 && < 2.8,
                         directory >= 1.3.0.0 && < 2,
                         granite >= 0.6 && < 1,
                         process ^>= 1.6,
diff --git a/src/DataFrame/Display.hs b/src/DataFrame/Display.hs
--- a/src/DataFrame/Display.hs
+++ b/src/DataFrame/Display.hs
@@ -22,3 +22,6 @@
 -- | Render a 'DataFrame' to stdout according to 'DisplayOptions'.
 display :: DisplayOptions -> D.DataFrame -> IO ()
 display opts = T.putStrLn . D.asTextWith Plain (displayTruncate opts)
+
+displayRows :: Int -> D.DataFrame -> IO ()
+displayRows n = T.putStrLn . D.asTextWith Plain (Just D.defaultTruncateConfig{D.maxRows = n})
diff --git a/src/DataFrame/Display/Internal/VegaLite.hs b/src/DataFrame/Display/Internal/VegaLite.hs
--- a/src/DataFrame/Display/Internal/VegaLite.hs
+++ b/src/DataFrame/Display/Internal/VegaLite.hs
@@ -51,7 +51,13 @@
 import Type.Reflection (TypeRep, tyConName, typeRep, typeRepTyCon, pattern App)
 
 import Control.Exception (throw)
-import DataFrame.Internal.Column (Column, Columnable)
+import DataFrame.Internal.Column (
+    Column (MergedColumn),
+    Columnable,
+    bitmapTestBit,
+    columnBitmap,
+    materializeMerged,
+ )
 import DataFrame.Internal.DataFrame (DataFrame, dataframeDimensions, getColumn)
 import DataFrame.Internal.Expression (Expr (Col))
 import DataFrame.Internal.Interpreter (Ctx (..), eval, materialize)
@@ -223,9 +229,17 @@
     v <- eval (FlatCtx df) expr
     pure $ materialize @a (fst (dataframeDimensions df)) v
 
+-- | A column's values as JSON, with null slots as 'Null'.
 columnToValues :: FieldType -> Column -> [Value]
-columnToValues Quantitative col = map toJSON (columnToDoubles col)
-columnToValues _ col = map (toJSON :: T.Text -> Value) (columnToStrings col)
+columnToValues ft col = case col of
+    MergedColumn _ _ -> columnToValues ft (materializeMerged col)
+    _ -> maskNulls (raw ft)
+  where
+    raw Quantitative = map toJSON (columnToDoubles col)
+    raw _ = map (toJSON :: T.Text -> Value) (columnToStrings col)
+    maskNulls vs = case columnBitmap col of
+        Nothing -> vs
+        Just bm -> zipWith (\i v -> if bitmapTestBit bm i then v else Null) [0 ..] vs
 
 -- | A nominal field built directly from text values (for pre-computed data).
 textField :: T.Text -> [T.Text] -> ResolvedField
@@ -278,7 +292,9 @@
 channelValue e =
     object $
         catMaybes
-            [ if T.null (ceField e) then Nothing else Just ("field" .= ceField e)
+            [ if T.null (ceField e)
+                then Nothing
+                else Just ("field" .= fieldRef (ceField e))
             , Just ("type" .= fieldTypeName (ceType e))
             , fmap ("aggregate" .=) (ceAggregate e)
             , if ceBin e then Just ("bin" .= True) else Nothing
@@ -307,9 +323,17 @@
 
 transformValue :: Transform -> Value
 transformValue (RegressionT yField xField) =
-    object ["regression" .= yField, "on" .= xField]
+    object ["regression" .= fieldRef yField, "on" .= fieldRef xField]
 transformValue (DensityT field) =
-    object ["density" .= field]
+    object ["density" .= fieldRef field]
+
+-- | A column as a Vega-Lite field; escapes @.@, @[@, @]@ (else nested).
+fieldRef :: T.Text -> T.Text
+fieldRef = T.concatMap esc
+  where
+    esc c
+        | c `elem` ['.', '[', ']', '\\'] = T.pack ['\\', c]
+        | otherwise = T.singleton c
 
 -- | Build the @data.values@ array of row objects, deduplicating fields by name.
 inlineRows :: [ResolvedField] -> Value
diff --git a/src/DataFrame/Display/Web/Chart/Typed.hs b/src/DataFrame/Display/Web/Chart/Typed.hs
--- a/src/DataFrame/Display/Web/Chart/Typed.hs
+++ b/src/DataFrame/Display/Web/Chart/Typed.hs
@@ -1,6 +1,7 @@
 {-# LANGUAGE DataKinds #-}
 {-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE KindSignatures #-}
+{-# LANGUAGE TypeOperators #-}
 
 -- | Schema-checked typed-expression analogue of "DataFrame.Display.Web.Chart".
 module DataFrame.Display.Web.Chart.Typed (
@@ -58,10 +59,11 @@
  )
 import qualified DataFrame.Display.Web.Chart as C
 import DataFrame.Internal.Column (Columnable)
+import DataFrame.Internal.Expression.Operators.Nullable (BaseType)
 import DataFrame.Typed.Types (TExpr (..), TypedDataFrame (..))
 import GHC.TypeLits (Symbol)
 
--- | A typed chart: a phantom-@cols@ wrapper over the untyped builder.
+-- | A typed chart over the untyped builder; encoded exprs must be non-null.
 newtype Chart (cols :: [(Symbol, Type)]) = Chart C.Chart
 
 -- | Start a typed chart from a typed frame.
@@ -73,12 +75,14 @@
 mark m (Chart c) = Chart (C.mark m c)
 
 -- | Encode a typed expression on a channel (field type inferred from its type).
-enc :: (Columnable a) => Channel -> TExpr cols a -> Chart cols -> Chart cols
+enc ::
+    (Columnable a, a ~ BaseType a) =>
+    Channel -> TExpr cols a -> Chart cols -> Chart cols
 enc ch te (Chart c) = Chart (C.enc ch (unTExpr te) c)
 
 -- | Like 'enc' but force the Vega-Lite field type.
 encAs ::
-    (Columnable a) =>
+    (Columnable a, a ~ BaseType a) =>
     Channel -> TExpr cols a -> FieldType -> Chart cols -> Chart cols
 encAs ch te ft (Chart c) = Chart (C.encAs ch (unTExpr te) ft c)
 
@@ -100,12 +104,18 @@
 includeZero ch b (Chart c) = Chart (C.includeZero ch b c)
 
 -- | Facet into small multiples by a column (alias for 'column').
-facet :: (Columnable a) => TExpr cols a -> Chart cols -> Chart cols
+facet ::
+    (Columnable a, a ~ BaseType a) => TExpr cols a -> Chart cols -> Chart cols
 facet = column
 
--- | Facet across columns / down rows.
-column, row :: (Columnable a) => TExpr cols a -> Chart cols -> Chart cols
+-- | Facet across columns.
+column ::
+    (Columnable a, a ~ BaseType a) => TExpr cols a -> Chart cols -> Chart cols
 column te (Chart c) = Chart (C.column (unTExpr te) c)
+
+-- | Facet down rows.
+row ::
+    (Columnable a, a ~ BaseType a) => TExpr cols a -> Chart cols -> Chart cols
 row te (Chart c) = Chart (C.row (unTExpr te) c)
 
 -- | Set the chart title.
@@ -122,12 +132,13 @@
 
 -- | Add a least-squares regression line fitting @y@ on @x@.
 regression ::
-    (Columnable a, Columnable b) =>
+    (Columnable a, Columnable b, a ~ BaseType a, b ~ BaseType b) =>
     TExpr cols a -> TExpr cols b -> Chart cols -> Chart cols
 regression xE yE (Chart c) = Chart (C.regression (unTExpr xE) (unTExpr yE) c)
 
 -- | Kernel-density estimate of an expression, drawn as an area.
-density :: (Columnable a) => TExpr cols a -> Chart cols -> Chart cols
+density ::
+    (Columnable a, a ~ BaseType a) => TExpr cols a -> Chart cols -> Chart cols
 density e (Chart c) = Chart (C.density (unTExpr e) c)
 
 -- | The Vega-Lite spec as an aeson 'Value' (escape hatch, e.g. for hvega).
@@ -144,28 +155,30 @@
 
 -- | Scatter plot of two typed expressions.
 scatter ::
-    (Columnable a, Columnable b) =>
+    (Columnable a, Columnable b, a ~ BaseType a, b ~ BaseType b) =>
     TExpr cols a -> TExpr cols b -> TypedDataFrame cols -> IO ()
 scatter xE yE tdf = C.scatter (unTExpr xE) (unTExpr yE) (unTDF tdf)
 
--- | Count of rows per category, as bars.
+-- | Count of rows per category, as bars; a nullable column counts its nulls.
 bar :: (Columnable a) => TExpr cols a -> TypedDataFrame cols -> IO ()
 bar xE tdf = C.bar (unTExpr xE) (unTDF tdf)
 
 -- | Histogram of a numeric expression.
-histogram :: (Columnable a) => TExpr cols a -> TypedDataFrame cols -> IO ()
+histogram ::
+    (Columnable a, a ~ BaseType a) => TExpr cols a -> TypedDataFrame cols -> IO ()
 histogram xE tdf = C.histogram (unTExpr xE) (unTDF tdf)
 
 -- | Line chart of @y@ over @x@.
 line ::
-    (Columnable a, Columnable b) =>
+    (Columnable a, Columnable b, a ~ BaseType a, b ~ BaseType b) =>
     TExpr cols a -> TExpr cols b -> TypedDataFrame cols -> IO ()
 line xE yE tdf = C.line (unTExpr xE) (unTExpr yE) (unTDF tdf)
 
--- | Pie chart counting rows per category.
+-- | Pie chart counting rows per category; a nullable column counts its nulls.
 pie :: (Columnable a) => TExpr cols a -> TypedDataFrame cols -> IO ()
 pie cE tdf = C.pie (unTExpr cE) (unTDF tdf)
 
 -- | Box-and-whisker plot of a numeric expression.
-box :: (Columnable a) => TExpr cols a -> TypedDataFrame cols -> IO ()
+box ::
+    (Columnable a, a ~ BaseType a) => TExpr cols a -> TypedDataFrame cols -> IO ()
 box yE tdf = C.box (unTExpr yE) (unTDF tdf)
