diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Revision history for granite
 
+## 0.7.0.0 -- 2026-05-24
+
+* `Color` is now a 24-bit RGB value, and categorical colour mappings are honoured for points.
+* Scatter plots now respect point colours.
+
+
 ## 0.5.0.0 -- 2026-05-13
 
 A major refactor introducing a declarative, Grammar-of-Graphics-style
diff --git a/app/GenerateTutorial.hs b/app/GenerateTutorial.hs
deleted file mode 100644
--- a/app/GenerateTutorial.hs
+++ /dev/null
@@ -1,796 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-
-{- |
-Regenerate @docs/tutorial.md@ from @docs/base/tutorial.md@ by
-rendering each @\<!-- chart: SLUG -->@ marker to an SVG under
-@docs/images/@ and inserting the image link after the code block.
--}
-module Main where
-
-import Control.Monad (forM_)
-import Data.List qualified as List
-import Data.Text (Text)
-import Data.Text qualified as Text
-import Data.Text.IO qualified as Text.IO
-import System.Directory (createDirectoryIfMissing)
-import System.FilePath ((</>))
-
-import Granite.Color (Color (..))
-import Granite.Data.Frame
-import Granite.Render.Pipeline (renderChartSvg)
-import Granite.Spec
-
-baseFile, outputFile, imagesDir :: FilePath
-baseFile = "docs/base/tutorial.md"
-outputFile = "docs/tutorial.md"
-imagesDir = "docs/images"
-
-main :: IO ()
-main = do
-    createDirectoryIfMissing True imagesDir
-    src <- Text.IO.readFile baseFile
-    let rendered = renderTutorial src
-    Text.IO.writeFile outputFile rendered
-    forM_ fixtures $ \(slug, chart) -> do
-        let svgPath = imagesDir </> Text.unpack slug <> ".svg"
-        Text.IO.writeFile svgPath (renderChartSvg chart)
-    putStrLn $ "Wrote " <> outputFile
-    putStrLn $ "Wrote " <> show (length fixtures) <> " SVGs under " <> imagesDir
-
-renderTutorial :: Text -> Text
-renderTutorial src = Text.unlines (go (Text.lines src))
-  where
-    go :: [Text] -> [Text]
-    go [] = []
-    go (line : rest) = case chartSlug line of
-        Just slug ->
-            let (block, afterBlock) = takeCodeBlock rest
-                output = renderChartOutput slug
-             in line : block <> output <> go afterBlock
-        Nothing -> line : go rest
-
-chartSlug :: Text -> Maybe Text
-chartSlug ln =
-    let stripped = Text.strip ln
-     in case Text.stripPrefix "<!-- chart:" stripped of
-            Just rest -> case Text.stripSuffix "-->" (Text.strip rest) of
-                Just slug -> Just (Text.strip slug)
-                Nothing -> Nothing
-            Nothing -> Nothing
-
-takeCodeBlock :: [Text] -> ([Text], [Text])
-takeCodeBlock = goOpen []
-  where
-    goOpen acc [] = (reverse acc, [])
-    goOpen acc (ln : rest)
-        | Text.isPrefixOf "```" (Text.stripStart ln) =
-            goClose (ln : acc) rest
-        | otherwise = goOpen (ln : acc) rest
-
-    goClose acc [] = (reverse acc, [])
-    goClose acc (ln : rest)
-        | Text.isPrefixOf "```" (Text.stripStart ln) =
-            (reverse (ln : acc), rest)
-        | otherwise = goClose (ln : acc) rest
-
-renderChartOutput :: Text -> [Text]
-renderChartOutput slug = case lookup slug fixtures of
-    Nothing ->
-        [ ""
-        , "> _(no fixture for chart slug `" <> slug <> "` — skipped)_"
-        ]
-    Just _chart ->
-        let imgRel = "images/" <> slug <> ".svg"
-         in [ ""
-            , "![" <> slug <> "](" <> imgRel <> ")"
-            ]
-
-fixtures :: [(Text, Chart)]
-fixtures =
-    [ ("scatter", scatterChart)
-    , ("line", lineChart)
-    , ("bar", barChart)
-    , ("bar-horizontal", barHorizontal)
-    , ("bar-stacked", barStacked)
-    , ("bar-grouped", barGrouped)
-    , ("pie", pieChart)
-    , ("area", areaChart)
-    , ("histogram", histChart)
-    , ("boxplot", boxChart)
-    , ("errorbar", errorbarChart)
-    , ("density", densityChart)
-    , ("distplot", distplotChart)
-    , ("heatmap", heatmapChart)
-    , ("heatmap-annotated", heatmapAnnotated)
-    , ("funnel", funnelChart)
-    , ("waterfall", waterfallChart)
-    , ("polar", polarChart)
-    , ("facet", facetChart)
-    , ("log-y", logYChart)
-    , ("layer-bar-line", barLineChart)
-    , ("layer-scatter-radius", scatterRadiusChart)
-    , ("layer-scatter-fit", scatterFitChart)
-    , ("layer-line-ribbon", lineRibbonChart)
-    ]
-
-scatterChart :: Chart
-scatterChart =
-    let xs = [0, 1, 2, 3, 4] :: [Double]
-        ys = [0, 1, 4, 9, 16] :: [Double]
-        df = fromColumns [("x", ColNum xs), ("y", ColNum ys)]
-        layer =
-            (defLayer GeomPoint)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        }
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "y = x^2"
-            , chartSize = SizeChars 40 14
-            }
-
-lineChart :: Chart
-lineChart =
-    let xs = [0, 0.5 .. 6.0] :: [Double]
-        ys = map sin xs
-        df = fromColumns [("x", ColNum xs), ("y", ColNum ys)]
-        layer =
-            (defLayer GeomLine)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        }
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "sin(x)"
-            , chartSize = SizeChars 50 14
-            }
-
-barChart :: Chart
-barChart =
-    let xs = ["A", "B", "C", "D"]
-        ys = [12, 7, 18, 9] :: [Double]
-        df = fromColumns [("x", ColCat xs), ("y", ColNum ys)]
-        layer =
-            (defLayer GeomBar)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        }
-                , layerStat = StatIdentity
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "Bars"
-            , chartSize = SizeChars 40 14
-            }
-
-barHorizontal :: Chart
-barHorizontal =
-    let xs = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"]
-        ys = [82, 67, 45, 33, 21] :: [Double]
-        df = fromColumns [("item", ColCat xs), ("value", ColNum ys)]
-        layer =
-            (defLayer GeomBar)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "item")
-                        , aesY = Just (ColumnRef "value")
-                        }
-                , layerStat = StatIdentity
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartCoord = CoordFlip
-            , chartTitle = Just "Top 5 (horizontal)"
-            , chartSize = SizeChars 60 16
-            }
-
-barStackedFrame :: DataFrame
-barStackedFrame =
-    let quarters = concat [replicate 3 q | q <- ["Q1", "Q2", "Q3", "Q4"]]
-        products = take 12 (cycle ["Widgets", "Gadgets", "Gizmos"])
-        sales = [12, 8, 4, 15, 10, 6, 18, 12, 8, 22, 14, 10] :: [Double]
-     in fromColumns
-            [ ("quarter", ColCat quarters)
-            , ("product", ColCat products)
-            , ("sales", ColNum sales)
-            ]
-
-barStacked :: Chart
-barStacked =
-    let layer =
-            (defLayer GeomBar)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "quarter")
-                        , aesY = Just (ColumnRef "sales")
-                        , aesGroup = Just (ColumnRef "product")
-                        , aesFill = Just (ColumnRef "product")
-                        }
-                , layerStat = StatIdentity
-                , layerPosition = PosStack
-                }
-     in emptyChart
-            { chartData = barStackedFrame
-            , chartLayers = [layer]
-            , chartTitle = Just "Sales by quarter (stacked)"
-            , chartSize = SizeChars 60 16
-            }
-
-barGrouped :: Chart
-barGrouped =
-    barStacked
-        { chartLayers =
-            [ (List.head (chartLayers barStacked))
-                { layerPosition = PosDodge 0.25
-                }
-            ]
-        , chartTitle = Just "Sales by quarter (grouped)"
-        }
-
-pieChart :: Chart
-pieChart =
-    let df =
-            fromColumns
-                [ ("slice", ColCat ["A", "B", "C", "D"])
-                , ("value", ColNum [40, 25, 20, 15])
-                ]
-        layer =
-            (defLayer GeomArc)
-                { layerMapping = emptyMapping{aesY = Just (ColumnRef "value")}
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "Pie"
-            , chartSize = SizeChars 30 16
-            }
-
-areaChart :: Chart
-areaChart =
-    let xs = [0 .. 12] :: [Double]
-        ymax = [sin (x / 2) + 2 | x <- xs]
-        zeros = replicate (length xs) 0 :: [Double]
-        df =
-            fromColumns
-                [ ("x", ColNum xs)
-                , ("ymin", ColNum zeros)
-                , ("ymax", ColNum ymax)
-                ]
-        layer =
-            (defLayer GeomRibbon)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesYmin = Just (ColumnRef "ymin")
-                        , aesYmax = Just (ColumnRef "ymax")
-                        }
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "Filled area"
-            , chartSize = SizeChars 56 14
-            }
-
-histChart :: Chart
-histChart =
-    let raw = take 50 (cycle [1.0, 1.2, 1.8, 2.0, 2.3, 2.7, 3.1, 3.4, 3.8, 4.2])
-        df = fromColumns [("x", ColNum raw)]
-        layer =
-            (defLayer GeomHistogram)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "count")
-                        }
-                , layerStat = StatBin (BinByCount 8)
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "Histogram"
-            , chartSize = SizeChars 44 14
-            }
-
-boxChart :: Chart
-boxChart =
-    let pts =
-            [("A", v) | v <- [1, 2, 3, 4, 5, 6, 7, 8, 9 :: Double]]
-                <> [("B", v) | v <- [3, 4, 5, 6, 7, 8, 9, 10, 11]]
-                <> [("C", v) | v <- [5, 6, 7, 8, 9, 10, 11, 12, 13]]
-        df =
-            fromColumns
-                [ ("g", ColCat [g | (g, _) <- pts])
-                , ("y", ColNum [v | (_, v) <- pts])
-                ]
-        layer =
-            (defLayer GeomBoxplot)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "g")
-                        , aesY = Just (ColumnRef "y")
-                        }
-                , layerStat = StatBoxplot
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "Boxplot"
-            , chartSize = SizeChars 40 16
-            }
-
-errorbarChart :: Chart
-errorbarChart =
-    let xs = [1 .. 5] :: [Double]
-        ys = [2.1, 3.5, 5.2, 4.7, 6.1] :: [Double]
-        los = [1.5, 2.8, 4.6, 4.0, 5.3] :: [Double]
-        his = [2.7, 4.2, 5.8, 5.4, 6.9] :: [Double]
-        df =
-            fromColumns
-                [ ("x", ColNum xs)
-                , ("y", ColNum ys)
-                , ("lo", ColNum los)
-                , ("hi", ColNum his)
-                ]
-        m =
-            emptyMapping
-                { aesX = Just (ColumnRef "x")
-                , aesY = Just (ColumnRef "y")
-                , aesYmin = Just (ColumnRef "lo")
-                , aesYmax = Just (ColumnRef "hi")
-                }
-        bars = (defLayer GeomErrorbar){layerMapping = m, layerStat = StatIdentity}
-        pts = (defLayer GeomPoint){layerMapping = m, layerStat = StatIdentity}
-     in emptyChart
-            { chartData = df
-            , chartLayers = [bars, pts]
-            , chartTitle = Just "Measurements +/- CI"
-            , chartSize = SizeChars 56 14
-            }
-
-densitySample :: [Double]
-densitySample =
-    [ 1.0
-    , 1.2
-    , 1.3
-    , 1.5
-    , 1.7
-    , 2.0
-    , 2.1
-    , 2.3
-    , 2.5
-    , 2.8
-    , 3.0
-    , 3.1
-    , 3.3
-    , 3.7
-    , 3.9
-    , 4.0
-    ]
-
-densityChart :: Chart
-densityChart =
-    let df = fromColumns [("x", ColNum densitySample)]
-        layer =
-            (defLayer GeomDensity)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "density")
-                        }
-                , layerStat = StatDensity
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "Density (KDE)"
-            , chartSize = SizeChars 56 14
-            }
-
-distplotChart :: Chart
-distplotChart =
-    let df = fromColumns [("x", ColNum densitySample)]
-        hist =
-            (defLayer GeomHistogram)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "count")
-                        }
-                , layerStat = StatBin (BinByCount 8)
-                }
-        kde =
-            (defLayer GeomDensity)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "density")
-                        }
-                , layerStat = StatDensity
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [hist, kde]
-            , chartTitle = Just "Distribution"
-            , chartSize = SizeChars 56 16
-            }
-
-heatmapCoords :: [(Double, Double, Double)]
-heatmapCoords =
-    [ ( fromIntegral x
-      , fromIntegral y
-      , sin (fromIntegral x / 2) + cos (fromIntegral y / 2)
-      )
-    | x <- [0 .. 4 :: Int]
-    , y <- [0 .. 4 :: Int]
-    ]
-
-heatmapChart :: Chart
-heatmapChart =
-    let df =
-            fromColumns
-                [ ("x", ColNum [x | (x, _, _) <- heatmapCoords])
-                , ("y", ColNum [y | (_, y, _) <- heatmapCoords])
-                , ("z", ColNum [z | (_, _, z) <- heatmapCoords])
-                ]
-        layer =
-            (defLayer GeomTile)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        , aesFill = Just (ColumnRef "z")
-                        }
-                , layerStat = StatIdentity
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "Heatmap"
-            , chartSize = SizeChars 48 16
-            }
-
-heatmapAnnotated :: Chart
-heatmapAnnotated =
-    let labels = [Text.pack (show (round (z * 10) :: Int)) | (_, _, z) <- heatmapCoords]
-        df =
-            fromColumns
-                [ ("x", ColNum [x | (x, _, _) <- heatmapCoords])
-                , ("y", ColNum [y | (_, y, _) <- heatmapCoords])
-                , ("z", ColNum [z | (_, _, z) <- heatmapCoords])
-                , ("label", ColCat labels)
-                ]
-        tile =
-            (defLayer GeomTile)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        , aesFill = Just (ColumnRef "z")
-                        }
-                , layerStat = StatIdentity
-                }
-        label =
-            (defLayer GeomText)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        , aesLabel = Just (ColumnRef "label")
-                        }
-                , layerStat = StatIdentity
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [tile, label]
-            , chartTitle = Just "Annotated heatmap"
-            , chartSize = SizeChars 48 16
-            }
-
-funnelChart :: Chart
-funnelChart =
-    let xs = ["Visited", "Signed up", "Confirmed", "Active", "Paid"]
-        ys = [1000, 720, 480, 220, 120] :: [Double]
-        df = fromColumns [("stage", ColCat xs), ("count", ColNum ys)]
-        layer =
-            (defLayer GeomBar)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "stage")
-                        , aesY = Just (ColumnRef "count")
-                        }
-                , layerStat = StatIdentity
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartCoord = CoordFlip
-            , chartTitle = Just "Sales funnel"
-            , chartSize = SizeChars 60 14
-            }
-
-waterfallChart :: Chart
-waterfallChart =
-    let xs = ["Start", "Q1", "Q2", "Q3", "Refund", "Net"]
-        ystart = [0, 100, 130, 180, 175, 0] :: [Double]
-        yend = [100, 130, 180, 175, 195, 195] :: [Double]
-        df =
-            fromColumns
-                [ ("x", ColCat xs)
-                , ("y", ColNum yend)
-                , ("__ybase", ColNum ystart)
-                ]
-        layer =
-            (defLayer GeomBar)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        }
-                , layerStat = StatIdentity
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartTitle = Just "Waterfall"
-            , chartSize = SizeChars 60 14
-            }
-
-polarChart :: Chart
-polarChart =
-    let nSteps = 32 :: Int
-        pts =
-            [ (theta, abs (sin (2 * theta)))
-            | i <- [0 .. nSteps]
-            , let theta = (fromIntegral i / fromIntegral nSteps) * 2 * pi
-            ]
-        df =
-            fromColumns
-                [ ("theta", ColNum [t | (t, _) <- pts])
-                , ("r", ColNum [r | (_, r) <- pts])
-                ]
-        layer =
-            (defLayer GeomLine)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "theta")
-                        , aesY = Just (ColumnRef "r")
-                        }
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartCoord = CoordPolar ThetaX 0 PolarCCW
-            , chartTitle = Just "r = |sin(2 theta)|"
-            , chartSize = SizeChars 40 20
-            }
-
-facetChart :: Chart
-facetChart =
-    let df =
-            fromColumns
-                [ ("x", ColNum [0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3])
-                , ("y", ColNum [1, 4, 9, 16, 0, 2, 4, 6, 5, 4, 3, 2])
-                , ("series", ColCat (replicate 4 "A" <> replicate 4 "B" <> replicate 4 "C"))
-                ]
-        layer =
-            (defLayer GeomPoint)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        }
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartFacet = FacetWrap (ColumnRef "series") (Just 3) Nothing ScalesFixed
-            , chartTitle = Just "Faceted by series"
-            , chartSize = SizeChars 70 18
-            }
-
-logYChart :: Chart
-logYChart =
-    let xs = [1 .. 6] :: [Double]
-        ys = [3, 30, 80, 200, 700, 2100] :: [Double]
-        df = fromColumns [("x", ColNum xs), ("y", ColNum ys)]
-        layer =
-            (defLayer GeomPoint)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        }
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [layer]
-            , chartScales = defScales{scaleY = SLog Base10 defScaleOpts}
-            , chartTitle = Just "Log Y"
-            , chartSize = SizeChars 50 14
-            }
-
-barLineChart :: Chart
-barLineChart =
-    let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
-        sales = [12, 18, 15, 24, 20, 28] :: [Double]
-        movavg = [12, 15, 16.5, 19.5, 22, 24] :: [Double]
-        df =
-            fromColumns
-                [ ("month", ColCat months)
-                , ("sales", ColNum sales)
-                , ("avg", ColNum movavg)
-                ]
-        bars =
-            (defLayer GeomBar)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "month")
-                        , aesY = Just (ColumnRef "sales")
-                        }
-                , layerStat = StatIdentity
-                }
-        trend =
-            (defLayer GeomLine)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "month")
-                        , aesY = Just (ColumnRef "avg")
-                        }
-                , layerAesDef =
-                    emptyAesDefaults
-                        { defColor = Just (NamedColor BrightRed)
-                        , defLineWidth = Just 2.5
-                        }
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [bars, trend]
-            , chartTitle = Just "Monthly sales + trend"
-            , chartSize = SizeChars 60 14
-            }
-
-scatterRadiusChart :: Chart
-scatterRadiusChart =
-    let pts =
-            [ (0.2, 0.5)
-            , (0.8, 1.2)
-            , (-0.5, 0.3)
-            , (1.1, -0.2)
-            , (1.8, 0.7)
-            , (-0.3, -0.6)
-            , (2.2, 1.5)
-            , (1.5, -1.1)
-            , (0.1, 2.1)
-            , (2.5, 0.3)
-            , (-1.0, 1.0)
-            , (0.7, -0.4)
-            ] ::
-                [(Double, Double)]
-        points =
-            fromColumns
-                [ ("lon", ColNum [x | (x, _) <- pts])
-                , ("lat", ColNum [y | (_, y) <- pts])
-                ]
-        poi =
-            fromColumns
-                [ ("lon", ColNum [0.5])
-                , ("lat", ColNum [0.5])
-                ]
-        radius =
-            (defLayer GeomPoint)
-                { layerData = Just poi
-                , layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "lon")
-                        , aesY = Just (ColumnRef "lat")
-                        }
-                , layerAesDef =
-                    emptyAesDefaults
-                        { defColor = Just (NamedColor BrightCyan)
-                        , defSize = Just 60
-                        , defAlpha = Just 0.25
-                        }
-                }
-        scatter =
-            (defLayer GeomPoint)
-                { layerData = Just points
-                , layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "lon")
-                        , aesY = Just (ColumnRef "lat")
-                        }
-                }
-     in emptyChart
-            { chartLayers = [radius, scatter]
-            , chartTitle = Just "Points near POI"
-            , chartSize = SizeChars 50 18
-            }
-
-scatterFitChart :: Chart
-scatterFitChart =
-    let xs = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] :: [Double]
-        noise = [0.3, -0.5, 0.7, -0.2, 0.4, -0.6, 0.1, 0.3, -0.4, 0.5]
-        ys = [2 * x + 1 + n | (x, n) <- zip xs noise]
-        df = fromColumns [("x", ColNum xs), ("y", ColNum ys)]
-        m =
-            emptyMapping
-                { aesX = Just (ColumnRef "x")
-                , aesY = Just (ColumnRef "y")
-                }
-        points = (defLayer GeomPoint){layerMapping = m}
-        fit =
-            (defLayer GeomLine)
-                { layerMapping = m
-                , layerStat = StatSmooth SmoothLm
-                , layerAesDef =
-                    emptyAesDefaults
-                        { defColor = Just (NamedColor BrightRed)
-                        , defLineWidth = Just 2
-                        }
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [points, fit]
-            , chartTitle = Just "Scatter + OLS fit"
-            , chartSize = SizeChars 50 14
-            }
-
-lineRibbonChart :: Chart
-lineRibbonChart =
-    let xs = [0, 0.5 .. 6.0] :: [Double]
-        ys = map sin xs
-        los = map (\x -> sin x - 0.3) xs
-        his = map (\x -> sin x + 0.3) xs
-        df =
-            fromColumns
-                [ ("x", ColNum xs)
-                , ("y", ColNum ys)
-                , ("lo", ColNum los)
-                , ("hi", ColNum his)
-                ]
-        ribbon =
-            (defLayer GeomRibbon)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesYmin = Just (ColumnRef "lo")
-                        , aesYmax = Just (ColumnRef "hi")
-                        }
-                , layerAesDef =
-                    emptyAesDefaults
-                        { defColor = Just (NamedColor BrightCyan)
-                        , defAlpha = Just 0.3
-                        }
-                }
-        line =
-            (defLayer GeomLine)
-                { layerMapping =
-                    emptyMapping
-                        { aesX = Just (ColumnRef "x")
-                        , aesY = Just (ColumnRef "y")
-                        }
-                , layerAesDef =
-                    emptyAesDefaults
-                        { defColor = Just (NamedColor BrightBlue)
-                        , defLineWidth = Just 2
-                        }
-                }
-     in emptyChart
-            { chartData = df
-            , chartLayers = [ribbon, line]
-            , chartTitle = Just "sin(x) +/- 0.3 band"
-            , chartSize = SizeChars 56 14
-            }
diff --git a/granite.cabal b/granite.cabal
--- a/granite.cabal
+++ b/granite.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               granite
-version:            0.6.0.0
+version:            0.7.0.0
 synopsis:           Easy terminal plotting.
 description:        A terminal plotting library for quick and easy visualisation.
 license:            MIT
@@ -10,7 +10,8 @@
                      || ==9.6.7
                      || ==9.8.4
                      || ==9.10.3
-                     || ==9.12.2
+                     || ==9.12.4
+                     || ==9.14.1
 
 maintainer:         mschavinda@gmail.com
 copyright:          (c) 2024-2025 Michael Chavinda
@@ -18,6 +19,8 @@
 category:           Graphics
 build-type:         Simple
 extra-doc-files:    CHANGELOG.md README.md
+extra-source-files: test/golden/*.txt
+                    test/golden/*.svg
 
 source-repository head
   type:     git
@@ -69,19 +72,7 @@
     main-is:          Main.hs
     build-depends:
         base >=4 && <5,
-        granite ^>= 0.6,
-        text >= 1 && < 3
-    hs-source-dirs:   app
-    default-language: GHC2021
-
-executable granite-tutorial
-    import: ghc-options
-    main-is:          GenerateTutorial.hs
-    build-depends:
-        base >=4 && <5,
-        directory >= 1.3 && < 1.4,
-        filepath >= 1.4 && < 1.6,
-        granite ^>= 0.6,
+        granite ^>= 0.7,
         text >= 1 && < 3
     hs-source-dirs:   app
     default-language: GHC2021
@@ -106,7 +97,7 @@
 
     build-depends:
         base >= 4.14 && < 5
-        , granite ^>= 0.6
+        , granite ^>= 0.7
         , text >= 1.2 && < 3
         , hspec >= 2.7 && < 2.12
         , QuickCheck >= 2.14 && < 3
diff --git a/src/Granite/Color.hs b/src/Granite/Color.hs
--- a/src/Granite/Color.hs
+++ b/src/Granite/Color.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PatternSynonyms #-}
 {-# LANGUAGE Strict #-}
 
 {- |
@@ -7,12 +8,44 @@
 License     : MIT
 Maintainer  : mschavinda@gmail.com
 
-Color primitives shared by both the terminal and SVG backends:
-the ANSI 16-color palette ('Color'), escape-code helpers, and the
-hex mapping used by the SVG backend.
+Color primitives shared by the terminal and SVG backends.
+
+'Color' is a 24-bit RGB value. The classic ANSI palette names ('Black',
+'BrightBlue', 'Default', …) are provided as bidirectional pattern synonyms
+that expand to fixed RGB triples, so they may be used both as expressions
+and in patterns exactly as before.
+
+The two backends deliberately diverge on how a colour is realised:
+
+  * the SVG backend renders the RGB exactly via 'colorHex';
+  * the terminal backend approximates via 'ansiCode', which maps an
+    arbitrary colour to the nearest of the 16 ANSI slots.
+
+Because 'Color' is now a plain RGB triple, its /derived/ 'Show' prints
+@Color r g b@ rather than the alias name.
 -}
 module Granite.Color (
-    Color (..),
+    Color (
+        ..,
+        Black,
+        Red,
+        Green,
+        Yellow,
+        Blue,
+        Magenta,
+        Cyan,
+        White,
+        BrightBlack,
+        BrightRed,
+        BrightGreen,
+        BrightYellow,
+        BrightBlue,
+        BrightMagenta,
+        BrightCyan,
+        BrightWhite,
+        Default
+    ),
+    parseHex,
     ansiCode,
     ansiOn,
     ansiOff,
@@ -22,30 +55,75 @@
     pieColors,
 ) where
 
+import Data.Char (digitToInt, isHexDigit)
+import Data.List (minimumBy)
+import Data.Ord (comparing)
 import Data.Text (Text)
 import Data.Text qualified as Text
+import Data.Word (Word8)
+import Numeric (showHex)
 
--- | Supported ANSI colo(u)rs.
-data Color
-    = Default
-    | Black
-    | Red
-    | Green
-    | Yellow
-    | Blue
-    | Magenta
-    | Cyan
-    | White
-    | BrightBlack
-    | BrightRed
-    | BrightGreen
-    | BrightYellow
-    | BrightBlue
-    | BrightMagenta
-    | BrightCyan
-    | BrightWhite
+-- | A 24-bit RGB colour.
+data Color = Color !Word8 !Word8 !Word8
     deriving (Eq, Show, Read)
 
+-- The classic ANSI palette, as bidirectional pattern-synonym aliases over
+-- fixed RGB triples (the historical 'colorHex' values).
+
+pattern Black :: Color
+pattern Black = Color 0x2c 0x3e 0x50
+
+pattern Red :: Color
+pattern Red = Color 0xc0 0x39 0x2b
+
+pattern Green :: Color
+pattern Green = Color 0x27 0xae 0x60
+
+pattern Yellow :: Color
+pattern Yellow = Color 0xf3 0x9c 0x12
+
+pattern Blue :: Color
+pattern Blue = Color 0x29 0x80 0xb9
+
+pattern Magenta :: Color
+pattern Magenta = Color 0x8e 0x44 0xad
+
+pattern Cyan :: Color
+pattern Cyan = Color 0x16 0xa0 0x85
+
+pattern White :: Color
+pattern White = Color 0xec 0xf0 0xf1
+
+pattern BrightBlack :: Color
+pattern BrightBlack = Color 0x7f 0x8c 0x8d
+
+pattern BrightRed :: Color
+pattern BrightRed = Color 0xe7 0x4c 0x3c
+
+pattern BrightGreen :: Color
+pattern BrightGreen = Color 0x2e 0xcc 0x71
+
+pattern BrightYellow :: Color
+pattern BrightYellow = Color 0xf1 0xc4 0x0f
+
+pattern BrightBlue :: Color
+pattern BrightBlue = Color 0x34 0x98 0xdb
+
+pattern BrightMagenta :: Color
+pattern BrightMagenta = Color 0x9b 0x59 0xb6
+
+pattern BrightCyan :: Color
+pattern BrightCyan = Color 0x1a 0xbc 0x9c
+
+pattern BrightWhite :: Color
+pattern BrightWhite = Color 0xbd 0xc3 0xc7
+
+pattern Default :: Color
+pattern Default = Color 0x55 0x55 0x55
+
+{- | ANSI escape code for a colour. The 16 named slots (plus 'Default') return
+their canonical code; any other colour maps to the nearest ANSI slot.
+-}
 ansiCode :: Color -> Int
 ansiCode Black = 30
 ansiCode Red = 31
@@ -64,10 +142,48 @@
 ansiCode BrightCyan = 96
 ansiCode BrightWhite = 97
 ansiCode Default = 39
+ansiCode (Color r g b) = nearestAnsiCode r g b
 
+{- | Map an arbitrary RGB colour to the nearest of the 16 ANSI slots, using the
+canonical (VGA) ANSI palette as anchors — kept consistent with the terminal
+chrome quantisation in "Granite.Render.Chrome".
+-}
+nearestAnsiCode :: Word8 -> Word8 -> Word8 -> Int
+nearestAnsiCode r g b =
+    snd
+        (minimumBy (comparing fst) [(dist anchor, code) | (anchor, code) <- ansiAnchors])
+  where
+    ri = fromIntegral r :: Int
+    gi = fromIntegral g
+    bi = fromIntegral b
+    dist (ar, ag, ab) = sq (ar - ri) + sq (ag - gi) + sq (ab - bi)
+    sq x = x * x
+
+ansiAnchors :: [((Int, Int, Int), Int)]
+ansiAnchors =
+    [ ((0, 0, 0), 30)
+    , ((170, 0, 0), 31)
+    , ((0, 170, 0), 32)
+    , ((170, 85, 0), 33)
+    , ((0, 0, 170), 34)
+    , ((170, 0, 170), 35)
+    , ((0, 170, 170), 36)
+    , ((170, 170, 170), 37)
+    , ((85, 85, 85), 90)
+    , ((255, 85, 85), 91)
+    , ((85, 255, 85), 92)
+    , ((255, 255, 85), 93)
+    , ((85, 85, 255), 94)
+    , ((255, 85, 255), 95)
+    , ((85, 255, 255), 96)
+    , ((255, 255, 255), 97)
+    ]
+
+-- | Opening ANSI escape sequence selecting a colour (its nearest ANSI slot).
 ansiOn :: Color -> Text
 ansiOn c = "\ESC[" <> Text.pack (show (ansiCode c)) <> "m"
 
+-- | ANSI reset escape sequence.
 ansiOff :: Text
 ansiOff = "\ESC[0m"
 
@@ -75,25 +191,27 @@
 paint :: Color -> Char -> Text
 paint c ch = if ch == ' ' then " " else ansiOn c <> Text.singleton ch <> ansiOff
 
--- | Hex RGB approximation of the ANSI palette, used by the SVG backend.
+-- | Exact @#rrggbb@ rendering of a colour, used by the SVG backend.
 colorHex :: Color -> Text
-colorHex Default = "#555555"
-colorHex Black = "#2c3e50"
-colorHex Red = "#c0392b"
-colorHex Green = "#27ae60"
-colorHex Yellow = "#f39c12"
-colorHex Blue = "#2980b9"
-colorHex Magenta = "#8e44ad"
-colorHex Cyan = "#16a085"
-colorHex White = "#ecf0f1"
-colorHex BrightBlack = "#7f8c8d"
-colorHex BrightRed = "#e74c3c"
-colorHex BrightGreen = "#2ecc71"
-colorHex BrightYellow = "#f1c40f"
-colorHex BrightBlue = "#3498db"
-colorHex BrightMagenta = "#9b59b6"
-colorHex BrightCyan = "#1abc9c"
-colorHex BrightWhite = "#bdc3c7"
+colorHex (Color r g b) = "#" <> hex2 r <> hex2 g <> hex2 b
+  where
+    hex2 :: Word8 -> Text
+    hex2 w =
+        let s = showHex w ""
+         in Text.pack (if length s < 2 then '0' : s else s)
+
+{- | Parse a @"#rrggbb"@ (or @"rrggbb"@) string into a 'Color'. Returns
+'Nothing' on malformed input.
+-}
+parseHex :: Text -> Maybe Color
+parseHex t =
+    case Text.unpack (Text.dropWhile (== '#') t) of
+        cs@[r1, r2, g1, g2, b1, b2]
+            | all isHexDigit cs ->
+                Just (Color (byte r1 r2) (byte g1 g2) (byte b1 b2))
+        _ -> Nothing
+  where
+    byte hi lo = fromIntegral (digitToInt hi * 16 + digitToInt lo)
 
 -- | Default palette for layered charts (scatter, line, bars, …).
 paletteColors :: [Color]
diff --git a/src/Granite/Render/Pipeline.hs b/src/Granite/Render/Pipeline.hs
--- a/src/Granite/Render/Pipeline.hs
+++ b/src/Granite/Render/Pipeline.hs
@@ -23,7 +23,7 @@
 import Data.Text (Text)
 import Data.Text qualified as Text
 
-import Granite.Color (Color (..))
+import Granite.Color (Color (..), parseHex)
 import Granite.Data.Frame (
     Column (..),
     DataFrame (..),
@@ -146,12 +146,13 @@
         hasTitle = case chartTitle chart of
             Just t -> not (Text.null t)
             Nothing -> False
-        legendEntries = collectLegend (chartLayers chart) palette
+        colorMaps = map (layerColorMap palette (chartData chart)) (chartLayers chart)
+        legendEntries = collectLegend (chartLayers chart) palette colorMaps
         hasRightLegend = not (null legendEntries)
         box = computePlotBox (chartSize chart) theme hasTitle hasRightLegend False
 
         panels = layoutPanels theme box chart
-        panelMarks = concatMap (renderPanel theme palette coord) panels
+        panelMarks = concatMap (renderPanel theme palette coord colorMaps) panels
 
         legend = legendMarks theme box legendEntries
         title = titleMarks theme box (chartTitle chart)
@@ -168,12 +169,19 @@
     , panelYScale :: TrainedScale
     }
 
-renderPanel :: Theme -> [ColorSpec] -> Coord -> PanelSpec -> [Mark]
-renderPanel theme palette coord ps =
+renderPanel ::
+    Theme ->
+    [ColorSpec] ->
+    Coord ->
+    [Maybe [(Text, ColorSpec)]] ->
+    PanelSpec ->
+    [Mark]
+renderPanel theme palette coord colorMaps ps =
     let proj = buildProjector coord (panelBox ps) (panelXScale ps) (panelYScale ps)
+        cmap i = if i < length colorMaps then colorMaps !! i else Nothing
         layerMarks =
             concat
-                [ runLayer theme (panelBox ps) proj palette i (panelData ps) layer
+                [ runLayer theme (panelBox ps) proj palette (cmap i) i (panelData ps) layer
                 | (i, layer) <- zip [0 :: Int ..] (panelLayers ps)
                 ]
         axes = axesMarks theme (panelBox ps) coord (panelXScale ps) (panelYScale ps)
@@ -405,11 +413,12 @@
     PlotBox ->
     Projector ->
     [ColorSpec] ->
+    Maybe [(Text, ColorSpec)] ->
     Int ->
     DataFrame ->
     Layer ->
     [Mark]
-runLayer theme box proj palette ix globalFrame layer =
+runLayer theme box proj palette colorMap ix globalFrame layer =
     let frame0 = fromMaybe globalFrame (layerData layer)
         framePostStat = applyStat (layerStat layer) (layerMapping layer) frame0
         frame = applyPosition (layerPosition layer) (layerMapping layer) framePostStat
@@ -418,14 +427,21 @@
         colorSpec = case defColor defaults of
             Just c -> c
             Nothing -> layerDefaultColor palette ix layer
-        col = specToTerminal colorSpec
+        col = specToColor colorSpec
         radius = case defSize defaults of
             Just r -> r
             Nothing -> pointSize theme layer
         alpha = fromMaybe 1 (defAlpha defaults)
         lineW = fromMaybe 2 (defLineWidth defaults)
+        -- Per-row point colours: a mapped categorical 'aesColor' (when
+        -- 'colorMap' is set for this layer) overrides the constant colour.
+        pointColors = case colorMap of
+            Just levelColors
+                | Just cats <- categoricalColorColumn frame m ->
+                    map (\cat -> specToColor (fromMaybe colorSpec (lookup cat levelColors))) cats
+            _ -> repeat col
      in case layerGeom layer of
-            GeomPoint -> drawPoints proj frame m colorSpec radius alpha
+            GeomPoint -> drawPoints proj frame m pointColors radius alpha
             GeomLine -> drawLine proj frame m colorSpec lineW
             GeomBar -> drawBars proj frame m col
             GeomCol -> drawBars proj frame m col
@@ -442,21 +458,21 @@
     Projector ->
     DataFrame ->
     Mapping ->
-    ColorSpec ->
+    [Color] ->
     Double ->
     Double ->
     [Mark]
-drawPoints proj frame m col r alpha =
+drawPoints proj frame m cols r alpha =
     case (resolveNumColumn frame (aesX m), resolveNumColumn frame (aesY m)) of
         (Just xv, Just yv) ->
             [ MCircle
                 (proj x y)
                 r
                 defaultStyle
-                    { styleFill = Just (specToTerminal col)
+                    { styleFill = Just c
                     , styleFillOpacity = alpha
                     }
-            | (x, y) <- zip xv yv
+            | ((x, y), c) <- zip (zip xv yv) cols
             ]
         _ -> []
 
@@ -475,7 +491,7 @@
              in [ MPolyline
                     pts
                     defaultStyle
-                        { styleStroke = Just (specToTerminal col)
+                        { styleStroke = Just (specToColor col)
                         , styleStrokeWidth = lineW
                         }
                 ]
@@ -680,8 +696,8 @@
                         s
                         e
                         defaultStyle
-                            { styleFill = Just (specToTerminal (colorAt i))
-                            , styleStroke = Just (specToTerminal (NamedColor BrightBlack))
+                            { styleFill = Just (specToColor (colorAt i))
+                            , styleStroke = Just (specToColor (NamedColor BrightBlack))
                             , styleStrokeWidth = 1
                             }
                     | (i, (s, e)) <- zip [0 :: Int ..] (zip starts ends)
@@ -877,21 +893,70 @@
 pointSize :: Theme -> Layer -> Double
 pointSize _ _ = 3
 
-collectLegend :: [Layer] -> [ColorSpec] -> [(Text, ColorSpec)]
-collectLegend layers palette =
-    [ (legendName ix layer, palette !! (ix `mod` max 1 (length palette)))
-    | (ix, layer) <- zip [0 :: Int ..] layers
-    ]
+{- | Map the distinct values of a categorical column to palette colours, in
+first-seen order; shared by the renderer and 'collectLegend' so points and the
+legend agree.
+-}
+categoricalColorMap :: [ColorSpec] -> [Text] -> [(Text, ColorSpec)]
+categoricalColorMap palette levels =
+    [(lvl, colorAt i) | (i, lvl) <- zip [0 :: Int ..] levels]
   where
+    colorAt i
+        | null palette = NamedColor BrightBlue
+        | otherwise = palette !! (i `mod` length palette)
+
+{- | The per-category colour map for a layer, when its 'aesColor' maps to a
+categorical ('ColCat') column. Computed once from the whole-chart layer frame so
+colours stay consistent across facets and the legend; 'Nothing' otherwise
+(numeric colour columns keep the single per-layer colour).
+-}
+layerColorMap :: [ColorSpec] -> DataFrame -> Layer -> Maybe [(Text, ColorSpec)]
+layerColorMap palette globalFrame layer
+    | GeomPoint <- layerGeom layer
+    , Just cats <-
+        categoricalColorColumn
+            (fromMaybe globalFrame (layerData layer))
+            (layerMapping layer) =
+        Just (categoricalColorMap palette (List.nub cats))
+    | otherwise = Nothing
+
+{- | The categorical ('ColCat') colour column's per-row values, if 'aesColor'
+maps to one.
+-}
+categoricalColorColumn :: DataFrame -> Mapping -> Maybe [Text]
+categoricalColorColumn frame m = case aesColor m of
+    Just (ColumnRef n) -> case lookupColumn n frame of
+        Just (ColCat xs) -> Just xs
+        _ -> Nothing
+    Nothing -> Nothing
+
+{- | Legend entries. A categorical-'aesColor' layer contributes one entry per
+category value (colours matching the points via 'layerColorMap'); 'GeomText'
+layers contribute none; any other layer keeps a single per-layer swatch.
+-}
+collectLegend ::
+    [Layer] -> [ColorSpec] -> [Maybe [(Text, ColorSpec)]] -> [(Text, ColorSpec)]
+collectLegend layers palette colorMaps =
+    concat
+        [ entriesFor ix layer cmap
+        | (ix, layer, cmap) <- zip3 [0 :: Int ..] layers (colorMaps ++ repeat Nothing)
+        ]
+  where
+    entriesFor ix layer cmap
+        | GeomText <- layerGeom layer = []
+        | Just levelColors <- cmap = levelColors
+        | otherwise =
+            [(legendName ix layer, palette !! (ix `mod` max 1 (length palette)))]
     legendName ix layer =
         case aesGroup (layerMapping layer) of
             Just (ColumnRef n) -> n
             Nothing -> "series " <> Text.pack (show ix)
 
-{- | Quantise 'ColorSpec' to the nearest renderable ANSI 'Color' for
-the terminal backend. SVG reads RGB / Hex directly.
+{- | Resolve a 'ColorSpec' to an exact RGB 'Color'. The SVG backend renders
+this exactly (via @colorHex@); the terminal backend quantises it to the nearest
+ANSI slot (via @ansiCode@).
 -}
-specToTerminal :: ColorSpec -> Color
-specToTerminal (NamedColor c) = c
-specToTerminal (RGB{}) = BrightBlue
-specToTerminal (Hex _) = BrightBlue
+specToColor :: ColorSpec -> Color
+specToColor (NamedColor c) = c
+specToColor (RGB r g b) = Color r g b
+specToColor (Hex t) = fromMaybe Black (parseHex t)
diff --git a/test/GraniteSpec.hs b/test/GraniteSpec.hs
--- a/test/GraniteSpec.hs
+++ b/test/GraniteSpec.hs
@@ -339,6 +339,7 @@
 ansiCode BrightCyan = 96
 ansiCode BrightWhite = 97
 ansiCode Default = 39
+ansiCode _ = 39
 
 ansiOn :: Color -> Text
 ansiOn c = "\ESC[" <> Text.pack (show (ansiCode c)) <> "m"
diff --git a/test/PipelineSpec.hs b/test/PipelineSpec.hs
--- a/test/PipelineSpec.hs
+++ b/test/PipelineSpec.hs
@@ -4,6 +4,7 @@
 
 import Data.Text qualified as Text
 
+import Granite.Color qualified as Col
 import Granite.Render.Pipeline
 import Granite.Render.Scene (Scene (..))
 import Granite.Spec
@@ -83,6 +84,72 @@
             -- roughly 24 lines (22 plot rows + title + axis labels).
             lineCount `shouldSatisfy` (>= 15)
             lineCount `shouldSatisfy` (<= 40)
+
+    describe "colour" $ do
+        it "colorHex renders exact, zero-padded, lowercase hex" $ do
+            Col.colorHex Col.BrightBlue `shouldBe` "#3498db"
+            Col.colorHex Col.BrightYellow `shouldBe` "#f1c40f"
+            Col.colorHex (Col.Color 0 0 15) `shouldBe` "#00000f"
+
+        it "ansiCode keeps canonical codes for the named slots" $ do
+            Col.ansiCode Col.Black `shouldBe` 30
+            Col.ansiCode Col.BrightBlue `shouldBe` 94
+            Col.ansiCode Col.Default `shouldBe` 39
+
+        it "parseHex accepts valid hex and rejects junk" $ do
+            Col.parseHex "#ff0000" `shouldBe` Just (Col.Color 255 0 0)
+            Col.parseHex "1a2b3c" `shouldBe` Just (Col.Color 26 43 60)
+            Col.parseHex "nope" `shouldBe` Nothing
+
+        it "SVG renders a custom Hex defColor exactly (not quantised to blue)" $ do
+            let chart =
+                    emptyChart
+                        { chartSize = SizeChars 30 16
+                        , chartData =
+                            fromColumns [("x", ColNum [1, 2, 3]), ("y", ColNum [1, 2, 3])]
+                        , chartLayers =
+                            [ (defLayer GeomPoint)
+                                { layerMapping =
+                                    emptyMapping
+                                        { aesX = Just (ColumnRef "x")
+                                        , aesY = Just (ColumnRef "y")
+                                        }
+                                , layerAesDef = emptyAesDefaults{defColor = Just (Hex "#ff0000")}
+                                }
+                            ]
+                        }
+            renderChartSvg chart `shouldSatisfy` Text.isInfixOf "#ff0000"
+
+        it "categorical aesColor colours points per category with a per-category legend" $ do
+            let chart =
+                    emptyChart
+                        { chartSize = SizeChars 40 20
+                        , chartData =
+                            fromColumns
+                                [ ("x", ColNum [1, 2, 3])
+                                , ("y", ColNum [1, 2, 3])
+                                , ("g", ColCat ["a", "b", "c"])
+                                ]
+                        , chartLayers =
+                            [ (defLayer GeomPoint)
+                                { layerMapping =
+                                    emptyMapping
+                                        { aesX = Just (ColumnRef "x")
+                                        , aesY = Just (ColumnRef "y")
+                                        , aesColor = Just (ColumnRef "g")
+                                        }
+                                }
+                            ]
+                        }
+                svg = renderChartSvg chart
+            -- three categories → the first three palette colours all appear
+            svg `shouldSatisfy` Text.isInfixOf "#3498db"
+            svg `shouldSatisfy` Text.isInfixOf "#9b59b6"
+            svg `shouldSatisfy` Text.isInfixOf "#1abc9c"
+            -- the legend is keyed by category value, not a generic "series 0"
+            svg `shouldSatisfy` Text.isInfixOf ">a</text>"
+            svg `shouldSatisfy` Text.isInfixOf ">c</text>"
+            svg `shouldNotSatisfy` Text.isInfixOf "series 0"
 
     describe "Log-scale chart" $ do
         let logChart =
diff --git a/test/golden/bar-basic.svg b/test/golden/bar-basic.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/bar-basic.svg
@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 224" width="400" height="224" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="196.20" x2="280" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="86.76" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="140.59" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="194.41" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="248.24" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="49" y="192.49" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="151.53" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="49" y="110.57" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<text x="49" y="69.62" text-anchor="end" fill="#7f8c8d" font-size="11">15.0</text>
+<rect x="65.23" y="90.52" width="43.06" height="98.30" fill="#3498db"/>
+<rect x="119.06" y="131.48" width="43.06" height="57.34" fill="#3498db"/>
+<rect x="172.88" y="41.37" width="43.06" height="147.45" fill="#3498db"/>
+<rect x="226.71" y="115.10" width="43.06" height="73.73" fill="#3498db"/>
+<text x="167.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Bars</text>
+<rect x="295" y="39" width="12" height="12" fill="#3498db"/>
+<text x="311" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/bar-basic.txt b/test/golden/bar-basic.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/bar-basic.txt
@@ -0,0 +1,14 @@
+                                        
+              [90mBars                      [0m
+     [37m│           [0m[94m█████       ██         [0m
+     [37m│           [0m[94m█████       ██[0m[90mseries 0 [0m
+[90m15.0 [0m[37m│           [0m[94m█████                  [0m
+     [37m│[0m[94m█████      █████                  [0m
+[90m10.0 [0m[37m│[0m[94m█████      █████                  [0m
+     [37m│[0m[94m█████      ██████████             [0m
+     [37m│[0m[94m█████████████████████             [0m
+ [90m5.0 [0m[37m│[0m[94m█████████████████████             [0m
+     [37m│[0m[94m█████████████████████             [0m
+     [37m│[0m[94m█████████████████████             [0m
+ [90m0.0 [0m[37m┼───────────────────────           [0m
+       [90m0.0   1.0  2.0  3.0              [0m
diff --git a/test/golden/box-basic.svg b/test/golden/box-basic.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/box-basic.svg
@@ -0,0 +1,25 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 256" width="400" height="256" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="228.20" x2="280" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="94.45" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="167.50" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="240.55" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="49" y="164.19" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="49" y="90.63" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<polygon points="65.23,189.95 123.67,189.95 123.67,131.10 65.23,131.10" fill="#3498db" stroke="#3498db" stroke-width="1" fill-opacity="0.20"/>
+<polyline points="65.23,160.52 123.67,160.52" fill="none" stroke="#3498db" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="94.45,189.95 94.45,219.37" fill="none" stroke="#3498db" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="94.45,131.10 94.45,101.68" fill="none" stroke="#3498db" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"/>
+<polygon points="138.28,160.52 196.72,160.52 196.72,101.68 138.28,101.68" fill="#3498db" stroke="#3498db" stroke-width="1" fill-opacity="0.20"/>
+<polyline points="138.28,131.10 196.72,131.10" fill="none" stroke="#3498db" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="167.50,160.52 167.50,189.95" fill="none" stroke="#3498db" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="167.50,101.68 167.50,72.25" fill="none" stroke="#3498db" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"/>
+<polygon points="211.33,131.10 269.77,131.10 269.77,72.25 211.33,72.25" fill="#3498db" stroke="#3498db" stroke-width="1" fill-opacity="0.20"/>
+<polyline points="211.33,101.68 269.77,101.68" fill="none" stroke="#3498db" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="240.55,131.10 240.55,160.52" fill="none" stroke="#3498db" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="240.55,72.25 240.55,42.83" fill="none" stroke="#3498db" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"/>
+<text x="167.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Boxplot</text>
+<rect x="295" y="39" width="12" height="12" fill="#3498db"/>
+<text x="311" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/box-basic.txt b/test/golden/box-basic.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/box-basic.txt
@@ -0,0 +1,16 @@
+                                        
+             [90mBoxplot                    [0m
+     [37m│                  ⡀    [0m[94m██         [0m
+     [37m│                  ⡇    [0m[94m██[0m[90mseries 0 [0m
+     [37m│          ⢠    [0m[94m⡤⠤⠤⠧⠤⠤⡄            [0m
+[90m10.0 [0m[37m│          ⢸    [0m[94m⡇     ⡇            [0m
+     [37m│   ⢰    [0m[94m⡖⠒⠚⠒⠒⢲ ⡗⠒⠒⠒⠒⠒⡇            [0m
+     [37m│   ⢸    [0m[94m⡇    ⢸ ⡇     ⡇            [0m
+     [37m│[0m[94m⢰⠒⠒⠚⠒⠒⢲ ⡗⠒⠒⠒⠒⢺ ⠓⠒⠒⡖⠒⠒⠃            [0m
+     [37m│[0m[94m⢸     ⢸ ⡇    ⢸    ⡇               [0m
+ [90m5.0 [0m[37m│[0m[94m⢸⠉⠉⠉⠉⠉⢹ ⠉⠉⢹⠉⠉⠉    ⠁               [0m
+     [37m│[0m[94m⢸⣀⣀⣀⣀⣀⣸   ⢸                       [0m
+     [37m│   ⢸                              [0m
+     [37m│   ⢸                              [0m
+     [37m┼───────────────────────           [0m
+        [90m0.0    1.0     2.0              [0m
diff --git a/test/golden/empty.svg b/test/golden/empty.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/empty.svg
@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 320" width="600" height="320" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="292.20" x2="580" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="10" x2="55" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="78.86" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="317.50" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.5</text>
+<text x="556.14" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="49" y="283.04" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="154.77" text-anchor="end" fill="#7f8c8d" font-size="11">0.5</text>
+<text x="49" y="26.49" text-anchor="end" fill="#7f8c8d" font-size="11">1.0</text>
+</svg>
diff --git a/test/golden/facet-wrap.svg b/test/golden/facet-wrap.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/facet-wrap.svg
@@ -0,0 +1,51 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 288" width="700" height="288" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="60" y1="246.20" x2="225" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="60" y1="66" x2="60" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="67.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="117.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="167.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="217.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="54" y="241.68" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="54" y="190.48" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="54" y="139.29" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<text x="54" y="88.10" text-anchor="end" fill="#7f8c8d" font-size="11">15.0</text>
+<circle cx="67.50" cy="227.77" r="3" fill="#3498db"/>
+<circle cx="117.50" cy="197.05" r="3" fill="#3498db"/>
+<circle cx="167.50" cy="145.86" r="3" fill="#3498db"/>
+<circle cx="217.50" cy="74.19" r="3" fill="#3498db"/>
+<text x="142.50" y="62" text-anchor="middle" fill="#555555" font-size="11">A</text>
+<line x1="235" y1="246.20" x2="400" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="235" y1="66" x2="235" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="242.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="292.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="342.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="392.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="229" y="241.68" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="229" y="190.48" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="229" y="139.29" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<text x="229" y="88.10" text-anchor="end" fill="#7f8c8d" font-size="11">15.0</text>
+<circle cx="242.50" cy="238.01" r="3" fill="#3498db"/>
+<circle cx="292.50" cy="217.53" r="3" fill="#3498db"/>
+<circle cx="342.50" cy="197.05" r="3" fill="#3498db"/>
+<circle cx="392.50" cy="176.58" r="3" fill="#3498db"/>
+<text x="317.50" y="62" text-anchor="middle" fill="#555555" font-size="11">B</text>
+<line x1="410" y1="246.20" x2="575" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="410" y1="66" x2="410" y2="246.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="417.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="467.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="517.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="567.50" y="261.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="404" y="241.68" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="404" y="190.48" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="404" y="139.29" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<text x="404" y="88.10" text-anchor="end" fill="#7f8c8d" font-size="11">15.0</text>
+<circle cx="417.50" cy="186.82" r="3" fill="#3498db"/>
+<circle cx="467.50" cy="197.05" r="3" fill="#3498db"/>
+<circle cx="517.50" cy="207.29" r="3" fill="#3498db"/>
+<circle cx="567.50" cy="217.53" r="3" fill="#3498db"/>
+<text x="492.50" y="62" text-anchor="middle" fill="#555555" font-size="11">C</text>
+<text x="317.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Faceted by series</text>
+<rect x="595" y="39" width="12" height="12" fill="#3498db"/>
+<text x="611" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/facet-wrap.txt b/test/golden/facet-wrap.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/facet-wrap.txt
@@ -0,0 +1,18 @@
+                                                                      
+                       [90mFaceted by series                              [0m
+                                                           [94m██         [0m
+              [39mA                B                 C         [0m[94m██[0m[90mseries 0 [0m
+      [37m│              [0m[94m⣠⡀[0m[37m│                 │                            [0m
+ [90m15.0 [0m[37m│           [0m[90m15.0 [0m[37m│            [0m[90m15.0 [0m[37m│                            [0m
+      [37m│                │                 │                            [0m
+      [37m│                │                 │                            [0m
+ [90m10.0 [0m[37m│         [0m[94m⢀ [0m[90m10.0 [0m[37m│            [0m[90m10.0 [0m[37m│                            [0m
+      [37m│         [0m[94m⠙⠁     [0m[37m│                 │                            [0m
+      [37m│                │               [0m[94m⡀ [0m[37m│                            [0m
+  [90m5.0 [0m[37m│            [0m[90m5.0 [0m[37m│             [0m[90m5.0 [0m[37m│[0m[94m⡀                           [0m
+      [37m│    [0m[94m⠺⠂          [0m[37m│         [0m[94m⠐⠗      [0m[37m│    [0m[94m⠺⠂   ⢀                  [0m
+      [37m│                │    [0m[94m⠠⡦           [0m[37m│         [0m[94m⠙⠁   ⢴⠄            [0m
+      [37m│[0m[94m⠂               [0m[37m│[0m[94m⡀                [0m[37m│                            [0m
+  [90m0.0 [0m[37m┼────────────[0m[90m0.0[0m[37m─┼─────────────[0m[90m0.0[0m[37m─┼────────────────            [0m
+     [90m0.0  1.0  2.0  3.00.0  1.0  2.0  3.0.0  1.0  2.0  3.0            [0m
+                                                                      
diff --git a/test/golden/hist-basic.svg b/test/golden/hist-basic.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/hist-basic.svg
@@ -0,0 +1,23 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 224" width="440" height="224" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="196.20" x2="320" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="63.96" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="141.17" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="218.39" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="295.60" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="192.49" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="118.77" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="49" y="45.04" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<rect x="67.05" y="41.37" width="24.71" height="147.45" fill="#3498db"/>
+<rect x="97.93" y="188.83" width="24.71" height="0" fill="#3498db"/>
+<rect x="128.82" y="41.37" width="24.71" height="147.45" fill="#3498db"/>
+<rect x="159.70" y="115.10" width="24.71" height="73.73" fill="#3498db"/>
+<rect x="190.59" y="115.10" width="24.71" height="73.73" fill="#3498db"/>
+<rect x="221.47" y="115.10" width="24.71" height="73.73" fill="#3498db"/>
+<rect x="252.36" y="115.10" width="24.71" height="73.73" fill="#3498db"/>
+<rect x="283.25" y="41.37" width="24.71" height="147.45" fill="#3498db"/>
+<text x="187.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Histogram</text>
+<rect x="335" y="39" width="12" height="12" fill="#3498db"/>
+<text x="351" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/hist-basic.txt b/test/golden/hist-basic.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/hist-basic.txt
@@ -0,0 +1,14 @@
+                                            
+              [90mHistogram                     [0m
+[90m10.0 [0m[37m│[0m[94m████  ████            ███  ██         [0m
+     [37m│[0m[94m████  ████            ███  ██[0m[90mseries 0 [0m
+     [37m│[0m[94m████  ████            ███             [0m
+     [37m│[0m[94m████  ████            ███             [0m
+     [37m│[0m[94m████  ████            ███             [0m
+ [90m5.0 [0m[37m│[0m[94m████  ███████████████████             [0m
+     [37m│[0m[94m████  ███████████████████             [0m
+     [37m│[0m[94m████  ███████████████████             [0m
+     [37m│[0m[94m████  ███████████████████             [0m
+     [37m│[0m[94m█████████████████████████             [0m
+ [90m0.0 [0m[37m┼───────────────────────────           [0m
+     [90m1.0     2.0    3.0     4.0             [0m
diff --git a/test/golden/line-basic.svg b/test/golden/line-basic.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/line-basic.svg
@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 320" width="600" height="320" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="292.20" x2="480" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="74.32" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="203.11" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="331.89" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="460.68" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">6.0</text>
+<text x="49" y="286.80" text-anchor="end" fill="#7f8c8d" font-size="11">-1.0</text>
+<text x="49" y="167.95" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="49.11" text-anchor="end" fill="#7f8c8d" font-size="11">1.0</text>
+<polyline points="74.32,164.29 106.52,107.31 138.71,64.28 170.91,45.74 203.11,56.22 235.30,93.16 267.50,147.51 299.70,205.98 331.89,254.23 364.09,280.46 396.29,278.25 428.48,248.14 460.68,197.49" fill="none" stroke="#3498db" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">sin(x)</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">sine</text>
+</svg>
diff --git a/test/golden/line-basic.txt b/test/golden/line-basic.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/line-basic.txt
@@ -0,0 +1,20 @@
+                                                            
+                       [90msin(x)                               [0m
+     [37m│           ⣀                               [0m[94m██         [0m
+ [90m1.0 [0m[37m│        ⢀⠔⠊ ⠉⠒⠤⡀                           [0m[94m██[0m[90msine     [0m
+     [37m│       ⡰⠁      ⠑⡄                                     [0m
+     [37m│      ⡜         ⠈⢆                                    [0m
+     [37m│    ⢀⠎           ⠈⢆                                   [0m
+     [37m│   ⢀⠎             ⠈⡆                                  [0m
+     [37m│   ⡎               ⠘⡄                                 [0m
+     [37m│  ⡜                 ⠘⡄                                [0m
+ [90m0.0 [0m[37m│ ⠘                   ⠘⡄                               [0m
+     [37m│                      ⠘⡄                              [0m
+     [37m│                       ⠘⡄              ⢀⠆             [0m
+     [37m│                        ⠱⡀            ⢀⠎              [0m
+     [37m│                         ⠱⡀          ⢀⠎               [0m
+     [37m│                          ⠱⡀        ⢀⠎                [0m
+     [37m│                           ⠑⢄⡀     ⡰⠁                 [0m
+[90m-1.0 [0m[37m│                             ⠈⠢⠤⠤⠤⠊                   [0m
+     [37m┼───────────────────────────────────────────           [0m
+      [90m0.0          2.0          4.0          6.0            [0m
diff --git a/test/golden/line-coord-flip.svg b/test/golden/line-coord-flip.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/line-coord-flip.svg
@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 320" width="600" height="320" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="292.20" x2="480" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="74.32" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="170.91" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="267.50" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="364.09" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">6.0</text>
+<text x="460.68" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">8.0</text>
+<text x="49" y="49.40" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="108.08" text-anchor="end" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="49" y="166.77" text-anchor="end" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="49" y="225.45" text-anchor="end" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="49" y="284.13" text-anchor="end" fill="#7f8c8d" font-size="11">4.0</text>
+<polyline points="74.32,45.74 170.91,104.42 267.50,163.10 364.09,221.78 460.68,280.46" fill="none" stroke="#3498db" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">CoordFlip</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">y=2x</text>
+</svg>
diff --git a/test/golden/line-coord-flip.txt b/test/golden/line-coord-flip.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/line-coord-flip.txt
@@ -0,0 +1,20 @@
+                                                            
+                      [90mCoordFlip                             [0m
+     [37m│ ⢀                                         [0m[94m██         [0m
+ [90m0.0 [0m[37m│  ⠑⠢⡀                                      [0m[94m██[0m[90my=2x     [0m
+     [37m│    ⠈⠒⢄                                               [0m
+     [37m│       ⠉⠢⡀                                            [0m
+ [90m1.0 [0m[37m│         ⠈⠑⢄                                          [0m
+     [37m│            ⠉⠢⡀                                       [0m
+     [37m│              ⠈⠑⢄⡀                                    [0m
+     [37m│                 ⠈⠢⣀                                  [0m
+ [90m2.0 [0m[37m│                    ⠑⠤⡀                               [0m
+     [37m│                      ⠈⠒⢄⡀                            [0m
+     [37m│                         ⠈⠒⢄                          [0m
+     [37m│                            ⠉⠢⢄                       [0m
+ [90m3.0 [0m[37m│                               ⠑⠢⡀                    [0m
+     [37m│                                 ⠈⠒⢄                  [0m
+     [37m│                                    ⠉⠢⡀               [0m
+ [90m4.0 [0m[37m│                                      ⠈⠑⠄             [0m
+     [37m┼───────────────────────────────────────────           [0m
+      [90m0.0       2.0      4.0       6.0       8.0            [0m
diff --git a/test/golden/line-reverse-y.svg b/test/golden/line-reverse-y.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/line-reverse-y.svg
@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 320" width="600" height="320" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="292.20" x2="480" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="74.32" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="203.11" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="331.89" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="460.68" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">6.0</text>
+<text x="49" y="46.73" text-anchor="end" fill="#7f8c8d" font-size="11">-1.0</text>
+<text x="49" y="165.58" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="284.43" text-anchor="end" fill="#7f8c8d" font-size="11">1.0</text>
+<polyline points="74.32,161.91 106.52,218.89 138.71,261.92 170.91,280.46 203.11,269.98 235.30,233.04 267.50,178.69 299.70,120.22 331.89,71.97 364.09,45.74 396.29,47.95 428.48,78.06 460.68,128.71" fill="none" stroke="#3498db" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Reverse Y</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">sine</text>
+</svg>
diff --git a/test/golden/pie-basic.svg b/test/golden/pie-basic.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/pie-basic.svg
@@ -0,0 +1,18 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 256" width="300" height="256" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="228.20" x2="180" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="60.68" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="117.50" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.5</text>
+<text x="174.32" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="49" y="187.73" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>
+<text x="49" y="117.11" text-anchor="end" fill="#7f8c8d" font-size="11">30.0</text>
+<text x="49" y="46.49" text-anchor="end" fill="#7f8c8d" font-size="11">40.0</text>
+<path d="M 117.50 131.10 L 117.50 74.85 A 56.25 56.25 0 0 0 150.56 176.61 Z" fill="#3498db" stroke="#7f8c8d" stroke-width="1"/>
+<path d="M 117.50 131.10 L 150.56 176.61 A 56.25 56.25 0 0 0 71.99 164.16 Z" fill="#9b59b6" stroke="#7f8c8d" stroke-width="1"/>
+<path d="M 117.50 131.10 L 71.99 164.16 A 56.25 56.25 0 0 0 71.99 98.04 Z" fill="#1abc9c" stroke="#7f8c8d" stroke-width="1"/>
+<path d="M 117.50 131.10 L 71.99 98.04 A 56.25 56.25 0 0 0 117.50 74.85 Z" fill="#2ecc71" stroke="#7f8c8d" stroke-width="1"/>
+<text x="117.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Pie</text>
+<rect x="195" y="39" width="12" height="12" fill="#3498db"/>
+<text x="211" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/plotly-area-filled.svg b/test/golden/plotly-area-filled.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-area-filled.svg
@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 224" width="560" height="224" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="196.20" x2="440" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="72.50" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="218.33" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="364.17" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">10.0</text>
+<text x="49" y="192.49" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="143.30" text-anchor="end" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="49" y="94.11" text-anchor="end" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="49" y="44.92" text-anchor="end" fill="#7f8c8d" font-size="11">3.0</text>
+<polygon points="72.50,90.44 101.67,66.86 130.83,49.05 160.00,41.37 189.17,45.71 218.33,61.00 247.50,83.50 276.67,107.70 305.83,127.67 335.00,138.53 364.17,137.61 393.33,125.15 422.50,104.19 422.50,188.83 393.33,188.83 364.17,188.83 335.00,188.83 305.83,188.83 276.67,188.83 247.50,188.83 218.33,188.83 189.17,188.83 160.00,188.83 130.83,188.83 101.67,188.83 72.50,188.83" fill="#3498db" stroke="#3498db" stroke-width="1" fill-opacity="0.40"/>
+<text x="247.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Filled area</text>
+<rect x="455" y="39" width="12" height="12" fill="#3498db"/>
+<text x="471" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/plotly-area-filled.txt b/test/golden/plotly-area-filled.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-area-filled.txt
@@ -0,0 +1,14 @@
+                                                        
+                   [90mFilled area                          [0m
+ [90m3.0 [0m[37m│        [0m[94m⣀⡠⠤⢄⣀⡀                         ██         [0m
+     [37m│     [0m[94m⡠⠔⠉     ⠈⠑⠤⡀                      ██[0m[90mseries 0 [0m
+     [37m│   [0m[94m⡠⠊           ⠈⢢                                [0m
+ [90m2.0 [0m[37m│ [0m[94m⡠⠊               ⠑⢄                              [0m
+     [37m│ [0m[94m⡇                  ⠑⢄             ⢀⡄             [0m
+     [37m│ [0m[94m⡇                    ⠑⠤⡀        ⣀⠔⠁⡇             [0m
+ [90m1.0 [0m[37m│ [0m[94m⡇                      ⠈⠒⠤⣀⣀⠤⠤⠒⠉   ⡇             [0m
+     [37m│ [0m[94m⡇                                  ⡇             [0m
+     [37m│ [0m[94m⡇                                  ⡇             [0m
+     [37m│ [0m[94m⣇⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⡇             [0m
+ [90m0.0 [0m[37m┼───────────────────────────────────────           [0m
+      [90m0.0           5.0           10.0                  [0m
diff --git a/test/golden/plotly-bar-grouped.svg b/test/golden/plotly-bar-grouped.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-bar-grouped.svg
@@ -0,0 +1,27 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 256" width="600" height="256" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="228.20" x2="480" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="110.87" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="215.29" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="319.71" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="424.13" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="49" y="223.04" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="142.79" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<text x="49" y="62.54" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>
+<rect x="74.32" y="123.08" width="20.88" height="96.30" fill="#3498db"/>
+<rect x="100.42" y="155.17" width="20.88" height="64.20" fill="#3498db"/>
+<rect x="126.53" y="187.27" width="20.88" height="32.10" fill="#3498db"/>
+<rect x="178.74" y="99.00" width="20.88" height="120.37" fill="#3498db"/>
+<rect x="204.85" y="139.12" width="20.88" height="80.25" fill="#3498db"/>
+<rect x="230.95" y="171.22" width="20.88" height="48.15" fill="#3498db"/>
+<rect x="283.16" y="74.93" width="20.88" height="144.45" fill="#3498db"/>
+<rect x="309.27" y="123.08" width="20.88" height="96.30" fill="#3498db"/>
+<rect x="335.37" y="155.17" width="20.88" height="64.20" fill="#3498db"/>
+<rect x="387.59" y="42.83" width="20.88" height="176.55" fill="#3498db"/>
+<rect x="413.69" y="107.03" width="20.88" height="112.35" fill="#3498db"/>
+<rect x="439.80" y="139.12" width="20.88" height="80.25" fill="#3498db"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Sales by quarter (grouped)</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">product</text>
+</svg>
diff --git a/test/golden/plotly-bar-grouped.txt b/test/golden/plotly-bar-grouped.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-bar-grouped.txt
@@ -0,0 +1,16 @@
+                                                            
+             [90mSales by quarter (grouped)                     [0m
+     [37m│                                [0m[94m███        ██         [0m
+[90m20.0 [0m[37m│                                [0m[94m███        ██[0m[90mproduct  [0m
+     [37m│                      [0m[94m███       ███                   [0m
+     [37m│                      [0m[94m███       ███                   [0m
+     [37m│           [0m[94m███        ███       ██████                [0m
+     [37m│ [0m[94m███       ███        █████     ██████                [0m
+[90m10.0 [0m[37m│ [0m[94m███       ██████     █████     ████████              [0m
+     [37m│ [0m[94m██████    ██████     ████████  ████████              [0m
+     [37m│ [0m[94m██████    █████████  ████████  ████████              [0m
+     [37m│ [0m[94m████████  █████████  ████████  ████████              [0m
+     [37m│ [0m[94m████████  █████████  ████████  ████████              [0m
+ [90m0.0 [0m[37m│ [0m[94m████████  █████████  ████████  ████████              [0m
+     [37m┼───────────────────────────────────────────           [0m
+          [90m0.0       1.0       2.0        3.0                [0m
diff --git a/test/golden/plotly-bar-horizontal.svg b/test/golden/plotly-bar-horizontal.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-bar-horizontal.svg
@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 256" width="600" height="256" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="228.20" x2="480" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="74.32" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="168.55" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">20.0</text>
+<text x="262.79" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">40.0</text>
+<text x="357.02" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">60.0</text>
+<text x="451.26" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">80.0</text>
+<text x="49" y="61.21" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="134.77" text-anchor="end" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="49" y="208.33" text-anchor="end" fill="#7f8c8d" font-size="11">4.0</text>
+<rect x="74.32" y="42.83" width="386.36" height="29.42" fill="#3498db"/>
+<rect x="74.32" y="79.61" width="315.69" height="29.42" fill="#3498db"/>
+<rect x="74.32" y="116.39" width="212.03" height="29.42" fill="#3498db"/>
+<rect x="74.32" y="153.17" width="155.49" height="29.42" fill="#3498db"/>
+<rect x="74.32" y="189.95" width="98.95" height="29.42" fill="#3498db"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Top 5 (horizontal)</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/plotly-bar-horizontal.txt b/test/golden/plotly-bar-horizontal.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-bar-horizontal.txt
@@ -0,0 +1,16 @@
+                                                            
+                 [90mTop 5 (horizontal)                         [0m
+     [37m│ [0m[94m███████████████████████████████████████   ██         [0m
+ [90m0.0 [0m[37m│ [0m[94m███████████████████████████████████████   ██[0m[90mseries 0 [0m
+     [37m│ [0m[94m███████████████████████████████████████              [0m
+     [37m│ [0m[94m████████████████████████████████                     [0m
+     [37m│ [0m[94m████████████████████████████████                     [0m
+     [37m│ [0m[94m██████████████████████                               [0m
+ [90m2.0 [0m[37m│ [0m[94m██████████████████████                               [0m
+     [37m│ [0m[94m██████████████████████                               [0m
+     [37m│ [0m[94m████████████████                                     [0m
+     [37m│ [0m[94m████████████████                                     [0m
+     [37m│ [0m[94m███████████                                          [0m
+ [90m4.0 [0m[37m│ [0m[94m███████████                                          [0m
+     [37m┼───────────────────────────────────────────           [0m
+      [90m0.0     20.0      40.0     60.0      80.0             [0m
diff --git a/test/golden/plotly-bar-stacked.svg b/test/golden/plotly-bar-stacked.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-bar-stacked.svg
@@ -0,0 +1,27 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 256" width="600" height="256" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="228.20" x2="480" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="114.99" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="216.66" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="318.34" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="420.01" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="49" y="223.04" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="146.28" text-anchor="end" fill="#7f8c8d" font-size="11">20.0</text>
+<text x="49" y="69.52" text-anchor="end" fill="#7f8c8d" font-size="11">40.0</text>
+<rect x="74.32" y="173.32" width="81.34" height="46.06" fill="#3498db"/>
+<rect x="74.32" y="142.61" width="81.34" height="30.70" fill="#3498db"/>
+<rect x="74.32" y="127.26" width="81.34" height="15.35" fill="#3498db"/>
+<rect x="175.99" y="161.80" width="81.34" height="57.57" fill="#3498db"/>
+<rect x="175.99" y="123.42" width="81.34" height="38.38" fill="#3498db"/>
+<rect x="175.99" y="100.40" width="81.34" height="23.03" fill="#3498db"/>
+<rect x="277.67" y="150.29" width="81.34" height="69.08" fill="#3498db"/>
+<rect x="277.67" y="104.23" width="81.34" height="46.06" fill="#3498db"/>
+<rect x="277.67" y="73.53" width="81.34" height="30.70" fill="#3498db"/>
+<rect x="379.34" y="134.94" width="81.34" height="84.43" fill="#3498db"/>
+<rect x="379.34" y="81.21" width="81.34" height="53.73" fill="#3498db"/>
+<rect x="379.34" y="42.83" width="81.34" height="38.38" fill="#3498db"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Sales by quarter (stacked)</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">product</text>
+</svg>
diff --git a/test/golden/plotly-bar-stacked.txt b/test/golden/plotly-bar-stacked.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-bar-stacked.txt
@@ -0,0 +1,16 @@
+                                                            
+             [90mSales by quarter (stacked)                     [0m
+     [37m│                               [0m[94m█████████   ██         [0m
+     [37m│                               [0m[94m█████████   ██[0m[90mproduct  [0m
+[90m40.0 [0m[37m│                     [0m[94m█████████ █████████              [0m
+     [37m│                     [0m[94m█████████ █████████              [0m
+     [37m│           [0m[94m█████████ █████████ █████████              [0m
+     [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m
+     [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m
+[90m20.0 [0m[37m│ [0m[94m█████████ █████████ █████████ █████████              [0m
+     [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m
+     [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m
+     [37m│ [0m[94m█████████ █████████ █████████ █████████              [0m
+ [90m0.0 [0m[37m│ [0m[94m█████████ █████████ █████████ █████████              [0m
+     [37m┼───────────────────────────────────────────           [0m
+          [90m0.0       1.0       2.0        3.0                [0m
diff --git a/test/golden/plotly-density.svg b/test/golden/plotly-density.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-density.svg
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 224" width="560" height="224" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="196.20" x2="440" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="112.36" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="216.31" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="320.27" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="424.22" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">6.0</text>
+<text x="49" y="192.79" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="143.13" text-anchor="end" fill="#7f8c8d" font-size="11">0.1</text>
+<text x="49" y="93.46" text-anchor="end" fill="#7f8c8d" font-size="11">0.2</text>
+<text x="49" y="43.80" text-anchor="end" fill="#7f8c8d" font-size="11">0.3</text>
+<polyline points="72.50,188.81 75.26,188.70 78.01,188.57 80.77,188.39 83.52,188.17 86.28,187.90 89.04,187.55 91.79,187.12 94.55,186.60 97.30,185.96 100.06,185.18 102.81,184.25 105.57,183.15 108.33,181.85 111.08,180.33 113.84,178.56 116.59,176.53 119.35,174.22 122.11,171.60 124.86,168.67 127.62,165.40 130.37,161.80 133.13,157.87 135.89,153.60 138.64,149.02 141.40,144.14 144.15,138.99 146.91,133.60 149.67,128.01 152.42,122.26 155.18,116.40 157.93,110.49 160.69,104.58 163.44,98.73 166.20,92.98 168.96,87.40 171.71,82.03 174.47,76.91 177.22,72.09 179.98,67.58 182.74,63.43 185.49,59.65 188.25,56.25 191.00,53.23 193.76,50.58 196.52,48.31 199.27,46.40 202.03,44.83 204.78,43.59 207.54,42.65 210.30,41.98 213.05,41.56 215.81,41.37 218.56,41.38 221.32,41.57 224.07,41.90 226.83,42.36 229.59,42.93 232.34,43.57 235.10,44.27 237.85,45.01 240.61,45.78 243.37,46.56 246.12,47.33 248.88,48.10 251.63,48.85 254.39,49.58 257.15,50.29 259.90,50.99 262.66,51.69 265.41,52.39 268.17,53.11 270.93,53.87 273.68,54.68 276.44,55.56 279.19,56.54 281.95,57.64 284.70,58.87 287.46,60.27 290.22,61.84 292.97,63.62 295.73,65.61 298.48,67.83 301.24,70.29 304.00,73.01 306.75,75.98 309.51,79.22 312.26,82.71 315.02,86.45 317.78,90.42 320.53,94.62 323.29,99.01 326.04,103.56 328.80,108.26 331.56,113.06 334.31,117.93 337.07,122.83 339.82,127.72 342.58,132.56 345.33,137.31 348.09,141.94 350.85,146.40 353.60,150.68 356.36,154.74 359.11,158.57 361.87,162.14 364.63,165.45 367.38,168.50 370.14,171.27 372.89,173.77 375.65,176.01 378.41,178.00 381.16,179.76 383.92,181.29 386.67,182.62 389.43,183.76 392.19,184.74 394.94,185.56 397.70,186.25 400.45,186.82 403.21,187.29 405.96,187.68 408.72,187.99 411.48,188.25 414.23,188.45 416.99,188.61 419.74,188.73 422.50,188.83" fill="none" stroke="#3498db" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<text x="247.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Density (KDE)</text>
+<rect x="455" y="39" width="12" height="12" fill="#3498db"/>
+<text x="471" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/plotly-density.txt b/test/golden/plotly-density.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-density.txt
@@ -0,0 +1,14 @@
+                                                        
+                  [90mDensity (KDE)                         [0m
+ [90m0.3 [0m[37m│              ⣀⡤⢤⣀⡀                    [0m[94m██         [0m
+     [37m│            ⢀⠞⠁   ⠈⠙⠒⠢⢄⡀               [0m[94m██[0m[90mseries 0 [0m
+     [37m│           ⢠⠊          ⠈⢦                         [0m
+ [90m0.2 [0m[37m│          ⢀⠎             ⠣⡀                       [0m
+     [37m│          ⡜               ⠱⡀                      [0m
+     [37m│         ⡰⠁                ⢱                      [0m
+ [90m0.1 [0m[37m│        ⢰⠁                  ⠱⡀                    [0m
+     [37m│       ⢠⠃                    ⢣                    [0m
+     [37m│      ⡠⠃                      ⠳⣀                  [0m
+     [37m│ ⣀⣀⣠⠤⠚⠁                        ⠈⠲⠤⣀⣀⡀             [0m
+ [90m0.0 [0m[37m┼───────────────────────────────────────           [0m
+          [90m0.0       2.0        4.0       6.0            [0m
diff --git a/test/golden/plotly-distplot.svg b/test/golden/plotly-distplot.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-distplot.svg
@@ -0,0 +1,27 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 256" width="560" height="256" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="228.20" x2="440" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="118.49" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="217.73" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="316.97" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="416.20" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">6.0</text>
+<text x="49" y="223.04" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="164.19" text-anchor="end" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="49" y="105.34" text-anchor="end" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="49" y="46.49" text-anchor="end" fill="#7f8c8d" font-size="11">3.0</text>
+<rect x="170.10" y="42.83" width="15.88" height="176.55" fill="#3498db"/>
+<rect x="189.94" y="42.83" width="15.88" height="176.55" fill="#3498db"/>
+<rect x="209.79" y="101.68" width="15.88" height="117.70" fill="#3498db"/>
+<rect x="229.64" y="42.83" width="15.88" height="176.55" fill="#3498db"/>
+<rect x="249.48" y="160.52" width="15.88" height="58.85" fill="#3498db"/>
+<rect x="269.33" y="42.83" width="15.88" height="176.55" fill="#3498db"/>
+<rect x="289.18" y="101.68" width="15.88" height="117.70" fill="#3498db"/>
+<rect x="309.03" y="42.83" width="15.88" height="176.55" fill="#3498db"/>
+<polyline points="80.44,219.33 83.07,219.32 85.70,219.31 88.33,219.29 90.96,219.26 93.59,219.23 96.22,219.19 98.86,219.14 101.49,219.07 104.12,219.00 106.75,218.91 109.38,218.80 112.01,218.66 114.64,218.51 117.27,218.33 119.90,218.12 122.53,217.88 125.16,217.61 127.79,217.30 130.43,216.95 133.06,216.56 135.69,216.14 138.32,215.67 140.95,215.16 143.58,214.62 146.21,214.04 148.84,213.43 151.47,212.79 154.10,212.13 156.73,211.45 159.37,210.76 162.00,210.06 164.63,209.36 167.26,208.66 169.89,207.98 172.52,207.32 175.15,206.68 177.78,206.08 180.41,205.50 183.04,204.97 185.67,204.48 188.31,204.03 190.94,203.63 193.57,203.27 196.20,202.96 198.83,202.69 201.46,202.46 204.09,202.28 206.72,202.13 209.35,202.02 211.98,201.94 214.61,201.89 217.24,201.87 219.88,201.87 222.51,201.89 225.14,201.93 227.77,201.98 230.40,202.05 233.03,202.13 235.66,202.21 238.29,202.30 240.92,202.39 243.55,202.48 246.18,202.57 248.82,202.66 251.45,202.75 254.08,202.84 256.71,202.92 259.34,203.01 261.97,203.09 264.60,203.17 267.23,203.26 269.86,203.35 272.49,203.44 275.12,203.55 277.76,203.66 280.39,203.79 283.02,203.94 285.65,204.10 288.28,204.29 290.91,204.50 293.54,204.74 296.17,205.00 298.80,205.29 301.43,205.61 304.06,205.97 306.69,206.35 309.33,206.76 311.96,207.21 314.59,207.68 317.22,208.17 319.85,208.69 322.48,209.23 325.11,209.79 327.74,210.36 330.37,210.94 333.00,211.52 335.63,212.10 338.27,212.67 340.90,213.23 343.53,213.78 346.16,214.31 348.79,214.82 351.42,215.30 354.05,215.75 356.68,216.18 359.31,216.57 361.94,216.93 364.57,217.26 367.21,217.55 369.84,217.82 372.47,218.05 375.10,218.26 377.73,218.44 380.36,218.60 382.99,218.74 385.62,218.85 388.25,218.95 390.88,219.03 393.51,219.10 396.14,219.16 398.78,219.20 401.41,219.24 404.04,219.27 406.67,219.29 409.30,219.31 411.93,219.33 414.56,219.34" fill="none" stroke="#9b59b6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<text x="247.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Distribution</text>
+<rect x="455" y="39" width="12" height="12" fill="#3498db"/>
+<text x="471" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+<rect x="455" y="56" width="12" height="12" fill="#9b59b6"/>
+<text x="471" y="66" text-anchor="start" fill="#7f8c8d" font-size="11">series 1</text>
+</svg>
diff --git a/test/golden/plotly-distplot.txt b/test/golden/plotly-distplot.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-distplot.txt
@@ -0,0 +1,16 @@
+                                                        
+                  [90mDistribution                          [0m
+ [90m3.0 [0m[37m│           [0m[94m████ ███ ███ ███            ██         [0m
+     [37m│           [0m[94m████ ███ ███ ███            [0m[95m██[0m[90mseries 0 [0m
+     [37m│           [0m[94m████ ███ ███ ███            [0m[95m██[0m[90mseries 1 [0m
+     [37m│           [0m[94m████ ███ ███ ███                       [0m
+ [90m2.0 [0m[37m│           [0m[94m████████ ███████                       [0m
+     [37m│           [0m[94m████████ ███████                       [0m
+     [37m│           [0m[94m████████ ███████                       [0m
+     [37m│           [0m[94m████████ ███████                       [0m
+ [90m1.0 [0m[37m│           [0m[94m████████████████                       [0m
+     [37m│           [0m[94m████████████████                       [0m
+     [37m│           [0m[94m████████████████                       [0m
+ [90m0.0 [0m[37m│  ⣀⣀⣀⣀⡠⠤⠤⠒⠊[0m[94m████████████████⠒⠲⠤⠤⣄⣀⣀⣀⣀              [0m
+     [37m┼───────────────────────────────────────           [0m
+          [90m0.0       2.0       4.0       6.0             [0m
diff --git a/test/golden/plotly-error-bars.svg b/test/golden/plotly-error-bars.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-error-bars.svg
@@ -0,0 +1,35 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 224" width="560" height="224" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="196.20" x2="440" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="174.58" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="320.42" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="178.84" text-anchor="end" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="49" y="124.23" text-anchor="end" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="69.62" text-anchor="end" fill="#7f8c8d" font-size="11">6.0</text>
+<polyline points="101.67,188.83 101.67,156.06" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="90.00,188.83 113.33,188.83" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="90.00,156.06 113.33,156.06" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="174.58,153.33 174.58,115.10" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="162.92,153.33 186.25,153.33" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="162.92,115.10 186.25,115.10" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="247.50,104.18 247.50,71.41" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="235.83,104.18 259.17,104.18" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="235.83,71.41 259.17,71.41" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="320.42,120.56 320.42,82.33" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="308.75,120.56 332.08,120.56" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="308.75,82.33 332.08,82.33" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="393.33,85.06 393.33,41.37" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="381.67,85.06 405.00,85.06" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="381.67,41.37 405.00,41.37" fill="none" stroke="#3498db" stroke-width="1.50" stroke-linejoin="round" stroke-linecap="round"/>
+<circle cx="101.67" cy="172.44" r="3" fill="#9b59b6"/>
+<circle cx="174.58" cy="134.21" r="3" fill="#9b59b6"/>
+<circle cx="247.50" cy="87.79" r="3" fill="#9b59b6"/>
+<circle cx="320.42" cy="101.45" r="3" fill="#9b59b6"/>
+<circle cx="393.33" cy="63.22" r="3" fill="#9b59b6"/>
+<text x="247.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Measurements ± CI</text>
+<rect x="455" y="39" width="12" height="12" fill="#3498db"/>
+<text x="471" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+<rect x="455" y="56" width="12" height="12" fill="#9b59b6"/>
+<text x="471" y="66" text-anchor="start" fill="#7f8c8d" font-size="11">series 1</text>
+</svg>
diff --git a/test/golden/plotly-error-bars.txt b/test/golden/plotly-error-bars.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-error-bars.txt
@@ -0,0 +1,14 @@
+                                                        
+                [90mMeasurements ± CI                       [0m
+     [37m│                                ⠤⢤⠤    [0m[94m██         [0m
+     [37m│                                 [0m[95m⢸     ██[0m[90mseries 0 [0m
+ [90m6.0 [0m[37m│                 ⠠⢤⠤⠄            [0m[95m⢹⠁    ██[0m[90mseries 1 [0m
+     [37m│                  [0m[95m⢼⠄     ⠒⡖⠂    ⠒⠚⠒               [0m
+     [37m│                 ⠠⠼⠤⠄    [0m[95m⠐⡗                       [0m
+ [90m4.0 [0m[37m│          ⠐⢲⠒            ⠤⠧⠄                      [0m
+     [37m│           [0m[95m⢼⠄                                     [0m
+     [37m│   ⣀⣀⣀    ⠠⠼⠤                                     [0m
+     [37m│   [0m[95m⢀⣇                                             [0m
+ [90m2.0 [0m[37m│   ⣀[0m[95m⣇⣀                                            [0m
+     [37m┼───────────────────────────────────────           [0m
+                [90m2.0            4.0                      [0m
diff --git a/test/golden/plotly-funnel.svg b/test/golden/plotly-funnel.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-funnel.svg
@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 224" width="600" height="224" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="196.20" x2="480" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="74.32" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="267.50" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">500.0</text>
+<text x="460.68" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1000.0</text>
+<text x="49" y="57.33" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="118.77" text-anchor="end" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="49" y="180.21" text-anchor="end" fill="#7f8c8d" font-size="11">4.0</text>
+<rect x="74.32" y="41.37" width="386.36" height="24.58" fill="#3498db"/>
+<rect x="74.32" y="72.09" width="278.18" height="24.58" fill="#3498db"/>
+<rect x="74.32" y="102.81" width="185.45" height="24.58" fill="#3498db"/>
+<rect x="74.32" y="133.53" width="85.00" height="24.58" fill="#3498db"/>
+<rect x="74.32" y="164.25" width="46.36" height="24.58" fill="#3498db"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Sales funnel</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/plotly-funnel.txt b/test/golden/plotly-funnel.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-funnel.txt
@@ -0,0 +1,14 @@
+                                                            
+                    [90mSales funnel                            [0m
+     [37m│ [0m[94m███████████████████████████████████████   ██         [0m
+ [90m0.0 [0m[37m│ [0m[94m███████████████████████████████████████   ██[0m[90mseries 0 [0m
+     [37m│ [0m[94m███████████████████████████████████████              [0m
+     [37m│ [0m[94m█████████████████████████████                        [0m
+     [37m│ [0m[94m███████████████████                                  [0m
+ [90m2.0 [0m[37m│ [0m[94m███████████████████                                  [0m
+     [37m│ [0m[94m█████████                                            [0m
+     [37m│ [0m[94m█████████                                            [0m
+     [37m│ [0m[94m█████                                                [0m
+ [90m4.0 [0m[37m│ [0m[94m█████                                                [0m
+     [37m┼───────────────────────────────────────────           [0m
+      [90m0.0               500.0              1000.0           [0m
diff --git a/test/golden/plotly-heatmap-annotated.svg b/test/golden/plotly-heatmap-annotated.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-heatmap-annotated.svg
@@ -0,0 +1,64 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 256" width="480" height="256" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="228.20" x2="360" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="91.97" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="207.50" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="323.03" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="208.33" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="134.77" text-anchor="end" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="49" y="61.21" text-anchor="end" fill="#7f8c8d" font-size="11">4.0</text>
+<rect x="63.09" y="186.27" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="63.09" y="149.49" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="63.09" y="112.71" width="57.77" height="36.78" fill="#1abc9c"/>
+<rect x="63.09" y="75.93" width="57.77" height="36.78" fill="#3498db"/>
+<rect x="63.09" y="39.15" width="57.77" height="36.78" fill="#2980b9"/>
+<rect x="120.85" y="186.27" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="120.85" y="149.49" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="120.85" y="112.71" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="120.85" y="75.93" width="57.77" height="36.78" fill="#1abc9c"/>
+<rect x="120.85" y="39.15" width="57.77" height="36.78" fill="#3498db"/>
+<rect x="178.62" y="186.27" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="178.62" y="149.49" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="178.62" y="112.71" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="178.62" y="75.93" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="178.62" y="39.15" width="57.77" height="36.78" fill="#1abc9c"/>
+<rect x="236.38" y="186.27" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="236.38" y="149.49" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="236.38" y="112.71" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="236.38" y="75.93" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="236.38" y="39.15" width="57.77" height="36.78" fill="#1abc9c"/>
+<rect x="294.15" y="186.27" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="294.15" y="149.49" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="294.15" y="112.71" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="294.15" y="75.93" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="294.15" y="39.15" width="57.77" height="36.78" fill="#1abc9c"/>
+<text x="91.97" y="204.66" text-anchor="middle" fill="#9b59b6" font-size="11">1.0</text>
+<text x="91.97" y="167.88" text-anchor="middle" fill="#9b59b6" font-size="11">0.9</text>
+<text x="91.97" y="131.10" text-anchor="middle" fill="#9b59b6" font-size="11">0.5</text>
+<text x="91.97" y="94.32" text-anchor="middle" fill="#9b59b6" font-size="11">0.1</text>
+<text x="91.97" y="57.54" text-anchor="middle" fill="#9b59b6" font-size="11">-0.4</text>
+<text x="149.73" y="204.66" text-anchor="middle" fill="#9b59b6" font-size="11">1.5</text>
+<text x="149.73" y="167.88" text-anchor="middle" fill="#9b59b6" font-size="11">1.4</text>
+<text x="149.73" y="131.10" text-anchor="middle" fill="#9b59b6" font-size="11">1.0</text>
+<text x="149.73" y="94.32" text-anchor="middle" fill="#9b59b6" font-size="11">0.6</text>
+<text x="149.73" y="57.54" text-anchor="middle" fill="#9b59b6" font-size="11">0.1</text>
+<text x="207.50" y="204.66" text-anchor="middle" fill="#9b59b6" font-size="11">1.8</text>
+<text x="207.50" y="167.88" text-anchor="middle" fill="#9b59b6" font-size="11">1.7</text>
+<text x="207.50" y="131.10" text-anchor="middle" fill="#9b59b6" font-size="11">1.4</text>
+<text x="207.50" y="94.32" text-anchor="middle" fill="#9b59b6" font-size="11">0.9</text>
+<text x="207.50" y="57.54" text-anchor="middle" fill="#9b59b6" font-size="11">0.4</text>
+<text x="265.27" y="204.66" text-anchor="middle" fill="#9b59b6" font-size="11">2.0</text>
+<text x="265.27" y="167.88" text-anchor="middle" fill="#9b59b6" font-size="11">1.9</text>
+<text x="265.27" y="131.10" text-anchor="middle" fill="#9b59b6" font-size="11">1.5</text>
+<text x="265.27" y="94.32" text-anchor="middle" fill="#9b59b6" font-size="11">1.1</text>
+<text x="265.27" y="57.54" text-anchor="middle" fill="#9b59b6" font-size="11">0.6</text>
+<text x="323.03" y="204.66" text-anchor="middle" fill="#9b59b6" font-size="11">1.9</text>
+<text x="323.03" y="167.88" text-anchor="middle" fill="#9b59b6" font-size="11">1.8</text>
+<text x="323.03" y="131.10" text-anchor="middle" fill="#9b59b6" font-size="11">1.4</text>
+<text x="323.03" y="94.32" text-anchor="middle" fill="#9b59b6" font-size="11">1.0</text>
+<text x="323.03" y="57.54" text-anchor="middle" fill="#9b59b6" font-size="11">0.5</text>
+<text x="207.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Annotated heatmap</text>
+<rect x="375" y="39" width="12" height="12" fill="#3498db"/>
+<text x="391" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/plotly-heatmap-annotated.txt b/test/golden/plotly-heatmap-annotated.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-heatmap-annotated.txt
@@ -0,0 +1,16 @@
+                                                
+            [90mAnnotated heatmap                   [0m
+     [37m│[0m[34m██████[0m[94m█████[0m[96m███████████████████ [0m[94m██         [0m
+ [90m4.0 [0m[37m│[0m[34m█[0m[95m-0.4[0m[34m█[0m[94m█[0m[95m0.1[0m[94m█[0m[96m██[0m[95m0.4[0m[96m███[0m[95m0.6[0m[96m███[0m[95m0.5[0m[96m██ [0m[94m██[0m[90mseries 0 [0m
+     [37m│[0m[34m██████[0m[94m█████[0m[96m███████████████████            [0m
+     [37m│[0m[94m██[0m[95m0.1[0m[94m█[0m[96m█[0m[95m0.6[0m[96m█[0m[92m██[0m[95m0.9[0m[92m███[0m[95m1.1[0m[92m███[0m[95m1.0[0m[92m██            [0m
+     [37m│[0m[94m██████[0m[96m█████[0m[92m███████████████████            [0m
+     [37m│[0m[96m██████[0m[92m█████[0m[93m███████████████████            [0m
+ [90m2.0 [0m[37m│[0m[96m██[0m[95m0.5[0m[96m█[0m[92m█[0m[95m1.0[0m[92m█[0m[93m██[0m[95m1.4[0m[93m███[0m[95m1.5[0m[93m███[0m[95m1.4[0m[93m██            [0m
+     [37m│[0m[96m██████[0m[92m█████[0m[93m███████████████████            [0m
+     [37m│[0m[92m██[0m[95m0.9[0m[92m█[0m[93m█[0m[95m1.4[0m[93m█[0m[91m██[0m[95m1.7[0m[91m███[0m[95m1.9[0m[91m███[0m[95m1.8[0m[91m██            [0m
+     [37m│[0m[92m██████[0m[93m█████[0m[91m███████████████████            [0m
+     [37m│[0m[92m██[0m[95m1.0[0m[92m█[0m[93m█[0m[95m1.5[0m[93m█[0m[91m██[0m[95m1.8[0m[91m███[0m[95m2.0[0m[91m███[0m[95m1.9[0m[91m██            [0m
+ [90m0.0 [0m[37m│[0m[92m██████[0m[93m█████[0m[91m███████████████████            [0m
+     [37m┼───────────────────────────────           [0m
+        [90m0.0        2.0         4.0              [0m
diff --git a/test/golden/plotly-heatmap.svg b/test/golden/plotly-heatmap.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-heatmap.svg
@@ -0,0 +1,39 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 256" width="480" height="256" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="228.20" x2="360" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="228.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="91.97" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="207.50" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="323.03" y="243.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="208.33" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="134.77" text-anchor="end" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="49" y="61.21" text-anchor="end" fill="#7f8c8d" font-size="11">4.0</text>
+<rect x="63.09" y="186.27" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="63.09" y="149.49" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="63.09" y="112.71" width="57.77" height="36.78" fill="#1abc9c"/>
+<rect x="63.09" y="75.93" width="57.77" height="36.78" fill="#3498db"/>
+<rect x="63.09" y="39.15" width="57.77" height="36.78" fill="#2980b9"/>
+<rect x="120.85" y="186.27" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="120.85" y="149.49" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="120.85" y="112.71" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="120.85" y="75.93" width="57.77" height="36.78" fill="#1abc9c"/>
+<rect x="120.85" y="39.15" width="57.77" height="36.78" fill="#3498db"/>
+<rect x="178.62" y="186.27" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="178.62" y="149.49" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="178.62" y="112.71" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="178.62" y="75.93" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="178.62" y="39.15" width="57.77" height="36.78" fill="#1abc9c"/>
+<rect x="236.38" y="186.27" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="236.38" y="149.49" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="236.38" y="112.71" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="236.38" y="75.93" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="236.38" y="39.15" width="57.77" height="36.78" fill="#1abc9c"/>
+<rect x="294.15" y="186.27" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="294.15" y="149.49" width="57.77" height="36.78" fill="#e74c3c"/>
+<rect x="294.15" y="112.71" width="57.77" height="36.78" fill="#f1c40f"/>
+<rect x="294.15" y="75.93" width="57.77" height="36.78" fill="#2ecc71"/>
+<rect x="294.15" y="39.15" width="57.77" height="36.78" fill="#1abc9c"/>
+<text x="207.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Heatmap</text>
+<rect x="375" y="39" width="12" height="12" fill="#3498db"/>
+<text x="391" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/plotly-heatmap.txt b/test/golden/plotly-heatmap.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-heatmap.txt
@@ -0,0 +1,16 @@
+                                                
+                 [90mHeatmap                        [0m
+     [37m│[0m[34m██████[0m[94m█████[0m[96m███████████████████ [0m[94m██         [0m
+ [90m4.0 [0m[37m│[0m[34m██████[0m[94m█████[0m[96m███████████████████ [0m[94m██[0m[90mseries 0 [0m
+     [37m│[0m[34m██████[0m[94m█████[0m[96m███████████████████            [0m
+     [37m│[0m[94m██████[0m[96m█████[0m[92m███████████████████            [0m
+     [37m│[0m[94m██████[0m[96m█████[0m[92m███████████████████            [0m
+     [37m│[0m[96m██████[0m[92m█████[0m[93m███████████████████            [0m
+ [90m2.0 [0m[37m│[0m[96m██████[0m[92m█████[0m[93m███████████████████            [0m
+     [37m│[0m[96m██████[0m[92m█████[0m[93m███████████████████            [0m
+     [37m│[0m[92m██████[0m[93m█████[0m[91m███████████████████            [0m
+     [37m│[0m[92m██████[0m[93m█████[0m[91m███████████████████            [0m
+     [37m│[0m[92m██████[0m[93m█████[0m[91m███████████████████            [0m
+ [90m0.0 [0m[37m│[0m[92m██████[0m[93m█████[0m[91m███████████████████            [0m
+     [37m┼───────────────────────────────           [0m
+        [90m0.0        2.0         4.0              [0m
diff --git a/test/golden/plotly-waterfall.svg b/test/golden/plotly-waterfall.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-waterfall.svg
@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 224" width="600" height="224" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="196.20" x2="480" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="196.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="100.96" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="234.19" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="367.42" y="211.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="192.49" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="116.88" text-anchor="end" fill="#7f8c8d" font-size="11">100.0</text>
+<text x="49" y="41.26" text-anchor="end" fill="#7f8c8d" font-size="11">200.0</text>
+<rect x="74.32" y="113.21" width="53.29" height="75.62" fill="#3498db"/>
+<rect x="140.93" y="90.52" width="53.29" height="22.69" fill="#3498db"/>
+<rect x="207.55" y="52.72" width="53.29" height="37.81" fill="#3498db"/>
+<rect x="274.16" y="52.72" width="53.29" height="3.78" fill="#3498db"/>
+<rect x="340.78" y="41.37" width="53.29" height="15.12" fill="#3498db"/>
+<rect x="407.39" y="41.37" width="53.29" height="147.45" fill="#3498db"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Waterfall</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">series 0</text>
+</svg>
diff --git a/test/golden/plotly-waterfall.txt b/test/golden/plotly-waterfall.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/plotly-waterfall.txt
@@ -0,0 +1,14 @@
+                                                            
+                      [90mWaterfall                             [0m
+[90m00.0 [0m[37m│                            [0m[94m████████████   ██         [0m
+     [37m│              [0m[94m██████ ██████ ████████████   ██[0m[90mseries 0 [0m
+     [37m│              [0m[94m██████              ██████              [0m
+     [37m│        [0m[94m████████████              ██████              [0m
+     [37m│        [0m[94m██████                    ██████              [0m
+[90m00.0 [0m[37m│ [0m[94m██████ ██████                    ██████              [0m
+     [37m│ [0m[94m██████                           ██████              [0m
+     [37m│ [0m[94m██████                           ██████              [0m
+     [37m│ [0m[94m██████                           ██████              [0m
+     [37m│ [0m[94m██████                           ██████              [0m
+ [90m0.0 [0m[37m┼───────────────────────────────────────────           [0m
+         [90m0.0          2.0          4.0                      [0m
diff --git a/test/golden/polar-rose.svg b/test/golden/polar-rose.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/polar-rose.svg
@@ -0,0 +1,22 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 320" width="400" height="320" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<polyline points="172.61,163.10 172.59,163.60 172.52,164.10 172.39,164.58 172.22,165.06 172.01,165.51 171.75,165.94 171.45,166.34 171.12,166.72 170.74,167.05 170.34,167.35 169.91,167.61 169.46,167.82 168.98,167.99 168.50,168.12 168.00,168.19 167.50,168.21 167.00,168.19 166.50,168.12 166.02,167.99 165.54,167.82 165.09,167.61 164.66,167.35 164.26,167.05 163.88,166.72 163.55,166.34 163.25,165.94 162.99,165.51 162.78,165.06 162.61,164.58 162.48,164.10 162.41,163.60 162.39,163.10 162.41,162.60 162.48,162.10 162.61,161.62 162.78,161.14 162.99,160.69 163.25,160.26 163.55,159.86 163.88,159.48 164.26,159.15 164.66,158.85 165.09,158.59 165.54,158.38 166.02,158.21 166.50,158.08 167.00,158.01 167.50,157.99 168.00,158.01 168.50,158.08 168.98,158.21 169.46,158.38 169.91,158.59 170.34,158.85 170.74,159.15 171.12,159.48 171.45,159.86 171.75,160.26 172.01,160.69 172.22,161.14 172.39,161.62 172.52,162.10 172.59,162.60 172.61,163.10" fill="none" stroke="#bdc3c7" stroke-width="0.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="223.75,163.10 223.48,168.61 222.67,174.07 221.33,179.43 219.47,184.63 217.11,189.62 214.27,194.35 210.98,198.78 207.27,202.87 203.18,206.58 198.75,209.87 194.02,212.71 189.03,215.07 183.83,216.93 178.47,218.27 173.01,219.08 167.50,219.35 161.99,219.08 156.53,218.27 151.17,216.93 145.97,215.07 140.98,212.71 136.25,209.87 131.82,206.58 127.73,202.87 124.02,198.78 120.73,194.35 117.89,189.62 115.53,184.63 113.67,179.43 112.33,174.07 111.52,168.61 111.25,163.10 111.52,157.59 112.33,152.13 113.67,146.77 115.53,141.57 117.89,136.58 120.73,131.85 124.02,127.42 127.73,123.33 131.82,119.62 136.25,116.33 140.98,113.49 145.97,111.13 151.17,109.27 156.53,107.93 161.99,107.12 167.50,106.85 173.01,107.12 178.47,107.93 183.83,109.27 189.03,111.13 194.02,113.49 198.75,116.33 203.18,119.62 207.27,123.33 210.98,127.42 214.27,131.85 217.11,136.58 219.47,141.57 221.33,146.77 222.67,152.13 223.48,157.59 223.75,163.10" fill="none" stroke="#bdc3c7" stroke-width="0.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="274.89,163.10 274.37,173.63 272.82,184.05 270.26,194.27 266.71,204.19 262.21,213.72 256.79,222.76 250.51,231.23 243.43,239.03 235.63,246.11 227.16,252.39 218.12,257.81 208.59,262.31 198.67,265.86 188.45,268.42 178.03,269.97 167.50,270.49 156.97,269.97 146.55,268.42 136.33,265.86 126.41,262.31 116.88,257.81 107.84,252.39 99.37,246.11 91.57,239.03 84.49,231.23 78.21,222.76 72.79,213.72 68.29,204.19 64.74,194.27 62.18,184.05 60.63,173.63 60.11,163.10 60.63,152.57 62.18,142.15 64.74,131.93 68.29,122.01 72.79,112.48 78.21,103.44 84.49,94.97 91.57,87.17 99.37,80.09 107.84,73.81 116.88,68.39 126.41,63.89 136.33,60.34 146.55,57.78 156.97,56.23 167.50,55.71 178.03,56.23 188.45,57.78 198.67,60.34 208.59,63.89 218.12,68.39 227.16,73.81 235.63,80.09 243.43,87.17 250.51,94.97 256.79,103.44 262.21,112.48 266.71,122.01 270.26,131.93 272.82,142.15 274.37,152.57 274.89,163.10" fill="none" stroke="#bdc3c7" stroke-width="0.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="167.50,163.10 275.44,131.41" fill="none" stroke="#bdc3c7" stroke-width="0.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="167.50,163.10 110.34,66.20" fill="none" stroke="#bdc3c7" stroke-width="0.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="167.50,163.10 87.55,242.25" fill="none" stroke="#bdc3c7" stroke-width="0.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="167.50,163.10 263.82,221.23" fill="none" stroke="#bdc3c7" stroke-width="0.50" stroke-linejoin="round" stroke-linecap="round"/>
+<polyline points="280,163.10 279.46,174.13 277.84,185.05 275.16,195.76 271.44,206.15 266.72,216.13 261.04,225.60 254.46,234.47 247.05,242.65 238.87,250.06 230.00,256.64 220.53,262.32 210.55,267.04 200.16,270.76 189.45,273.44 178.53,275.06 167.50,275.60 156.47,275.06 145.55,273.44 134.84,270.76 124.45,267.04 114.47,262.32 105.00,256.64 96.13,250.06 87.95,242.65 80.54,234.47 73.96,225.60 68.28,216.13 63.56,206.15 59.84,195.76 57.16,185.05 55.54,174.13 55,163.10 55.54,152.07 57.16,141.15 59.84,130.44 63.56,120.05 68.28,110.07 73.96,100.60 80.54,91.73 87.95,83.55 96.13,76.14 105.00,69.56 114.47,63.88 124.45,59.16 134.84,55.44 145.55,52.76 156.47,51.14 167.50,50.60 178.53,51.14 189.45,52.76 200.16,55.44 210.55,59.16 220.53,63.88 230.00,69.56 238.87,76.14 247.05,83.55 254.46,91.73 261.04,100.60 266.72,110.07 271.44,120.05 275.16,130.44 277.84,141.15 279.46,152.07 280,163.10" fill="none" stroke="#ecf0f1" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"/>
+<text x="285.04" y="132.25" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="105.26" y="61.26" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="80.45" y="252.95" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="272.38" y="230.07" text-anchor="middle" fill="#7f8c8d" font-size="11">6.0</text>
+<text x="175.61" y="161.10" text-anchor="start" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="226.75" y="161.10" text-anchor="start" fill="#7f8c8d" font-size="11">0.5</text>
+<text x="277.89" y="161.10" text-anchor="start" fill="#7f8c8d" font-size="11">1.0</text>
+<polyline points="172.41,161.66 207.07,143.29 229.49,116.70 235.37,90.20 225.56,72.76 205.62,71.08 183.96,87.44 169.08,118.88 166.77,158.04 153.53,121.11 130.39,95.14 105.00,85.55 86.34,92.78 81.84,112.28 94.95,136.04 123.95,155.24 162.39,163.10 123.95,170.96 94.95,190.16 81.84,213.92 86.34,233.42 105.00,240.65 130.39,231.06 153.53,205.09 166.77,168.16 169.08,207.32 183.96,238.76 205.62,255.12 225.56,253.44 235.37,236.00 229.49,209.50 207.07,182.91 172.41,164.54" fill="none" stroke="#3498db" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<text x="167.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Polar rose</text>
+<rect x="295" y="39" width="12" height="12" fill="#3498db"/>
+<text x="311" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">r = |sin(2θ)|</text>
+</svg>
diff --git a/test/golden/polar-rose.txt b/test/golden/polar-rose.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/polar-rose.txt
@@ -0,0 +1,20 @@
+                                        
+           [90mPolar rose                   [0m
+                             [94m██         [0m
+         [90m2.0⣀⣤⠶⠶⠶⠶⠶⢦⣄⡀       [0m[94m██[0m[90mr = |sin([0m
+         ⢀⢔⢟⠉       ⡨⠽⢷⡢⡀               
+        ⣠⡾⠕⠬⣆     ⢠⠊   ⢫⠺⣄              
+       ⡰⡝   ⠈⢗⡄⣀⣀⣠⣃    ⡸ ⠱⣇             
+      ⢠⡟⠱⡀   ⡜⢏⢢ ⡎ ⠉⢢ ⢠⠃  ⢫⡆            
+     ⢀⡷⠁ ⠱⡀⢀⠎ ⠈⢎⣆⠇   ⡵⡁ ⢀⣀⠤[90m0.0          [0m
+     ⢸⡇   ⠈⡞⢄⡀ ⠈⢿⣀⡠⣔⡪⠤⢳⠉⠁  ⢸⡇           
+     ⢸⡇    ⡇⢀⡨⠭⠕⣺[90m0.0  0.5  1.0          [0m
+     ⢸⡇   ⡠⠵⡁ ⢀⠔⡿⡀ ⠑⠢⣀⠎    ⢸⡇           
+      ⢿⡀⢀⠎  ⠣⡠⠃⢰⠁⡇  ⢀⠜⢕⢄⡀ ⢠⡻            
+      ⠘⡵⡎  ⢀⠎⠉⡢⢇⣀⣣⡠⠔⠉  ⡇⠈⢢⣷⠁            
+       ⠘⣿⣀⡔⢁⡠⠜    ⢇    ⢸⢀⢮[90m6.0           [0m
+       [90m4.0⡭⡁      ⠈⠢⡀⢀⣀⡮⡞⠁              [0m
+          ⠈⠪⢦⡤⢄⣀⡀ ⣀⣀⠬⣕⠕⠉                
+             ⠈⠉⠑⠚⠛⠒⠉⠉                   
+                                        
+                                        
diff --git a/test/golden/primitives.svg b/test/golden/primitives.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/primitives.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 120" width="240" height="120" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<rect x="10" y="10" width="40" height="30" fill="#3498db"/>
+<circle cx="120" cy="50" r="12" fill="#e74c3c"/>
+<polyline points="20,100 80,80 140,90 220,70" fill="none" stroke="#2ecc71" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+<text x="120" y="25" text-anchor="start" fill="#f1c40f" font-size="11">Primitives</text>
+<path d="M 200 50 L 220 50 A 20 20 0 0 0 201.41 69.95 Z" fill="#9b59b6"/>
+</svg>
diff --git a/test/golden/primitives.txt b/test/golden/primitives.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/primitives.txt
@@ -0,0 +1,8 @@
+ [94m████                   [0m
+ [94m████       [0m[93mPrimitives  [0m
+ [94m████      [0m[91m⢠⣦           [0m
+           [91m⠹⡿⠁       [0m[95m⢀⡇ [0m
+                   ⣀[95m⣒⠮⠄ [0m
+    ⣀⠤⠔⠊⠉⠑⠒⠒⠢⠤⠤⠒⠒⠉⠉     
+  ⠒⠉                    
+                        
diff --git a/test/golden/scatter-basic.svg b/test/golden/scatter-basic.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/scatter-basic.svg
@@ -0,0 +1,29 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 320" width="600" height="320" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="292.20" x2="480" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="74.32" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="170.91" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="267.50" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="364.09" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="460.68" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="284.13" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="210.78" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="49" y="137.43" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<text x="49" y="64.07" text-anchor="end" fill="#7f8c8d" font-size="11">15.0</text>
+<circle cx="74.32" cy="280.46" r="3" fill="#3498db"/>
+<circle cx="170.91" cy="265.79" r="3" fill="#3498db"/>
+<circle cx="267.50" cy="221.78" r="3" fill="#3498db"/>
+<circle cx="364.09" cy="148.43" r="3" fill="#3498db"/>
+<circle cx="460.68" cy="45.74" r="3" fill="#3498db"/>
+<circle cx="74.32" cy="265.79" r="3" fill="#9b59b6"/>
+<circle cx="170.91" cy="236.45" r="3" fill="#9b59b6"/>
+<circle cx="267.50" cy="192.44" r="3" fill="#9b59b6"/>
+<circle cx="364.09" cy="133.76" r="3" fill="#9b59b6"/>
+<circle cx="460.68" cy="60.41" r="3" fill="#9b59b6"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Random points</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">A</text>
+<rect x="495" y="56" width="12" height="12" fill="#9b59b6"/>
+<text x="511" y="66" text-anchor="start" fill="#7f8c8d" font-size="11">B</text>
+</svg>
diff --git a/test/golden/scatter-basic.txt b/test/golden/scatter-basic.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/scatter-basic.txt
@@ -0,0 +1,20 @@
+                                                            
+                    [90mRandom points                           [0m
+     [37m│                                       [0m[94m⢀⣄  ██         [0m
+     [37m│                                       [0m[95m⢀⣅  ██[0m[90mA        [0m
+[90m15.0 [0m[37m│                                        [0m[95m⠁  ██[0m[90mB        [0m
+     [37m│                                                      [0m
+     [37m│                                                      [0m
+     [37m│                                                      [0m
+[90m10.0 [0m[37m│                              [0m[95m⠺⠂                      [0m
+     [37m│                              [0m[94m⠺⠂                      [0m
+     [37m│                                                      [0m
+     [37m│                    [0m[95m⢀                                 [0m
+     [37m│                    [0m[95m⠙⠁                                [0m
+ [90m5.0 [0m[37m│                    [0m[94m⣠⡀                                [0m
+     [37m│          [0m[95m⢀⣄        [0m[94m⠈                                 [0m
+     [37m│           [0m[95m⠁                                          [0m
+     [37m│ [0m[95m⢴⠄       [0m[94m⠠⡦                                          [0m
+ [90m0.0 [0m[37m│ [0m[94m⢴⠄                                                   [0m
+     [37m┼───────────────────────────────────────────           [0m
+      [90m0.0       1.0      2.0       3.0       4.0            [0m
diff --git a/test/golden/scatter-log-y.svg b/test/golden/scatter-log-y.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/scatter-log-y.svg
@@ -0,0 +1,28 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 320" width="600" height="320" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="292.20" x2="480" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="74.32" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="170.91" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="267.50" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="364.09" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="460.68" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="221.86" text-anchor="end" fill="#7f8c8d" font-size="11">0.1</text>
+<text x="49" y="143.62" text-anchor="end" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="49" y="65.37" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<circle cx="74.32" cy="280.46" r="3" fill="#3498db"/>
+<circle cx="170.91" cy="139.95" r="3" fill="#3498db"/>
+<circle cx="267.50" cy="92.84" r="3" fill="#3498db"/>
+<circle cx="364.09" cy="65.29" r="3" fill="#3498db"/>
+<circle cx="460.68" cy="45.74" r="3" fill="#3498db"/>
+<circle cx="74.32" cy="139.95" r="3" fill="#9b59b6"/>
+<circle cx="170.91" cy="102.62" r="3" fill="#9b59b6"/>
+<circle cx="267.50" cy="79.07" r="3" fill="#9b59b6"/>
+<circle cx="364.09" cy="61.71" r="3" fill="#9b59b6"/>
+<circle cx="460.68" cy="47.93" r="3" fill="#9b59b6"/>
+<text x="267.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Log Y</text>
+<rect x="495" y="39" width="12" height="12" fill="#3498db"/>
+<text x="511" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">A</text>
+<rect x="495" y="56" width="12" height="12" fill="#9b59b6"/>
+<text x="511" y="66" text-anchor="start" fill="#7f8c8d" font-size="11">B</text>
+</svg>
diff --git a/test/golden/scatter-no-title.svg b/test/golden/scatter-no-title.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/scatter-no-title.svg
@@ -0,0 +1,28 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 320" width="600" height="320" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="292.20" x2="480" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="10" x2="55" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="74.32" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="170.91" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="267.50" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="364.09" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="460.68" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="283.04" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="202.87" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="49" y="122.70" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<text x="49" y="42.53" text-anchor="end" fill="#7f8c8d" font-size="11">15.0</text>
+<circle cx="74.32" cy="279.37" r="3" fill="#3498db"/>
+<circle cx="170.91" cy="263.34" r="3" fill="#3498db"/>
+<circle cx="267.50" cy="215.24" r="3" fill="#3498db"/>
+<circle cx="364.09" cy="135.07" r="3" fill="#3498db"/>
+<circle cx="460.68" cy="22.83" r="3" fill="#3498db"/>
+<circle cx="74.32" cy="263.34" r="3" fill="#9b59b6"/>
+<circle cx="170.91" cy="231.27" r="3" fill="#9b59b6"/>
+<circle cx="267.50" cy="183.17" r="3" fill="#9b59b6"/>
+<circle cx="364.09" cy="119.03" r="3" fill="#9b59b6"/>
+<circle cx="460.68" cy="38.86" r="3" fill="#9b59b6"/>
+<rect x="495" y="15" width="12" height="12" fill="#3498db"/>
+<text x="511" y="25" text-anchor="start" fill="#7f8c8d" font-size="11">A</text>
+<rect x="495" y="32" width="12" height="12" fill="#9b59b6"/>
+<text x="511" y="42" text-anchor="start" fill="#7f8c8d" font-size="11">B</text>
+</svg>
diff --git a/test/golden/scatter-responsive.svg b/test/golden/scatter-responsive.svg
new file mode 100644
--- /dev/null
+++ b/test/golden/scatter-responsive.svg
@@ -0,0 +1,29 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 320" preserveAspectRatio="xMidYMid meet" width="100%" style="height:auto" font-family="system-ui, -apple-system, sans-serif">
+<rect width="100%" height="100%" fill="white"/>
+<line x1="55" y1="292.20" x2="392" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<line x1="55" y1="34" x2="55" y2="292.20" stroke="#ecf0f1" stroke-width="1"/>
+<text x="70.32" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="146.91" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">1.0</text>
+<text x="223.50" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">2.0</text>
+<text x="300.09" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">3.0</text>
+<text x="376.68" y="307.20" text-anchor="middle" fill="#7f8c8d" font-size="11">4.0</text>
+<text x="49" y="284.13" text-anchor="end" fill="#7f8c8d" font-size="11">0.0</text>
+<text x="49" y="210.78" text-anchor="end" fill="#7f8c8d" font-size="11">5.0</text>
+<text x="49" y="137.43" text-anchor="end" fill="#7f8c8d" font-size="11">10.0</text>
+<text x="49" y="64.07" text-anchor="end" fill="#7f8c8d" font-size="11">15.0</text>
+<circle cx="70.32" cy="280.46" r="3" fill="#3498db"/>
+<circle cx="146.91" cy="265.79" r="3" fill="#3498db"/>
+<circle cx="223.50" cy="221.78" r="3" fill="#3498db"/>
+<circle cx="300.09" cy="148.43" r="3" fill="#3498db"/>
+<circle cx="376.68" cy="45.74" r="3" fill="#3498db"/>
+<circle cx="70.32" cy="265.79" r="3" fill="#9b59b6"/>
+<circle cx="146.91" cy="236.45" r="3" fill="#9b59b6"/>
+<circle cx="223.50" cy="192.44" r="3" fill="#9b59b6"/>
+<circle cx="300.09" cy="133.76" r="3" fill="#9b59b6"/>
+<circle cx="376.68" cy="60.41" r="3" fill="#9b59b6"/>
+<text x="223.50" y="26" text-anchor="middle" fill="#7f8c8d" font-size="14">Responsive</text>
+<rect x="407" y="39" width="12" height="12" fill="#3498db"/>
+<text x="423" y="49" text-anchor="start" fill="#7f8c8d" font-size="11">A</text>
+<rect x="407" y="56" width="12" height="12" fill="#9b59b6"/>
+<text x="423" y="66" text-anchor="start" fill="#7f8c8d" font-size="11">B</text>
+</svg>
