Repository: mwaskom/seaborn (2023-01-09 ~ 2023-06-19)

problem_statement: Width computation after histogram slightly wrong with log scale
Note the slight overlap here:

```python
(
    so.Plot(tips, "total_bill")
    .add(so.Bars(alpha=.3, edgewidth=0), so.Hist(bins=4))
    .scale(x="log")
)
```
![image](https://user-images.githubusercontent.com/315810/178975852-d8fd830e-ae69-487d-be22-36531fca3f8f.png)

It becomes nearly imperceptible with more bins:

```
(
    so.Plot(tips, "total_bill")
    .add(so.Bars(alpha=.3, edgewidth=0), so.Hist(bins=8))
    .scale(x="log")
)
```
![image](https://user-images.githubusercontent.com/315810/178976113-7026b3ae-0b87-48df-adc0-00e90d5aea94.png)

This is not about `Bars`; `Bar` has it too:

```python
(
    so.Plot(tips, "total_bill")
    .add(so.Bar(alpha=.3, edgewidth=0, width=1), so.Hist(bins=4))
    .scale(x="log")
)
```
![image](https://user-images.githubusercontent.com/315810/178975910-484df65f-4ce6-482e-9992-5d02faf6b9ea.png)


date: 2023-01-09
patch: diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py
--- a/seaborn/_core/plot.py
+++ b/seaborn/_core/plot.py
@@ -1377,10 +1377,9 @@ def _unscale_coords(
     ) -> DataFrame:
         # TODO do we still have numbers in the variable name at this point?
         coord_cols = [c for c in df if re.match(r"^[xy]\D*$", str(c))]
-        drop_cols = [*coord_cols, "width"] if "width" in df else coord_cols
         out_df = (
             df
-            .drop(drop_cols, axis=1)
+            .drop(coord_cols, axis=1)
             .reindex(df.columns, axis=1)  # So unscaled columns retain their place
             .copy(deep=False)
         )
@@ -1396,12 +1395,6 @@ def _unscale_coords(
                 inverted = transform(values)
                 out_df.loc[values.index, str(var)] = inverted
 
-                if var == orient and "width" in view_df:
-                    width = view_df["width"]
-                    out_df.loc[values.index, "width"] = (
-                        transform(values + width / 2) - transform(values - width / 2)
-                    )
-
         return out_df
 
     def _generate_pairings(
diff --git a/seaborn/_marks/bar.py b/seaborn/_marks/bar.py
--- a/seaborn/_marks/bar.py
+++ b/seaborn/_marks/bar.py
@@ -29,17 +29,23 @@ class BarBase(Mark):
 
     def _make_patches(self, data, scales, orient):
 
+        transform = scales[orient]._matplotlib_scale.get_transform()
+        forward = transform.transform
+        reverse = transform.inverted().transform
+
+        other = {"x": "y", "y": "x"}[orient]
+
+        pos = reverse(forward(data[orient]) - data["width"] / 2)
+        width = reverse(forward(data[orient]) + data["width"] / 2) - pos
+
+        val = (data[other] - data["baseline"]).to_numpy()
+        base = data["baseline"].to_numpy()
+
         kws = self._resolve_properties(data, scales)
         if orient == "x":
-            kws["x"] = (data["x"] - data["width"] / 2).to_numpy()
-            kws["y"] = data["baseline"].to_numpy()
-            kws["w"] = data["width"].to_numpy()
-            kws["h"] = (data["y"] - data["baseline"]).to_numpy()
+            kws.update(x=pos, y=base, w=width, h=val)
         else:
-            kws["x"] = data["baseline"].to_numpy()
-            kws["y"] = (data["y"] - data["width"] / 2).to_numpy()
-            kws["w"] = (data["x"] - data["baseline"]).to_numpy()
-            kws["h"] = data["width"].to_numpy()
+            kws.update(x=base, y=pos, w=val, h=width)
 
         kws.pop("width", None)
         kws.pop("baseline", None)

test_patch: diff --git a/tests/_marks/test_bar.py b/tests/_marks/test_bar.py
--- a/tests/_marks/test_bar.py
+++ b/tests/_marks/test_bar.py
@@ -200,3 +200,13 @@ def test_unfilled(self, x, y):
         colors = p._theme["axes.prop_cycle"].by_key()["color"]
         assert_array_equal(fcs, to_rgba_array([colors[0]] * len(x), 0))
         assert_array_equal(ecs, to_rgba_array([colors[4]] * len(x), 1))
+
+    def test_log_scale(self):
+
+        x = y = [1, 10, 100, 1000]
+        p = Plot(x, y).add(Bars()).scale(x="log").plot()
+        ax = p._figure.axes[0]
+
+        paths = ax.collections[0].get_paths()
+        for a, b in zip(paths, paths[1:]):
+            assert a.vertices[1, 0] == pytest.approx(b.vertices[0, 0])


----

problem_statement: `heatmap(..., norm=None, ...)` has different behaviour than without specifying the `norm` argument
Hi,

I'm noticing a strange behaviour when passing `norm=None` to a heatmap and I believe it's due to these lines: https://github.com/mwaskom/seaborn/blob/3733590d86a7f2c2a95cd9940a34aa7df5f5a3d2/seaborn/matrix.py#L299-L303

Specifically, if I use `sns.heatmap(..., vmin=0.0, vmax=1.0, ...)` I get something like this:

![without-norm](https://user-images.githubusercontent.com/3457859/220935158-fdc86688-1780-4efd-8418-28523bdc5c24.png)

but when I use `sns.heatmap(..., vmin=0.0, vmax=1.0, norm=None, ...)`, `vmin` and `vmax` are lost:

![with-norm](https://user-images.githubusercontent.com/3457859/220935301-d8c4b1ce-d76b-4d58-add5-18d08529ab41.png)

I'm happy to send a PR if this issue isn't addressed anywhere.

Ștefan

date: 2023-02-22
patch: diff --git a/seaborn/matrix.py b/seaborn/matrix.py
--- a/seaborn/matrix.py
+++ b/seaborn/matrix.py
@@ -298,7 +298,7 @@ def plot(self, ax, cax, kws):
 
         # setting vmin/vmax in addition to norm is deprecated
         # so avoid setting if norm is set
-        if "norm" not in kws:
+        if kws.get("norm") is None:
             kws.setdefault("vmin", self.vmin)
             kws.setdefault("vmax", self.vmax)
 

test_patch: diff --git a/tests/test_matrix.py b/tests/test_matrix.py
--- a/tests/test_matrix.py
+++ b/tests/test_matrix.py
@@ -265,6 +265,20 @@ def test_cmap_with_properties(self):
         hm = mat._HeatMapper(self.df_unif, **kws)
         npt.assert_array_equal(cmap(np.inf), hm.cmap(np.inf))
 
+    def test_explicit_none_norm(self):
+
+        vals = np.linspace(.2, 1, 9)
+        cmap = mpl.cm.binary
+        _, (ax1, ax2) = plt.subplots(2)
+
+        mat.heatmap([vals], vmin=0, cmap=cmap, ax=ax1)
+        fc_default_norm = ax1.collections[0].get_facecolors()
+
+        mat.heatmap([vals], vmin=0, norm=None, cmap=cmap, ax=ax2)
+        fc_explicit_norm = ax2.collections[0].get_facecolors()
+
+        npt.assert_array_almost_equal(fc_default_norm, fc_explicit_norm, 2)
+
     def test_ticklabels_off(self):
         kws = self.default_kws.copy()
         kws['xticklabels'] = False


----

problem_statement: pd.NA reverses axis ordering
## Issue
When plotting with `pd.NA`, axis ordering get reversed into **descending**. 

## Workaround
`np.nan` does not produce this issue

## Expected Behavior
NAs should be excluded without reversing axis order

## Reproducible Example
```python
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt


mock_data = pd.DataFrame({
    'date': ['0', '1', '2', '3'],
    'value': [1, 2, 1, 1.5]
})

mock_data_full = mock_data.copy()
mock_data_full['type'] = 'no_NA'

mock_data_pd_na = mock_data.copy()
mock_data_pd_na['type'] = 'pd.NA'
mock_data_pd_na.loc[2, 'value'] = pd.NA

mock_data_np_nan = mock_data.copy()
mock_data_np_nan['type'] = 'np.nan'
mock_data_np_nan.loc[2, 'value'] = np.nan

test_data = pd.concat([mock_data_full, mock_data_pd_na, mock_data_np_nan])

grid = sns.FacetGrid(
    data=test_data,
    col='type',
    sharey=False,
    sharex=True,  # time-series consistency
)
grid.map(sns.lineplot, 'date', 'value', alpha=0.5)
plt.show()
```
#### Result
![image](https://user-images.githubusercontent.com/22682408/138944257-f0ff1d0b-717b-48cf-895d-97683a9dd019.png)



## System Info
```python
print(f'''
    python: {sys.version},
    seaborn: {sns.__version__},
    pandas: {pd.__version__}
''')
```
```log
    python: 3.9.7 (default, Sep  9 2021, 23:20:13)  [GCC 9.3.0],
    seaborn: 0.11.2,
    pandas: 1.3.4
```





date: 2023-06-19
patch: diff --git a/seaborn/_compat.py b/seaborn/_compat.py
index 52902439..05a4a5f2 100644
--- a/seaborn/_compat.py
+++ b/seaborn/_compat.py
@@ -166,3 +166,11 @@ def share_axis(ax0, ax1, which):
         group.join(ax1, ax0)
     else:
         getattr(ax1, f"share{which}")(ax0)
+
+
+def get_legend_handles(legend):
+    """Handle legendHandles attribute rename."""
+    if _version_predates(mpl, "3.7"):
+        return legend.legendHandles
+    else:
+        return legend.legend_handles
diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py
index 940f1db7..a73af9bf 100644
--- a/seaborn/_core/plot.py
+++ b/seaborn/_core/plot.py
@@ -4,14 +4,14 @@ from __future__ import annotations
 import io
 import os
 import re
-import sys
 import inspect
 import itertools
 import textwrap
 from contextlib import contextmanager
 from collections import abc
 from collections.abc import Callable, Generator
-from typing import Any, List, Optional, cast
+from typing import Any, List, Literal, Optional, cast
+from xml.etree import ElementTree
 
 from cycler import cycler
 import pandas as pd
@@ -20,6 +20,7 @@ import matplotlib as mpl
 from matplotlib.axes import Axes
 from matplotlib.artist import Artist
 from matplotlib.figure import Figure
+from PIL import Image
 
 from seaborn._marks.base import Mark
 from seaborn._stats.base import Stat
@@ -43,21 +44,15 @@ from seaborn.rcmod import axes_style, plotting_context
 from seaborn.palettes import color_palette
 from seaborn.utils import _version_predates
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, TypedDict
 if TYPE_CHECKING:
     from matplotlib.figure import SubFigure
 
 
-if sys.version_info >= (3, 8):
-    from typing import TypedDict
-else:
-    from typing_extensions import TypedDict
-
-
 default = Default()
 
 
-# ---- Definitions for internal specs --------------------------------- #
+# ---- Definitions for internal specs ---------------------------------------------- #
 
 
 class Layer(TypedDict, total=False):
@@ -87,7 +82,7 @@ class PairSpec(TypedDict, total=False):
     wrap: int | None
 
 
-# --- Local helpers ----------------------------------------------------------------
+# --- Local helpers ---------------------------------------------------------------- #
 
 
 @contextmanager
@@ -102,13 +97,11 @@ def theme_context(params: dict[str, Any]) -> Generator:
         mpl.rcParams.update(params)
         for (code, color) in zip(color_codes, nice_colors):
             mpl.colors.colorConverter.colors[code] = color
-            mpl.colors.colorConverter.cache[code] = color
         yield
     finally:
         mpl.rcParams.update(orig_params)
         for (code, color) in zip(color_codes, orig_colors):
             mpl.colors.colorConverter.colors[code] = color
-            mpl.colors.colorConverter.cache[code] = color
 
 
 def build_plot_signature(cls):
@@ -143,7 +136,115 @@ def build_plot_signature(cls):
     return cls
 
 
-# ---- The main interface for declarative plotting -------------------- #
+# ---- Plot configuration ---------------------------------------------------------- #
+
+
+class ThemeConfig(mpl.RcParams):
+    """
+    Configuration object for the Plot.theme, using matplotlib rc parameters.
+    """
+    THEME_GROUPS = [
+        "axes", "figure", "font", "grid", "hatch", "legend", "lines",
+        "mathtext", "markers", "patch", "savefig", "scatter",
+        "xaxis", "xtick", "yaxis", "ytick",
+    ]
+
+    def __init__(self):
+        super().__init__()
+        self.reset()
+
+    @property
+    def _default(self) -> dict[str, Any]:
+
+        return {
+            **self._filter_params(mpl.rcParamsDefault),
+            **axes_style("darkgrid"),
+            **plotting_context("notebook"),
+            "axes.prop_cycle": cycler("color", color_palette("deep")),
+        }
+
+    def reset(self) -> None:
+        """Update the theme dictionary with seaborn's default values."""
+        self.update(self._default)
+
+    def update(self, other: dict[str, Any] | None = None, /, **kwds):
+        """Update the theme with a dictionary or keyword arguments of rc parameters."""
+        if other is not None:
+            theme = self._filter_params(other)
+        else:
+            theme = {}
+        theme.update(kwds)
+        super().update(theme)
+
+    def _filter_params(self, params: dict[str, Any]) -> dict[str, Any]:
+        """Restruct to thematic rc params."""
+        return {
+            k: v for k, v in params.items()
+            if any(k.startswith(p) for p in self.THEME_GROUPS)
+        }
+
+    def _html_table(self, params: dict[str, Any]) -> list[str]:
+
+        lines = ["<table>"]
+        for k, v in params.items():
+            row = f"<tr><td>{k}:</td><td style='text-align:left'>{v!r}</td></tr>"
+            lines.append(row)
+        lines.append("</table>")
+        return lines
+
+    def _repr_html_(self) -> str:
+
+        repr = [
+            "<div style='height: 300px'>",
+            "<div style='border-style: inset; border-width: 2px'>",
+            *self._html_table(self),
+            "</div>",
+            "</div>",
+        ]
+        return "\n".join(repr)
+
+
+class DisplayConfig(TypedDict):
+    """Configuration for IPython's rich display hooks."""
+    format: Literal["png", "svg"]
+    scaling: float
+    hidpi: bool
+
+
+class PlotConfig:
+    """Configuration for default behavior / appearance of class:`Plot` instances."""
+    def __init__(self):
+
+        self._theme = ThemeConfig()
+        self._display = {"format": "png", "scaling": .85, "hidpi": True}
+
+    @property
+    def theme(self) -> dict[str, Any]:
+        """
+        Dictionary of base theme parameters for :class:`Plot`.
+
+        Keys and values correspond to matplotlib rc params, as documented here:
+        https://matplotlib.org/stable/tutorials/introductory/customizing.html
+
+        """
+        return self._theme
+
+    @property
+    def display(self) -> DisplayConfig:
+        """
+        Dictionary of parameters for rich display in Jupyter notebook.
+
+        Valid parameters:
+
+        - format ("png" or "svg"): Image format to produce
+        - scaling (float): Relative scaling of embedded image
+        - hidpi (bool): When True, double the DPI while preserving the size
+
+        """
+        return self._display
+
+
+# ---- The main interface for declarative plotting --------------------------------- #
 
 
 @build_plot_signature
@@ -181,6 +282,8 @@ class Plot:
     the plot without rendering it to access the lower-level representation.
 
     """
+    config = PlotConfig()
+
     _data: PlotData
     _layers: list[Layer]
 
@@ -274,11 +377,17 @@ class Plot:
         other_type = other.__class__.__name__
         raise TypeError(f"Unsupported operand type(s) for +: 'Plot' and '{other_type}")
 
-    def _repr_png_(self) -> tuple[bytes, dict[str, float]]:
+    def _repr_png_(self) -> tuple[bytes, dict[str, float]] | None:
 
+        if Plot.config.display["format"] != "png":
+            return None
         return self.plot()._repr_png_()
 
-    # TODO _repr_svg_?
+    def _repr_svg_(self) -> str | None:
+
+        if Plot.config.display["format"] != "svg":
+            return None
+        return self.plot()._repr_svg_()
 
     def _clone(self) -> Plot:
         """Generate a new object with the same information as the current spec."""
@@ -308,21 +417,7 @@ class Plot:
 
     def _theme_with_defaults(self) -> dict[str, Any]:
 
-        style_groups = [
-            "axes", "figure", "font", "grid", "hatch", "legend", "lines",
-            "mathtext", "markers", "patch", "savefig", "scatter",
-            "xaxis", "xtick", "yaxis", "ytick",
-        ]
-        base = {
-            k: mpl.rcParamsDefault[k] for k in mpl.rcParams
-            if any(k.startswith(p) for p in style_groups)
-        }
-        theme = {
-            **base,
-            **axes_style("darkgrid"),
-            **plotting_context("notebook"),
-            "axes.prop_cycle": cycler("color", color_palette("deep")),
-        }
+        theme = self.config.theme.copy()
         theme.update(self._theme)
         return theme
 
@@ -744,7 +839,7 @@ class Plot:
 
     def theme(self, *args: dict[str, Any]) -> Plot:
         """
-        Control the default appearance of elements in the plot.
+        Control the appearance of elements in the plot.
 
         .. note::
 
@@ -770,7 +865,7 @@ class Plot:
             err = f"theme() takes 1 positional argument, but {nargs} were given"
             raise TypeError(err)
 
-        rc = args[0]
+        rc = mpl.RcParams(args[0])
         new._theme.update(rc)
 
         return new
@@ -907,13 +1002,7 @@ class Plotter:
     # TODO API for accessing the underlying matplotlib objects
     # TODO what else is useful in the public API for this class?
 
-    def _repr_png_(self) -> tuple[bytes, dict[str, float]]:
-
-        # TODO better to do this through a Jupyter hook? e.g.
-        # ipy = IPython.core.formatters.get_ipython()
-        # fmt = ipy.display_formatter.formatters["text/html"]
-        # fmt.for_type(Plot, ...)
-        # Would like to have a svg option too, not sure how to make that flexible
+    def _repr_png_(self) -> tuple[bytes, dict[str, float]] | None:
 
         # TODO use matplotlib backend directly instead of going through savefig?
 
@@ -925,27 +1014,44 @@ class Plotter:
         # Better solution would be to default (with option to change)
         # to using constrained/tight layout.
 
-        # TODO need to decide what the right default behavior here is:
-        # - Use dpi=72 to match default InlineBackend figure size?
-        # - Accept a generic "scaling" somewhere and scale DPI from that,
-        #   either with 1x -> 72 or 1x -> 96 and the default scaling be .75?
-        # - Listen to rcParams? InlineBackend behavior makes that so complicated :(
-        # - Do we ever want to *not* use retina mode at this point?
+        if Plot.config.display["format"] != "png":
+            return None
 
-        from PIL import Image
-
-        dpi = 96
         buffer = io.BytesIO()
 
-        with theme_context(self._theme):
-            self._figure.savefig(buffer, dpi=dpi * 2, format="png", bbox_inches="tight")
+        factor = 2 if Plot.config.display["hidpi"] else 1
+        scaling = Plot.config.display["scaling"] / factor
+        dpi = 96 * factor  # TODO put dpi in Plot.config?
+
+        with theme_context(self._theme):  # TODO _theme_with_defaults?
+            self._figure.savefig(buffer, dpi=dpi, format="png", bbox_inches="tight")
         data = buffer.getvalue()
 
-        scaling = .85 / 2
         w, h = Image.open(buffer).size
         metadata = {"width": w * scaling, "height": h * scaling}
         return data, metadata
 
+    def _repr_svg_(self) -> str | None:
+
+        if Plot.config.display["format"] != "svg":
+            return None
+
+        # TODO DPI for rasterized artists?
+
+        scaling = Plot.config.display["scaling"]
+
+        buffer = io.StringIO()
+        with theme_context(self._theme):  # TODO _theme_with_defaults?
+            self._figure.savefig(buffer, format="svg", bbox_inches="tight")
+
+        root = ElementTree.fromstring(buffer.getvalue())
+        w = scaling * float(root.attrib["width"][:-2])
+        h = scaling * float(root.attrib["height"][:-2])
+        root.attrib.update(width=f"{w}pt", height=f"{h}pt", viewbox=f"0 0 {w} {h}")
+        ElementTree.ElementTree(root).write(out := io.BytesIO())
+
+        return out.getvalue().decode()
+
     def _extract_data(self, p: Plot) -> tuple[PlotData, list[Layer]]:
 
         common_data = (
@@ -1662,4 +1768,5 @@ class Plotter:
             # Don't modify the layout engine if the user supplied their own
             # matplotlib figure and didn't specify an engine through Plot
             # TODO switch default to "constrained"?
+            # TODO either way, make configurable
             set_layout_engine(self._figure, "tight")
diff --git a/seaborn/_core/rules.py b/seaborn/_core/rules.py
index 0de7bcd7..7ba5247d 100644
--- a/seaborn/_core/rules.py
+++ b/seaborn/_core/rules.py
@@ -67,7 +67,7 @@ def variable_type(
     """
 
     # If a categorical dtype is set, infer categorical
-    if pd.api.types.is_categorical_dtype(vector):
+    if isinstance(getattr(vector, 'dtype', None), pd.CategoricalDtype):
         return VarType("categorical")
 
     # Special-case all-na data, which is always "numeric"
@@ -94,7 +94,7 @@ def variable_type(
                 boolean_dtypes = ["bool"]
             boolean_vector = vector.dtype in boolean_dtypes
         else:
-            boolean_vector = bool(np.isin(vector, [0, 1, np.nan]).all())
+            boolean_vector = bool(np.isin(vector.dropna(), [0, 1]).all())
         if boolean_vector:
             return VarType(boolean_type)
 
diff --git a/seaborn/_core/typing.py b/seaborn/_core/typing.py
index 5295b995..cc3522d4 100644
--- a/seaborn/_core/typing.py
+++ b/seaborn/_core/typing.py
@@ -36,4 +36,10 @@ class Default:
         return "<default>"
 
 
+class Deprecated:
+    def __repr__(self):
+        return "<deprecated>"
+
+
 default = Default()
+deprecated = Deprecated()
diff --git a/seaborn/_marks/bar.py b/seaborn/_marks/bar.py
index 0dcf361d..4b1c0729 100644
--- a/seaborn/_marks/bar.py
+++ b/seaborn/_marks/bar.py
@@ -183,7 +183,7 @@ class Bar(BarBase):
 @dataclass
 class Bars(BarBase):
     """
-    A faster bar mark with defaults more suitable histograms.
+    A faster bar mark with defaults more suitable for histograms.
 
     See also
     --------
diff --git a/seaborn/_oldcore.py b/seaborn/_oldcore.py
index e0a38b8a..f1dab46f 100644
--- a/seaborn/_oldcore.py
+++ b/seaborn/_oldcore.py
@@ -20,6 +20,7 @@ from .palettes import (
 )
 from .utils import (
     _check_argument,
+    desaturate,
     get_color_cycle,
     remove_na,
 )
@@ -103,7 +104,7 @@ class HueMapping(SemanticMapping):
     cmap = None
 
     def __init__(
-        self, plotter, palette=None, order=None, norm=None,
+        self, plotter, palette=None, order=None, norm=None, saturation=1,
     ):
         """Map the levels of the `hue` variable to distinct colors.
 
@@ -159,6 +160,7 @@ class HueMapping(SemanticMapping):
                     list(data), palette, order,
                 )
 
+            self.saturation = saturation
             self.map_type = map_type
             self.lookup_table = lookup_table
             self.palette = palette
@@ -192,6 +194,10 @@ class HueMapping(SemanticMapping):
                 if np.ma.is_masked(normed):
                     normed = np.nan
                 value = self.cmap(normed)
+
+        if self.saturation < 1:
+            value = desaturate(value, self.saturation)
+
         return value
 
     def infer_map_type(self, palette, norm, input_format, var_type):
@@ -1413,15 +1419,15 @@ class VectorPlotter:
         if self.var_types[axis] == "numeric":
             self.plot_data = self.plot_data.sort_values(axis, kind="mergesort")
 
-        # Now get a reference to the categorical data vector
-        cat_data = self.plot_data[axis]
+        # Now get a reference to the categorical data vector and remove na values
+        cat_data = self.plot_data[axis].dropna()
 
         # Get the initial categorical order, which we do before string
         # conversion to respect the original types of the order list.
         # Track whether the order is given explicitly so that we can know
         # whether or not to use the order constructed here downstream
         self._var_ordered[axis] = order is not None or cat_data.dtype.name == "category"
-        order = pd.Index(categorical_order(cat_data, order))
+        order = pd.Index(categorical_order(cat_data, order), name=axis)
 
         # Then convert data to strings. This is because in matplotlib,
         # "categorical" data really mean "string" data, so doing this artists
@@ -1489,9 +1495,10 @@ def variable_type(vector, boolean_type="numeric"):
     var_type : 'numeric', 'categorical', or 'datetime'
         Name identifying the type of data in the vector.
     """
+    vector = pd.Series(vector)
 
     # If a categorical dtype is set, infer categorical
-    if pd.api.types.is_categorical_dtype(vector):
+    if isinstance(vector.dtype, pd.CategoricalDtype):
         return VariableType("categorical")
 
     # Special-case all-na data, which is always "numeric"
@@ -1510,7 +1517,7 @@ def variable_type(vector, boolean_type="numeric"):
         warnings.simplefilter(
             action='ignore', category=(FutureWarning, DeprecationWarning)
         )
-        if np.isin(vector, [0, 1, np.nan]).all():
+        if np.isin(vector.dropna(), [0, 1]).all():
             return VariableType(boolean_type)
 
     # Defer to positive pandas tests
@@ -1562,17 +1569,18 @@ def infer_orient(x=None, y=None, orient=None, require_numeric=True):
     x, y : Vector data or None
         Positional data vectors for the plot.
     orient : string or None
-        Specified orientation, which must start with "v" or "h" if not None.
+        Specified orientation. If not None, can be "x" or "y", or otherwise
+        must start with "v" or "h".
     require_numeric : bool
         If set, raise when the implied dependent variable is not numeric.
 
     Returns
     -------
-    orient : "v" or "h"
+    orient : "x" or "y"
 
     Raises
     ------
-    ValueError: When `orient` is not None and does not start with "h" or "v"
+    ValueError: When `orient` is an unknown string.
     TypeError: When dependent variable is not numeric, with `require_numeric`
 
     """
@@ -1588,24 +1596,24 @@ def infer_orient(x=None, y=None, orient=None, require_numeric=True):
             warnings.warn(single_var_warning.format("Horizontal", "y"))
         if require_numeric and y_type != "numeric":
             raise TypeError(nonnumeric_dv_error.format("Vertical", "y"))
-        return "v"
+        return "x"
 
     elif y is None:
         if str(orient).startswith("v"):
             warnings.warn(single_var_warning.format("Vertical", "x"))
         if require_numeric and x_type != "numeric":
             raise TypeError(nonnumeric_dv_error.format("Horizontal", "x"))
-        return "h"
+        return "y"
 
-    elif str(orient).startswith("v"):
+    elif str(orient).startswith("v") or orient == "x":
         if require_numeric and y_type != "numeric":
             raise TypeError(nonnumeric_dv_error.format("Vertical", "y"))
-        return "v"
+        return "x"
 
-    elif str(orient).startswith("h"):
+    elif str(orient).startswith("h") or orient == "y":
         if require_numeric and x_type != "numeric":
             raise TypeError(nonnumeric_dv_error.format("Horizontal", "x"))
-        return "h"
+        return "y"
 
     elif orient is not None:
         err = (
@@ -1615,20 +1623,20 @@ def infer_orient(x=None, y=None, orient=None, require_numeric=True):
         raise ValueError(err)
 
     elif x_type != "categorical" and y_type == "categorical":
-        return "h"
+        return "y"
 
     elif x_type != "numeric" and y_type == "numeric":
-        return "v"
+        return "x"
 
     elif x_type == "numeric" and y_type != "numeric":
-        return "h"
+        return "y"
 
     elif require_numeric and "numeric" not in (x_type, y_type):
         err = "Neither the `x` nor `y` variable appears to be numeric."
         raise TypeError(err)
 
     else:
-        return "v"
+        return "x"
 
 
 def unique_dashes(n):
diff --git a/seaborn/_statistics.py b/seaborn/_statistics.py
index 7fe4fbe7..ea9c15d2 100644
--- a/seaborn/_statistics.py
+++ b/seaborn/_statistics.py
@@ -405,13 +405,13 @@ class ECDF:
 
         Parameters
         ----------
-        stat : {{"proportion", "count"}}
+        stat : {{"proportion", "percent", "count"}}
             Distribution statistic to compute.
         complementary : bool
             If True, use the complementary CDF (1 - CDF)
 
         """
-        _check_argument("stat", ["count", "proportion"], stat)
+        _check_argument("stat", ["count", "percent", "proportion"], stat)
         self.stat = stat
         self.complementary = complementary
 
@@ -426,8 +426,10 @@ class ECDF:
         weights = weights[sorter]
         y = weights.cumsum()
 
-        if self.stat == "proportion":
+        if self.stat in ["percent", "proportion"]:
             y = y / y.max()
+        if self.stat == "percent":
+            y = y * 100
 
         x = np.r_[-np.inf, x]
         y = np.r_[0, y]
diff --git a/seaborn/axisgrid.py b/seaborn/axisgrid.py
index a5783699..75349099 100644
--- a/seaborn/axisgrid.py
+++ b/seaborn/axisgrid.py
@@ -10,7 +10,7 @@ import matplotlib as mpl
 import matplotlib.pyplot as plt
 
 from ._oldcore import VectorPlotter, variable_type, categorical_order
-from ._compat import share_axis
+from ._compat import share_axis, get_legend_handles
 from . import utils
 from .utils import (
     adjust_legend_subtitles, _check_argument, _draw_figure, _disable_autolayout
@@ -229,7 +229,7 @@ class Grid(_BaseGrid):
         # Get data directly from the legend, which is necessary
         # for newer functions that don't add labeled proxy artists
         if ax.legend_ is not None and self._extract_legend_handles:
-            handles = ax.legend_.legendHandles
+            handles = get_legend_handles(ax.legend_)
             labels = [t.get_text() for t in ax.legend_.texts]
             data.update({l: h for h, l in zip(handles, labels)})
 
@@ -2352,7 +2352,6 @@ Parameters
 {params.core.data}
 {params.core.xy}
 {params.core.hue}
-    Semantic variable that is mapped to determine the color of plot elements.
 kind : {{ "scatter" | "kde" | "hist" | "hex" | "reg" | "resid" }}
     Kind of plot to draw. See the examples for references to the underlying functions.
 height : numeric
diff --git a/seaborn/categorical.py b/seaborn/categorical.py
index e22d301b..9aa4a90b 100644
--- a/seaborn/categorical.py
+++ b/seaborn/categorical.py
@@ -1,3 +1,4 @@
+from collections import namedtuple
 from textwrap import dedent
 from numbers import Number
 import warnings
@@ -6,26 +7,31 @@ from functools import partial
 
 import numpy as np
 import pandas as pd
-try:
-    from scipy.stats import gaussian_kde
-    _no_scipy = False
-except ImportError:
-    from .external.kde import gaussian_kde
-    _no_scipy = True
 
 import matplotlib as mpl
 from matplotlib.collections import PatchCollection
 import matplotlib.patches as Patches
 import matplotlib.pyplot as plt
 
+from seaborn._core.typing import default, deprecated
 from seaborn._oldcore import (
     variable_type,
     infer_orient,
     categorical_order,
 )
+from seaborn._stats.density import KDE
 from seaborn.relational import _RelationalPlotter
 from seaborn import utils
-from seaborn.utils import remove_na, _normal_quantile_func, _draw_figure, _default_color
+from seaborn.utils import (
+    remove_na,
+    desaturate,
+    _check_argument,
+    _draw_figure,
+    _default_color,
+    _normal_quantile_func,
+    _normalize_kwargs,
+    _version_predates,
+)
 from seaborn._statistics import EstimateAggregator
 from seaborn.palettes import color_palette, husl_palette, light_palette, dark_palette
 from seaborn.axisgrid import FacetGrid, _facet_docs
@@ -45,9 +51,7 @@ class _CategoricalPlotterNew(_RelationalPlotter):
 
     semantics = "x", "y", "hue", "units"
 
-    wide_structure = {"x": "@columns", "y": "@values", "hue": "@columns"}
-
-    # flat_structure = {"x": "@values", "y": "@values"}
+    wide_structure = {"x": "@columns", "y": "@values"}
     flat_structure = {"y": "@values"}
 
     _legend_func = "scatter"
@@ -74,7 +78,7 @@ class _CategoricalPlotterNew(_RelationalPlotter):
         # For wide data, orient determines assignment to x/y differently from the
         # wide_structure rules in _core. If we do decide to make orient part of the
         # _core variable assignment, we'll want to figure out how to express that.
-        if self.input_format == "wide" and orient == "h":
+        if self.input_format == "wide" and orient in ["h", "y"]:
             self.plot_data = self.plot_data.rename(columns={"x": "y", "y": "x"})
             orig_variables = set(self.variables)
             orig_x = self.variables.pop("x", None)
@@ -109,14 +113,14 @@ class _CategoricalPlotterNew(_RelationalPlotter):
         # Categorical plots can be "univariate" in which case they get an anonymous
         # category label on the opposite axis. Note: this duplicates code in the core
         # scale_categorical function. We need to do it here because of the next line.
-        if self.cat_axis not in self.variables:
-            self.variables[self.cat_axis] = None
-            self.var_types[self.cat_axis] = "categorical"
-            self.plot_data[self.cat_axis] = ""
+        if self.orient not in self.variables:
+            self.variables[self.orient] = None
+            self.var_types[self.orient] = "categorical"
+            self.plot_data[self.orient] = ""
 
         # Categorical variables have discrete levels that we need to track
-        cat_levels = categorical_order(self.plot_data[self.cat_axis], order)
-        self.var_levels[self.cat_axis] = cat_levels
+        cat_levels = categorical_order(self.plot_data[self.orient], order)
+        self.var_levels[self.orient] = cat_levels
 
     def _hue_backcompat(self, color, palette, hue_order, force_hue=False):
         """Implement backwards compatibility for hue parametrization.
@@ -139,10 +143,10 @@ class _CategoricalPlotterNew(_RelationalPlotter):
         default_behavior = color is None or palette is not None
         if force_hue and "hue" not in self.variables and default_behavior:
             self._redundant_hue = True
-            self.plot_data["hue"] = self.plot_data[self.cat_axis]
-            self.variables["hue"] = self.variables[self.cat_axis]
+            self.plot_data["hue"] = self.plot_data[self.orient]
+            self.variables["hue"] = self.variables[self.orient]
             self.var_types["hue"] = "categorical"
-            hue_order = self.var_levels[self.cat_axis]
+            hue_order = self.var_levels[self.orient]
 
             # Because we convert the categorical axis variable to string,
             # we need to update a dictionary palette too
@@ -150,7 +154,11 @@ class _CategoricalPlotterNew(_RelationalPlotter):
                 palette = {str(k): v for k, v in palette.items()}
 
         else:
-            self._redundant_hue = False
+            if "hue" in self.variables:
+                redundant = (self.plot_data["hue"] == self.plot_data[self.orient]).all()
+            else:
+                redundant = False
+            self._redundant_hue = redundant
 
         # Previously, categorical plots had a trick where color= could seed the palette.
         # Because that's an explicit parameterization, we are going to give it one
@@ -160,38 +168,135 @@ class _CategoricalPlotterNew(_RelationalPlotter):
                 color = mpl.colors.to_hex(color)
             palette = f"dark:{color}"
             msg = (
-                "Setting a gradient palette using color= is deprecated and will be "
-                f"removed in version 0.13. Set `palette='{palette}'` for same effect."
+                "\n\nSetting a gradient palette using color= is deprecated and will be "
+                f"removed in v0.14.0. Set `palette='{palette}'` for the same effect.\n"
             )
-            warnings.warn(msg, FutureWarning)
+            warnings.warn(msg, FutureWarning, stacklevel=3)
 
         return palette, hue_order
 
     def _palette_without_hue_backcompat(self, palette, hue_order):
         """Provide one cycle where palette= implies hue= when not provided"""
         if "hue" not in self.variables and palette is not None:
-            msg = "Passing `palette` without assigning `hue` is deprecated."
+            msg = (
+                "\n\nPassing `palette` without assigning `hue` is deprecated "
+                f"and will be removed in v0.14.0. Assign the `{self.orient}` variable "
+                "to `hue` and set `legend=False` for the same effect.\n"
+            )
             warnings.warn(msg, FutureWarning, stacklevel=3)
+
             self.legend = False
-            self.plot_data["hue"] = self.plot_data[self.cat_axis]
-            self.variables["hue"] = self.variables.get(self.cat_axis)
-            self.var_types["hue"] = self.var_types.get(self.cat_axis)
-            hue_order = self.var_levels.get(self.cat_axis)
+            self.plot_data["hue"] = self.plot_data[self.orient]
+            self.variables["hue"] = self.variables.get(self.orient)
+            self.var_types["hue"] = self.var_types.get(self.orient)
+
+            hue_order = self.var_levels.get(self.orient)
+            self._var_levels.pop("hue", None)
+
         return hue_order
 
-    @property
-    def cat_axis(self):
-        return {"v": "x", "h": "y"}[self.orient]
+    def _point_kwargs_backcompat(self, scale, join, kwargs):
+        """Provide two cycles where scale= and join= work, but redirect to kwargs."""
+        if scale is not deprecated:
+            lw = mpl.rcParams["lines.linewidth"] * 1.8 * scale
+            mew = lw * .75
+            ms = lw * 2
+
+            msg = (
+                "\n\n"
+                "The `scale` parameter is deprecated and will be removed in v0.15.0. "
+                "You can now control the size of each plot element using matplotlib "
+                "`Line2D` parameters (e.g., `linewidth`, `markersize`, etc.)."
+                "\n"
+            )
+            warnings.warn(msg, stacklevel=3)
+            kwargs.update(linewidth=lw, markeredgewidth=mew, markersize=ms)
+
+        if join is not deprecated:
+            msg = (
+                "\n\n"
+                "The `join` parameter is deprecated and will be removed in v0.15.0."
+            )
+            if not join:
+                msg += (
+                    " You can remove the line between points with `linestyle='none'`."
+                )
+                kwargs.update(linestyle="")
+            msg += "\n"
+            warnings.warn(msg, stacklevel=3)
+
+    def _err_kws_backcompat(self, err_kws, errcolor, errwidth, capsize):
+        """Provide two cycles where existing signature-level err_kws are handled."""
+        def deprecate_err_param(name, key, val):
+            if val is deprecated:
+                return
+            suggest = f"err_kws={{'{key}': {val!r}}}"
+            msg = (
+                f"\n\nThe `{name}` parameter is deprecated. And will be removed "
+                f"in v0.15.0. Pass `{suggest}` instead.\n"
+            )
+            warnings.warn(msg, FutureWarning, stacklevel=4)
+            err_kws[key] = val
+
+        if errcolor is not None:
+            deprecate_err_param("errcolor", "color", errcolor)
+        deprecate_err_param("errwidth", "linewidth", errwidth)
+
+        if capsize is None:
+            capsize = 0
+            msg = (
+                "\n\nPassing `capsize=None` is deprecated and will be removed "
+                "in v0.15.0. Pass `capsize=0` to disable caps.\n"
+            )
+            warnings.warn(msg, FutureWarning, stacklevel=3)
+
+        return err_kws, capsize
+
+    def _scale_backcompat(self, scale, scale_hue, density_norm, common_norm):
+        """Provide two cycles of backcompat for scale kwargs"""
+        if scale is not deprecated:
+            density_norm = scale
+            msg = (
+                "\n\nThe `scale` parameter has been renamed and will be removed "
+                f"in v0.15.0. Pass `density_norm={scale!r}` for the same effect."
+            )
+            warnings.warn(msg, FutureWarning, stacklevel=3)
+
+        if scale_hue is not deprecated:
+            common_norm = scale_hue
+            msg = (
+                "\n\nThe `scale_hue` parameter has been replaced and will be removed "
+                f"in v0.15.0. Pass `common_norm={not scale_hue}` for the same effect."
+            )
+            warnings.warn(msg, FutureWarning, stacklevel=3)
+
+        return density_norm, common_norm
 
     def _get_gray(self, colors):
         """Get a grayscale value that looks good with color."""
         if not len(colors):
             return None
+        colors = [mpl.colors.to_rgb(c) for c in colors]
         unique_colors = np.unique(colors, axis=0)
         light_vals = [rgb_to_hls(*rgb[:3])[1] for rgb in unique_colors]
         lum = min(light_vals) * .6
         return (lum, lum, lum)
 
+    def _map_prop_with_hue(self, name, value, fallback, plot_kws):
+        """Support pointplot behavior of modifying the marker/linestyle with hue."""
+        if value is default:
+            value = plot_kws.pop(name, fallback)
+
+        if (levels := self._hue_map.levels) is None:
+            mapping = {None: value}
+        else:
+            if isinstance(value, list):
+                mapping = {k: v for k, v in zip(levels, value)}
+            else:
+                mapping = {k: value for k in levels}
+
+        return mapping
+
     def _adjust_cat_axis(self, ax, axis):
         """Set ticks and limits for a categorical variable."""
         # Note: in theory, this could happen in _attach for all categorical axes
@@ -223,10 +328,59 @@ class _CategoricalPlotterNew(_RelationalPlotter):
             # Note limits that correspond to previously-inverted y axis
             ax.set_ylim(n - .5, -.5, auto=None)
 
+    def _dodge_needed(self):
+        """Return True when use of `hue` would cause overlaps."""
+        groupers = list({self.orient, "col", "row"} & set(self.variables))
+        if "hue" in self.variables:
+            orient = self.plot_data[groupers].value_counts()
+            paired = self.plot_data[[*groupers, "hue"]].value_counts()
+            return orient.size != paired.size
+        return False
+
+    def _dodge(self, keys, data):
+        """Apply a dodge transform to coordinates in place."""
+        hue_idx = self._hue_map.levels.index(keys["hue"])
+        n = len(self._hue_map.levels)
+        data["width"] /= n
+
+        full_width = data["width"] * n
+        offset = data["width"] * hue_idx + data["width"] / 2 - full_width / 2
+        data[self.orient] += offset
+
+    def _invert_scale(self, ax, data, vars=("x", "y")):
+        """Undo scaling after computation so data are plotted correctly."""
+        for var in vars:
+            _, inv = utils._get_transform_functions(ax, var[0])
+            if var == self.orient and "width" in data:
+                hw = data["width"] / 2
+                data["edge"] = inv(data[var] - hw)
+                data["width"] = inv(data[var] + hw) - data["edge"].to_numpy()
+            for suf in ["", "min", "max"]:
+                if (col := f"{var}{suf}") in data:
+                    data[col] = inv(data[col])
+
+    def _configure_legend(self, ax, func, common_kws=None, semantic_kws=None):
+
+        if self.legend == "auto":
+            show_legend = not self._redundant_hue and self.input_format != "wide"
+        else:
+            show_legend = bool(self.legend)
+
+        if show_legend:
+            self.add_legend_data(ax, func, common_kws, semantic_kws)
+            handles, _ = ax.get_legend_handles_labels()
+            if handles:
+                ax.legend(title=self.legend_title)
+
     @property
     def _native_width(self):
         """Return unit of width separating categories on native numeric scale."""
-        unique_values = np.unique(self.comp_data[self.cat_axis])
+        # Categorical data always have a unit width
+        if self.var_types[self.orient] == "categorical":
+            return 1
+
+        # Otherwise, define the width as the smallest space between observations
+        unique_values = np.unique(self.comp_data[self.orient])
         if len(unique_values) > 1:
             native_width = np.nanmin(np.diff(unique_values))
         else:
@@ -273,7 +427,7 @@ class _CategoricalPlotterNew(_RelationalPlotter):
         jlim *= self._native_width
         jitterer = partial(np.random.uniform, low=-jlim, high=+jlim)
 
-        iter_vars = [self.cat_axis]
+        iter_vars = [self.orient]
         if dodge:
             iter_vars.append("hue")
 
@@ -283,19 +437,18 @@ class _CategoricalPlotterNew(_RelationalPlotter):
         for sub_vars, sub_data in self.iter_data(iter_vars,
                                                  from_comp_data=True,
                                                  allow_empty=True):
+
+            ax = self._get_axes(sub_vars)
+
             if offsets is not None and (offsets != 0).any():
                 dodge_move = offsets[sub_data["hue"].map(self._hue_map.levels.index)]
 
             jitter_move = jitterer(size=len(sub_data)) if len(sub_data) > 1 else 0
 
-            adjusted_data = sub_data[self.cat_axis] + dodge_move + jitter_move
-            sub_data[self.cat_axis] = adjusted_data
+            adjusted_data = sub_data[self.orient] + dodge_move + jitter_move
+            sub_data[self.orient] = adjusted_data
+            self._invert_scale(ax, sub_data)
 
-            for var in "xy":
-                if self._log_scaled(var):
-                    sub_data[var] = np.power(10, sub_data[var])
-
-            ax = self._get_axes(sub_vars)
             points = ax.scatter(sub_data["x"], sub_data["y"], color=color, **plot_kws)
 
             if "hue" in self.variables:
@@ -306,17 +459,7 @@ class _CategoricalPlotterNew(_RelationalPlotter):
             else:
                 points.set_edgecolors(edgecolor)
 
-        # Finalize the axes details
-        if self.legend == "auto":
-            show_legend = not self._redundant_hue and self.input_format != "wide"
-        else:
-            show_legend = bool(self.legend)
-
-        if show_legend:
-            self.add_legend_data(ax)
-            handles, _ = ax.get_legend_handles_labels()
-            if handles:
-                ax.legend(title=self.legend_title)
+        self._configure_legend(ax, ax.scatter)
 
     def plot_swarms(
         self,
@@ -330,7 +473,7 @@ class _CategoricalPlotterNew(_RelationalPlotter):
         width = .8 * self._native_width
         offsets = self._nested_offsets(width, dodge)
 
-        iter_vars = [self.cat_axis]
+        iter_vars = [self.orient]
         if dodge:
             iter_vars.append("hue")
 
@@ -342,17 +485,15 @@ class _CategoricalPlotterNew(_RelationalPlotter):
                                                  from_comp_data=True,
                                                  allow_empty=True):
 
+            ax = self._get_axes(sub_vars)
+
             if offsets is not None:
                 dodge_move = offsets[sub_data["hue"].map(self._hue_map.levels.index)]
 
             if not sub_data.empty:
-                sub_data[self.cat_axis] = sub_data[self.cat_axis] + dodge_move
-
-            for var in "xy":
-                if self._log_scaled(var):
-                    sub_data[var] = np.power(10, sub_data[var])
+                sub_data[self.orient] = sub_data[self.orient] + dodge_move
 
-            ax = self._get_axes(sub_vars)
+            self._invert_scale(ax, sub_data)
             points = ax.scatter(sub_data["x"], sub_data["y"], color=color, **plot_kws)
 
             if "hue" in self.variables:
@@ -364,7 +505,7 @@ class _CategoricalPlotterNew(_RelationalPlotter):
                 points.set_edgecolors(edgecolor)
 
             if not sub_data.empty:
-                point_collections[(ax, sub_data[self.cat_axis].iloc[0])] = points
+                point_collections[(ax, sub_data[self.orient].iloc[0])] = points
 
         beeswarm = Beeswarm(
             width=width, orient=self.orient, warn_thresh=warn_thresh,
@@ -376,7 +517,7 @@ class _CategoricalPlotterNew(_RelationalPlotter):
 
                     beeswarm(points, center)
 
-                    if self.orient == "h":
+                    if self.orient == "y":
                         scalex = False
                         scaley = ax.get_autoscaley_on()
                     else:
@@ -387,7 +528,7 @@ class _CategoricalPlotterNew(_RelationalPlotter):
                     # set in _adjust_cat_axis, because that method currently leave
                     # the autoscale flag in its original setting. It may be better
                     # to disable autoscaling there to avoid needing to do this.
-                    fixed_scale = self.var_types[self.cat_axis] == "categorical"
+                    fixed_scale = self.var_types[self.orient] == "categorical"
                     ax.update_datalim(points.get_datalim(ax.transData))
                     if not fixed_scale and (scalex or scaley):
                         ax.autoscale_view(scalex=scalex, scaley=scaley)
@@ -397,25 +538,649 @@ class _CategoricalPlotterNew(_RelationalPlotter):
                 points.draw = draw.__get__(points)
 
         _draw_figure(ax.figure)
+        self._configure_legend(ax, ax.scatter)
 
-        # Finalize the axes details
-        if self.legend == "auto":
-            show_legend = not self._redundant_hue and self.input_format != "wide"
+    def plot_boxes(
+        self,
+        width,
+        dodge,
+        gap,
+        fill,
+        whis,
+        color,
+        linecolor,
+        linewidth,
+        fliersize,
+        plot_kws,  # TODO rename user_kws?
+    ):
+
+        iter_vars = ["hue"]
+        value_var = {"x": "y", "y": "x"}[self.orient]
+
+        if linecolor is None:
+            if "hue" in self.variables:
+                linecolor = self._get_gray(list(self._hue_map.lookup_table.values()))
+            else:
+                linecolor = self._get_gray([color])
+
+        def get_props(element, artist=mpl.lines.Line2D):
+            return _normalize_kwargs(plot_kws.pop(f"{element}props", {}), artist)
+
+        if not fill and linewidth is None:
+            linewidth = mpl.rcParams["lines.linewidth"]
+
+        plot_kws.setdefault("shownotches", plot_kws.pop("notch", False))
+
+        box_artist = mpl.patches.Rectangle if fill else mpl.lines.Line2D
+        props = {
+            "box": get_props("box", box_artist),
+            "median": get_props("median"),
+            "whisker": get_props("whisker"),
+            "flier": get_props("flier"),
+            "cap": get_props("cap"),
+        }
+
+        props["median"].setdefault("solid_capstyle", "butt")
+        props["whisker"].setdefault("solid_capstyle", "butt")
+        props["flier"].setdefault("markersize", fliersize)
+
+        ax = self.ax
+
+        for sub_vars, sub_data in self.iter_data(iter_vars,
+                                                 from_comp_data=True,
+                                                 allow_empty=False):
+
+            ax = self._get_axes(sub_vars)
+
+            grouped = sub_data.groupby(self.orient)[value_var]
+            value_data = [x.to_numpy() for _, x in grouped]
+            stats = pd.DataFrame(mpl.cbook.boxplot_stats(value_data, whis=whis))
+            positions = grouped.grouper.result_index.to_numpy(dtype=float)
+
+            orig_width = width * self._native_width
+            data = pd.DataFrame({self.orient: positions, "width": orig_width})
+            if dodge:
+                self._dodge(sub_vars, data)
+            if gap:
+                data["width"] *= 1 - gap
+            capwidth = plot_kws.get("capwidths", 0.5 * data["width"])
+
+            self._invert_scale(ax, data)
+
+            maincolor = self._hue_map(sub_vars["hue"]) if "hue" in sub_vars else color
+
+            # TODO how to handle solid / empty fliers?
+
+            if fill:
+                boxprops = {
+                    "facecolor": maincolor, "edgecolor": linecolor, **props["box"]
+                }
+                medianprops = {"color": linecolor, **props["median"]}
+                whiskerprops = {"color": linecolor, **props["whisker"]}
+                flierprops = {"markeredgecolor": linecolor, **props["flier"]}
+                capprops = {"color": linecolor, **props["cap"]}
+            else:
+                boxprops = {"color": maincolor, **props["box"]}
+                medianprops = {"color": maincolor, **props["median"]}
+                whiskerprops = {"color": maincolor, **props["whisker"]}
+                flierprops = {"markeredgecolor": maincolor, **props["flier"]}
+                capprops = {"color": maincolor, **props["cap"]}
+
+            if linewidth is not None:
+                for prop_dict in [boxprops, medianprops, whiskerprops, capprops]:
+                    prop_dict.setdefault("linewidth", linewidth)
+
+            default_kws = dict(
+                bxpstats=stats.to_dict("records"),
+                positions=data[self.orient],
+                # Set width to 0 with log scaled orient axis to avoid going < 0
+                widths=0 if self._log_scaled(self.orient) else data["width"],
+                patch_artist=fill,
+                vert=self.orient == "x",
+                manage_ticks=False,
+                boxprops=boxprops,
+                medianprops=medianprops,
+                whiskerprops=whiskerprops,
+                flierprops=flierprops,
+                capprops=capprops,
+                # Added in matplotlib 3.6.0; see below
+                # capwidths=capwidth,
+                **(
+                    {} if _version_predates(mpl, "3.6.0")
+                    else {"capwidths": capwidth}
+                )
+            )
+            boxplot_kws = {**default_kws, **plot_kws}
+            artists = ax.bxp(**boxplot_kws)
+
+            # Reset artist widths after adding so everything stays positive
+            ori_idx = ["x", "y"].index(self.orient)
+            if self._log_scaled(self.orient):
+                for i, box in enumerate(data.to_dict("records")):
+                    p0 = box["edge"]
+                    p1 = box["edge"] + box["width"]
+
+                    if artists["boxes"]:
+                        box_artist = artists["boxes"][i]
+                        if fill:
+                            box_verts = box_artist.get_path().vertices.T
+                        else:
+                            box_verts = box_artist.get_data()
+                        box_verts[ori_idx][0] = p0
+                        box_verts[ori_idx][3:] = p0
+                        box_verts[ori_idx][1:3] = p1
+                        if not fill:
+                            # When fill is True, the data get changed in place
+                            box_artist.set_data(box_verts)
+                        # TODO XXX don't update value dimension; don't shrink orient dim
+                        ax.update_datalim(np.transpose(box_verts))
+
+                    if artists["medians"]:
+                        verts = artists["medians"][i].get_xydata().T
+                        verts[ori_idx][:] = p0, p1
+                        artists["medians"][i].set_data(verts)
+
+                    if artists["caps"]:
+                        for line in artists["caps"][2 * i:2 * i + 2]:
+                            p0 = 10 ** (np.log10(box[self.orient]) - capwidth[i] / 2)
+                            p1 = 10 ** (np.log10(box[self.orient]) + capwidth[i] / 2)
+                            verts = line.get_xydata().T
+                            verts[ori_idx][:] = p0, p1
+                            line.set_data(verts)
+
+            ax.add_container(BoxPlotContainer(artists))
+
+        patch_kws = props["box"].copy()
+        if not fill:
+            patch_kws["facecolor"] = (1, 1, 1, 0)
         else:
-            show_legend = bool(self.legend)
+            patch_kws["edgecolor"] = linecolor
+        self._configure_legend(ax, ax.fill_between, patch_kws)
 
-        if show_legend:
-            self.add_legend_data(ax)
-            handles, _ = ax.get_legend_handles_labels()
-            if handles:
-                ax.legend(title=self.legend_title)
+    def plot_violins(
+        self,
+        width,
+        dodge,
+        gap,
+        split,
+        color,
+        fill,
+        linecolor,
+        linewidth,
+        inner,
+        density_norm,
+        common_norm,
+        kde_kws,
+        inner_kws,
+        plot_kws,
+    ):
 
+        iter_vars = [self.orient, "hue"]
+        value_var = {"x": "y", "y": "x"}[self.orient]
 
-class _CategoricalFacetPlotter(_CategoricalPlotterNew):
+        inner_options = ["box", "quart", "stick", "point", None]
+        _check_argument("inner", inner_options, inner, prefix=True)
+        _check_argument("density_norm", ["area", "count", "width"], density_norm)
+
+        if linecolor is None:
+            if "hue" in self.variables:
+                linecolor = self._get_gray(list(self._hue_map.lookup_table.values()))
+            else:
+                linecolor = self._get_gray([color])
+
+        if linewidth is None:
+            if fill:
+                linewidth = 1.25 * mpl.rcParams["patch.linewidth"]
+            else:
+                linewidth = mpl.rcParams["lines.linewidth"]
+
+        if inner is not None and inner.startswith("box"):
+            box_width = inner_kws.pop("box_width", linewidth * 4.5)
+            whis_width = inner_kws.pop("whis_width", box_width / 3)
+            marker = inner_kws.pop("marker", "_" if self.orient == "x" else "|")
+
+        kde = KDE(**kde_kws)
+        ax = self.ax
+        violin_data = []
+
+        # Iterate through all the data splits once to compute the KDEs
+        for sub_vars, sub_data in self.iter_data(iter_vars,
+                                                 from_comp_data=True,
+                                                 allow_empty=False):
+
+            sub_data["weight"] = sub_data.get("weights", 1)
+            stat_data = kde._transform(sub_data, value_var, [])
+
+            maincolor = self._hue_map(sub_vars["hue"]) if "hue" in sub_vars else color
+            if not fill:
+                linecolor = maincolor
+                maincolor = "none"
+            default_kws = dict(
+                facecolor=maincolor,
+                edgecolor=linecolor,
+                linewidth=linewidth,
+            )
+
+            violin_data.append({
+                "position": sub_vars[self.orient],
+                "observations": sub_data[value_var],
+                "density": stat_data["density"],
+                "support": stat_data[value_var],
+                "kwargs": {**default_kws, **plot_kws},
+                "sub_vars": sub_vars,
+                "ax": self._get_axes(sub_vars),
+            })
 
+        # Once we've computed all the KDEs, get statistics for normalization
+        def vars_to_key(sub_vars):
+            return tuple((k, v) for k, v in sub_vars.items() if k != self.orient)
+
+        norm_keys = [vars_to_key(violin["sub_vars"]) for violin in violin_data]
+        if common_norm:
+            common_max_density = np.nanmax([v["density"].max() for v in violin_data])
+            common_max_count = np.nanmax([len(v["observations"]) for v in violin_data])
+            max_density = {key: common_max_density for key in norm_keys}
+            max_count = {key: common_max_count for key in norm_keys}
+        else:
+            max_density = {
+                key: np.nanmax([
+                    v["density"].max() for v in violin_data
+                    if vars_to_key(v["sub_vars"]) == key
+                ]) for key in norm_keys
+            }
+            max_count = {
+                key: np.nanmax([
+                    len(v["observations"]) for v in violin_data
+                    if vars_to_key(v["sub_vars"]) == key
+                ]) for key in norm_keys
+            }
+
+        real_width = width * self._native_width
+
+        # Now iterate through the violins again to apply the normalization and plot
+        for violin in violin_data:
+
+            index = pd.RangeIndex(0, max(len(violin["support"]), 1))
+            data = pd.DataFrame({
+                self.orient: violin["position"],
+                value_var: violin["support"],
+                "density": violin["density"],
+                "width": real_width,
+            }, index=index)
+
+            if dodge:
+                self._dodge(violin["sub_vars"], data)
+            if gap:
+                data["width"] *= 1 - gap
+
+            # Normalize the density across the distribution(s) and relative to the width
+            norm_key = vars_to_key(violin["sub_vars"])
+            hw = data["width"] / 2
+            peak_density = violin["density"].max()
+            if np.isnan(peak_density):
+                span = 1
+            elif density_norm == "area":
+                span = data["density"] / max_density[norm_key]
+            elif density_norm == "count":
+                count = len(violin["observations"])
+                span = data["density"] / peak_density * (count / max_count[norm_key])
+            elif density_norm == "width":
+                span = data["density"] / peak_density
+            span = span * hw * (2 if split else 1)
+
+            # Handle split violins (i.e. asymmetric spans)
+            right_side = (
+                0 if "hue" not in self.variables
+                else self._hue_map.levels.index(violin["sub_vars"]["hue"]) % 2
+            )
+            if split:
+                offsets = (hw, span - hw) if right_side else (span - hw, hw)
+            else:
+                offsets = span, span
+
+            ax = violin["ax"]
+            _, invx = utils._get_transform_functions(ax, "x")
+            _, invy = utils._get_transform_functions(ax, "y")
+            inv_pos = {"x": invx, "y": invy}[self.orient]
+            inv_val = {"x": invx, "y": invy}[value_var]
+
+            linecolor = violin["kwargs"]["edgecolor"]
+
+            # Handle singular datasets (one or more observations with no variance
+            if np.isnan(peak_density):
+                pos = data[self.orient].iloc[0]
+                val = violin["observations"].mean()
+                if self.orient == "x":
+                    x, y = [pos - offsets[0], pos + offsets[1]], [val, val]
+                else:
+                    x, y = [val, val], [pos - offsets[0], pos + offsets[1]]
+                ax.plot(invx(x), invy(y), color=linecolor, linewidth=linewidth)
+                continue
+
+            # Plot the main violin body
+            plot_func = {"x": ax.fill_betweenx, "y": ax.fill_between}[self.orient]
+            plot_func(
+                inv_val(data[value_var]),
+                inv_pos(data[self.orient] - offsets[0]),
+                inv_pos(data[self.orient] + offsets[1]),
+                **violin["kwargs"]
+            )
+
+            # Adjust the observation data
+            obs = violin["observations"]
+            pos_dict = {self.orient: violin["position"], "width": real_width}
+            if dodge:
+                self._dodge(violin["sub_vars"], pos_dict)
+            if gap:
+                pos_dict["width"] *= (1 - gap)
+
+            # --- Plot the inner components
+            if inner is None:
+                continue
+
+            elif inner.startswith("point"):
+                pos = np.array([pos_dict[self.orient]] * len(obs))
+                if split:
+                    pos += (-1 if right_side else 1) * pos_dict["width"] / 2
+                x, y = (pos, obs) if self.orient == "x" else (obs, pos)
+                kws = {
+                    "color": linecolor,
+                    "edgecolor": linecolor,
+                    "s": (linewidth * 2) ** 2,
+                    "zorder": violin["kwargs"].get("zorder", 2) + 1,
+                    **inner_kws,
+                }
+                ax.scatter(invx(x), invy(y), **kws)
+
+            elif inner.startswith("stick"):
+                pos0 = np.interp(obs, data[value_var], data[self.orient] - offsets[0])
+                pos1 = np.interp(obs, data[value_var], data[self.orient] + offsets[1])
+                pos_pts = np.stack([inv_pos(pos0), inv_pos(pos1)])
+                val_pts = np.stack([inv_val(obs), inv_val(obs)])
+                segments = np.stack([pos_pts, val_pts]).transpose(2, 1, 0)
+                if self.orient == "y":
+                    segments = segments[:, :, ::-1]
+                kws = {
+                    "color": linecolor,
+                    "linewidth": linewidth / 2,
+                    **inner_kws,
+                }
+                lines = mpl.collections.LineCollection(segments, **kws)
+                ax.add_collection(lines, autolim=False)
+
+            elif inner.startswith("quart"):
+                stats = np.percentile(obs, [25, 50, 75])
+                pos0 = np.interp(stats, data[value_var], data[self.orient] - offsets[0])
+                pos1 = np.interp(stats, data[value_var], data[self.orient] + offsets[1])
+                pos_pts = np.stack([inv_pos(pos0), inv_pos(pos1)])
+                val_pts = np.stack([inv_val(stats), inv_val(stats)])
+                segments = np.stack([pos_pts, val_pts]).transpose(2, 0, 1)
+                if self.orient == "y":
+                    segments = segments[:, ::-1, :]
+                dashes = [(1.25, .75), (2.5, 1), (1.25, .75)]
+                for i, segment in enumerate(segments):
+                    kws = {
+                        "color": linecolor,
+                        "linewidth": linewidth,
+                        "dashes": dashes[i],
+                        **inner_kws,
+                    }
+                    ax.plot(*segment, **kws)
+
+            elif inner.startswith("box"):
+                stats = mpl.cbook.boxplot_stats(obs)[0]
+                pos = np.array(pos_dict[self.orient])
+                if split:
+                    pos += (-1 if right_side else 1) * pos_dict["width"] / 2
+                pos = [pos, pos], [pos, pos], [pos]
+                val = (
+                    [stats["whislo"], stats["whishi"]],
+                    [stats["q1"], stats["q3"]],
+                    [stats["med"]]
+                )
+                if self.orient == "x":
+                    (x0, x1, x2), (y0, y1, y2) = pos, val
+                else:
+                    (x0, x1, x2), (y0, y1, y2) = val, pos
+
+                if split:
+                    offset = (1 if right_side else -1) * box_width / 72 / 2
+                    dx, dy = (offset, 0) if self.orient == "x" else (0, -offset)
+                    trans = ax.transData + mpl.transforms.ScaledTranslation(
+                        dx, dy, ax.figure.dpi_scale_trans,
+                    )
+                else:
+                    trans = ax.transData
+                line_kws = {
+                    "color": linecolor,
+                    "transform": trans,
+                    **inner_kws,
+                    "linewidth": whis_width,
+                }
+                ax.plot(invx(x0), invy(y0), **line_kws)
+                line_kws["linewidth"] = box_width
+                ax.plot(invx(x1), invy(y1), **line_kws)
+                dot_kws = {
+                    "marker": marker,
+                    "markersize": box_width / 1.2,
+                    "markeredgewidth": box_width / 5,
+                    "transform": trans,
+                    **inner_kws,
+                    "markeredgecolor": "w",
+                    "markerfacecolor": "w",
+                    "color": linecolor,  # simplify tests
+                }
+                ax.plot(invx(x2), invy(y2), **dot_kws)
+
+        self._configure_legend(ax, ax.fill_between)  # TODO, patch_kws)
+
+    def plot_points(
+        self,
+        aggregator,
+        markers,
+        linestyles,
+        dodge,
+        color,
+        capsize,
+        err_kws,
+        plot_kws,
+    ):
+
+        agg_var = {"x": "y", "y": "x"}[self.orient]
+        iter_vars = ["hue"]
+
+        plot_kws = _normalize_kwargs(plot_kws, mpl.lines.Line2D)
+        plot_kws.setdefault("linewidth", mpl.rcParams["lines.linewidth"] * 1.8)
+        plot_kws.setdefault("markeredgewidth", plot_kws["linewidth"] * 0.75)
+        plot_kws.setdefault("markersize", plot_kws["linewidth"] * np.sqrt(2 * np.pi))
+
+        markers = self._map_prop_with_hue("marker", markers, "o", plot_kws)
+        linestyles = self._map_prop_with_hue("linestyle", linestyles, "-", plot_kws)
+
+        positions = self.var_levels[self.orient]
+        if self.var_types[self.orient] == "categorical":
+            min_cat_val = int(self.comp_data[self.orient].min())
+            max_cat_val = int(self.comp_data[self.orient].max())
+            positions = [i for i in range(min_cat_val, max_cat_val + 1)]
+        else:
+            if self._log_scaled(self.orient):
+                positions = np.log10(positions)
+            if self.var_types[self.orient] == "datetime":
+                positions = mpl.dates.date2num(positions)
+        positions = pd.Index(positions, name=self.orient)
+
+        n_hue_levels = 0 if self._hue_map.levels is None else len(self._hue_map.levels)
+        if dodge is True:
+            dodge = .025 * n_hue_levels
+
+        ax = self.ax
+
+        for sub_vars, sub_data in self.iter_data(iter_vars,
+                                                 from_comp_data=True,
+                                                 allow_empty=True):
+
+            ax = self._get_axes(sub_vars)
+
+            agg_data = sub_data if sub_data.empty else (
+                sub_data
+                .groupby(self.orient)
+                .apply(aggregator, agg_var)
+                .reindex(positions)
+                .reset_index()
+            )
+
+            if dodge:
+                hue_idx = self._hue_map.levels.index(sub_vars["hue"])
+                offset = -dodge * (n_hue_levels - 1) / 2 + dodge * hue_idx
+                agg_data[self.orient] += offset * self._native_width
+
+            self._invert_scale(ax, agg_data)
+
+            sub_kws = plot_kws.copy()
+            sub_kws.update(
+                marker=markers[sub_vars.get("hue")],
+                linestyle=linestyles[sub_vars.get("hue")],
+                color=self._hue_map(sub_vars["hue"]) if "hue" in sub_vars else color,
+            )
+
+            line, = ax.plot(agg_data["x"], agg_data["y"], **sub_kws)
+
+            sub_err_kws = err_kws.copy()
+            line_props = line.properties()
+            for prop in ["color", "linewidth", "alpha", "zorder"]:
+                sub_err_kws.setdefault(prop, line_props[prop])
+            if aggregator.error_method is not None:
+                self.plot_errorbars(ax, agg_data, capsize, sub_err_kws)
+
+        semantic_kws = {"hue": {"marker": markers, "linestyle": linestyles}}
+        self._configure_legend(ax, ax.plot, sub_kws, semantic_kws)
+
+    def plot_bars(
+        self,
+        aggregator,
+        dodge,
+        gap,
+        width,
+        fill,
+        color,
+        capsize,
+        err_kws,
+        plot_kws,
+    ):
+
+        agg_var = {"x": "y", "y": "x"}[self.orient]
+        iter_vars = ["hue"]
+
+        ax = self.ax
+
+        if self._hue_map.levels is None:
+            dodge = False
+
+        if dodge and capsize is not None:
+            capsize = capsize / len(self._hue_map.levels)
+
+        if not fill:
+            plot_kws.setdefault("linewidth", 1.5 * mpl.rcParams["lines.linewidth"])
+
+        err_kws.setdefault("linewidth", 1.5 * mpl.rcParams["lines.linewidth"])
+
+        for sub_vars, sub_data in self.iter_data(iter_vars,
+                                                 from_comp_data=True,
+                                                 allow_empty=True):
+
+            ax = self._get_axes(sub_vars)
+
+            agg_data = sub_data if sub_data.empty else (
+                sub_data
+                .groupby(self.orient)
+                .apply(aggregator, agg_var)
+                .reset_index()
+            )
+
+            agg_data["width"] = width * self._native_width
+            if dodge:
+                self._dodge(sub_vars, agg_data)
+            if gap:
+                agg_data["width"] *= 1 - gap
+
+            agg_data["edge"] = agg_data[self.orient] - agg_data["width"] / 2
+            self._invert_scale(ax, agg_data)
+
+            if self.orient == "x":
+                bar_func = ax.bar
+                kws = dict(
+                    x=agg_data["edge"], height=agg_data["y"], width=agg_data["width"]
+                )
+            else:
+                bar_func = ax.barh
+                kws = dict(
+                    y=agg_data["edge"], width=agg_data["x"], height=agg_data["width"]
+                )
+
+            main_color = self._hue_map(sub_vars["hue"]) if "hue" in sub_vars else color
+
+            # Set both color and facecolor for property cycle logic
+            kws["align"] = "edge"
+            if fill:
+                kws.update(color=main_color, facecolor=main_color)
+            else:
+                kws.update(color=main_color, edgecolor=main_color, facecolor="none")
+
+            bar_func(**{**kws, **plot_kws})
+
+            if aggregator.error_method is not None:
+                self.plot_errorbars(
+                    ax, agg_data, capsize,
+                    {"color": ".26" if fill else main_color, **err_kws}
+                )
+
+        self._configure_legend(ax, ax.fill_between)
+
+    def plot_errorbars(self, ax, data, capsize, err_kws):
+
+        var = {"x": "y", "y": "x"}[self.orient]
+        for row in data.to_dict("records"):
+
+            row = dict(row)
+            pos = np.array([row[self.orient], row[self.orient]])
+            val = np.array([row[f"{var}min"], row[f"{var}max"]])
+
+            cw = capsize * self._native_width / 2
+            if self._log_scaled(self.orient):
+                log_pos = np.log10(pos)
+                cap = 10 ** (log_pos[0] - cw), 10 ** (log_pos[1] + cw)
+            else:
+                cap = pos[0] - cw, pos[1] + cw
+
+            if capsize:
+                pos = np.concatenate([
+                    [*cap, np.nan], pos, [np.nan, *cap]
+                ])
+                val = np.concatenate([
+                    [val[0], val[0], np.nan], val, [np.nan, val[-1], val[-1]],
+                ])
+
+            if self.orient == "x":
+                args = pos, val
+            else:
+                args = val, pos
+            ax.plot(*args, **err_kws)
+
+
+class _CategoricalAggPlotter(_CategoricalPlotterNew):
+
+    flat_structure = {"x": "@index", "y": "@values"}
+
+
+class _CategoricalFacetPlotter(_CategoricalPlotterNew):
     semantics = _CategoricalPlotterNew.semantics + ("col", "row")
 
 
+class _CategoricalAggFacetPlotter(_CategoricalAggPlotter, _CategoricalFacetPlotter):
+    # Ugh, this is messy
+    pass
+
+
 class _CategoricalPlotter:
 
     width = .8
@@ -519,7 +1284,7 @@ class _CategoricalPlotter:
                 group_names = list(range(len(plot_data)))
 
             # Figure out the plotting orientation
-            orient = "h" if str(orient).startswith("h") else "v"
+            orient = "y" if str(orient)[0] in "hy" else "x"
 
         # Option 2:
         # We are plotting a long-form dataset
@@ -541,9 +1306,7 @@ class _CategoricalPlotter:
                     raise ValueError(err)
 
             # Figure out the plotting orientation
-            orient = infer_orient(
-                x, y, orient, require_numeric=self.require_numeric
-            )
+            orient = infer_orient(x, y, orient, require_numeric=self.require_numeric)
 
             # Option 2a:
             # We are plotting a single set of data
@@ -577,7 +1340,7 @@ class _CategoricalPlotter:
             else:
 
                 # Determine which role each variable will play
-                if orient == "v":
+                if orient == "x":
                     vals, groups = y, x
                 else:
                     vals, groups = x, y
@@ -735,7 +1498,7 @@ class _CategoricalPlotter:
 
     def annotate_axes(self, ax):
         """Add descriptive labels to an Axes object."""
-        if self.orient == "v":
+        if self.orient == "x":
             xlabel, ylabel = self.group_label, self.value_label
         else:
             xlabel, ylabel = self.value_label, self.group_label
@@ -749,14 +1512,14 @@ class _CategoricalPlotter:
         if not group_names:
             group_names = ["" for _ in range(len(self.plot_data))]
 
-        if self.orient == "v":
+        if self.orient == "x":
             ax.set_xticks(np.arange(len(self.plot_data)))
             ax.set_xticklabels(group_names)
         else:
             ax.set_yticks(np.arange(len(self.plot_data)))
             ax.set_yticklabels(group_names)
 
-        if self.orient == "v":
+        if self.orient == "x":
             ax.xaxis.grid(False)
             ax.set_xlim(-.5, len(self.plot_data) - .5, auto=None)
         else:
@@ -776,1014 +1539,48 @@ class _CategoricalPlotter:
         ax.add_patch(rect)
 
 
-class _BoxPlotter(_CategoricalPlotter):
+class _LVPlotter(_CategoricalPlotter):
 
     def __init__(self, x, y, hue, data, order, hue_order,
                  orient, color, palette, saturation,
-                 width, dodge, fliersize, linewidth):
-
-        self.establish_variables(x, y, hue, data, orient, order, hue_order)
-        self.establish_colors(color, palette, saturation)
+                 width, dodge, k_depth, linewidth, scale, outlier_prop,
+                 trust_alpha, showfliers=True):
 
-        self.dodge = dodge
         self.width = width
-        self.fliersize = fliersize
+        self.dodge = dodge
+        self.saturation = saturation
+
+        k_depth_methods = ['proportion', 'tukey', 'trustworthy', 'full']
+        if not (k_depth in k_depth_methods or isinstance(k_depth, Number)):
+            msg = (f'k_depth must be one of {k_depth_methods} or a number, '
+                   f'but {k_depth} was passed.')
+            raise ValueError(msg)
+        self.k_depth = k_depth
 
         if linewidth is None:
             linewidth = mpl.rcParams["lines.linewidth"]
         self.linewidth = linewidth
 
-    def draw_boxplot(self, ax, kws):
-        """Use matplotlib to draw a boxplot on an Axes."""
-        vert = self.orient == "v"
-
-        props = {}
-        for obj in ["box", "whisker", "cap", "median", "flier"]:
-            props[obj] = kws.pop(obj + "props", {})
+        scales = ['linear', 'exponential', 'area']
+        if scale not in scales:
+            msg = f'scale must be one of {scales}, but {scale} was passed.'
+            raise ValueError(msg)
+        self.scale = scale
 
-        for i, group_data in enumerate(self.plot_data):
+        if ((outlier_prop > 1) or (outlier_prop <= 0)):
+            msg = f'outlier_prop {outlier_prop} not in range (0, 1]'
+            raise ValueError(msg)
+        self.outlier_prop = outlier_prop
 
-            if self.plot_hues is None:
+        if not 0 < trust_alpha < 1:
+            msg = f'trust_alpha {trust_alpha} not in range (0, 1) '
+            raise ValueError(msg)
+        self.trust_alpha = trust_alpha
 
-                # Handle case where there is data at this level
-                if group_data.size == 0:
-                    continue
+        self.showfliers = showfliers
 
-                # Draw a single box or a set of boxes
-                # with a single level of grouping
-                box_data = np.asarray(remove_na(group_data))
-
-                # Handle case where there is no non-null data
-                if box_data.size == 0:
-                    continue
-
-                artist_dict = ax.boxplot(box_data,
-                                         vert=vert,
-                                         patch_artist=True,
-                                         positions=[i],
-                                         widths=self.width,
-                                         **kws)
-                color = self.colors[i]
-                self.restyle_boxplot(artist_dict, color, props)
-            else:
-                # Draw nested groups of boxes
-                offsets = self.hue_offsets
-                for j, hue_level in enumerate(self.hue_names):
-
-                    # Add a legend for this hue level
-                    if not i:
-                        self.add_legend_data(ax, self.colors[j], hue_level)
-
-                    # Handle case where there is data at this level
-                    if group_data.size == 0:
-                        continue
-
-                    hue_mask = self.plot_hues[i] == hue_level
-                    box_data = np.asarray(remove_na(group_data[hue_mask]))
-
-                    # Handle case where there is no non-null data
-                    if box_data.size == 0:
-                        continue
-
-                    center = i + offsets[j]
-                    artist_dict = ax.boxplot(box_data,
-                                             vert=vert,
-                                             patch_artist=True,
-                                             positions=[center],
-                                             widths=self.nested_width,
-                                             **kws)
-                    self.restyle_boxplot(artist_dict, self.colors[j], props)
-                    # Add legend data, but just for one set of boxes
-
-    def restyle_boxplot(self, artist_dict, color, props):
-        """Take a drawn matplotlib boxplot and make it look nice."""
-        for box in artist_dict["boxes"]:
-            box.update(dict(facecolor=color,
-                            zorder=.9,
-                            edgecolor=self.gray,
-                            linewidth=self.linewidth))
-            box.update(props["box"])
-        for whisk in artist_dict["whiskers"]:
-            whisk.update(dict(color=self.gray,
-                              linewidth=self.linewidth,
-                              linestyle="-"))
-            whisk.update(props["whisker"])
-        for cap in artist_dict["caps"]:
-            cap.update(dict(color=self.gray,
-                            linewidth=self.linewidth))
-            cap.update(props["cap"])
-        for med in artist_dict["medians"]:
-            med.update(dict(color=self.gray,
-                            linewidth=self.linewidth))
-            med.update(props["median"])
-        for fly in artist_dict["fliers"]:
-            fly.update(dict(markerfacecolor=self.gray,
-                            marker="d",
-                            markeredgecolor=self.gray,
-                            markersize=self.fliersize))
-            fly.update(props["flier"])
-
-    def plot(self, ax, boxplot_kws):
-        """Make the plot."""
-        self.draw_boxplot(ax, boxplot_kws)
-        self.annotate_axes(ax)
-        if self.orient == "h":
-            ax.invert_yaxis()
-
-
-class _ViolinPlotter(_CategoricalPlotter):
-
-    def __init__(self, x, y, hue, data, order, hue_order,
-                 bw, cut, scale, scale_hue, gridsize,
-                 width, inner, split, dodge, orient, linewidth,
-                 color, palette, saturation):
-
-        self.establish_variables(x, y, hue, data, orient, order, hue_order)
-        self.establish_colors(color, palette, saturation)
-        self.estimate_densities(bw, cut, scale, scale_hue, gridsize)
-
-        self.gridsize = gridsize
-        self.width = width
-        self.dodge = dodge
-
-        if inner is not None:
-            if not any([inner.startswith("quart"),
-                        inner.startswith("box"),
-                        inner.startswith("stick"),
-                        inner.startswith("point")]):
-                err = f"Inner style '{inner}' not recognized"
-                raise ValueError(err)
-        self.inner = inner
-
-        if split and self.hue_names is not None and len(self.hue_names) != 2:
-            msg = "There must be exactly two hue levels to use `split`.'"
-            raise ValueError(msg)
-        self.split = split
-
-        if linewidth is None:
-            linewidth = mpl.rcParams["lines.linewidth"]
-        self.linewidth = linewidth
-
-    def estimate_densities(self, bw, cut, scale, scale_hue, gridsize):
-        """Find the support and density for all of the data."""
-        # Initialize data structures to keep track of plotting data
-        if self.hue_names is None:
-            support = []
-            density = []
-            counts = np.zeros(len(self.plot_data))
-            max_density = np.zeros(len(self.plot_data))
-        else:
-            support = [[] for _ in self.plot_data]
-            density = [[] for _ in self.plot_data]
-            size = len(self.group_names), len(self.hue_names)
-            counts = np.zeros(size)
-            max_density = np.zeros(size)
-
-        for i, group_data in enumerate(self.plot_data):
-
-            # Option 1: we have a single level of grouping
-            # --------------------------------------------
-
-            if self.plot_hues is None:
-
-                # Strip missing datapoints
-                kde_data = remove_na(group_data)
-
-                # Handle special case of no data at this level
-                if kde_data.size == 0:
-                    support.append(np.array([]))
-                    density.append(np.array([1.]))
-                    counts[i] = 0
-                    max_density[i] = 0
-                    continue
-
-                # Handle special case of a single unique datapoint
-                elif np.unique(kde_data).size == 1:
-                    support.append(np.unique(kde_data))
-                    density.append(np.array([1.]))
-                    counts[i] = 1
-                    max_density[i] = 0
-                    continue
-
-                # Fit the KDE and get the used bandwidth size
-                kde, bw_used = self.fit_kde(kde_data, bw)
-
-                # Determine the support grid and get the density over it
-                support_i = self.kde_support(kde_data, bw_used, cut, gridsize)
-                density_i = kde.evaluate(support_i)
-
-                # Update the data structures with these results
-                support.append(support_i)
-                density.append(density_i)
-                counts[i] = kde_data.size
-                max_density[i] = density_i.max()
-
-            # Option 2: we have nested grouping by a hue variable
-            # ---------------------------------------------------
-
-            else:
-                for j, hue_level in enumerate(self.hue_names):
-
-                    # Handle special case of no data at this category level
-                    if not group_data.size:
-                        support[i].append(np.array([]))
-                        density[i].append(np.array([1.]))
-                        counts[i, j] = 0
-                        max_density[i, j] = 0
-                        continue
-
-                    # Select out the observations for this hue level
-                    hue_mask = self.plot_hues[i] == hue_level
-
-                    # Strip missing datapoints
-                    kde_data = remove_na(group_data[hue_mask])
-
-                    # Handle special case of no data at this level
-                    if kde_data.size == 0:
-                        support[i].append(np.array([]))
-                        density[i].append(np.array([1.]))
-                        counts[i, j] = 0
-                        max_density[i, j] = 0
-                        continue
-
-                    # Handle special case of a single unique datapoint
-                    elif np.unique(kde_data).size == 1:
-                        support[i].append(np.unique(kde_data))
-                        density[i].append(np.array([1.]))
-                        counts[i, j] = 1
-                        max_density[i, j] = 0
-                        continue
-
-                    # Fit the KDE and get the used bandwidth size
-                    kde, bw_used = self.fit_kde(kde_data, bw)
-
-                    # Determine the support grid and get the density over it
-                    support_ij = self.kde_support(kde_data, bw_used,
-                                                  cut, gridsize)
-                    density_ij = kde.evaluate(support_ij)
-
-                    # Update the data structures with these results
-                    support[i].append(support_ij)
-                    density[i].append(density_ij)
-                    counts[i, j] = kde_data.size
-                    max_density[i, j] = density_ij.max()
-
-        # Scale the height of the density curve.
-        # For a violinplot the density is non-quantitative.
-        # The objective here is to scale the curves relative to 1 so that
-        # they can be multiplied by the width parameter during plotting.
-
-        if scale == "area":
-            self.scale_area(density, max_density, scale_hue)
-
-        elif scale == "width":
-            self.scale_width(density)
-
-        elif scale == "count":
-            self.scale_count(density, counts, scale_hue)
-
-        else:
-            raise ValueError(f"scale method '{scale}' not recognized")
-
-        # Set object attributes that will be used while plotting
-        self.support = support
-        self.density = density
-
-    def fit_kde(self, x, bw):
-        """Estimate a KDE for a vector of data with flexible bandwidth."""
-        kde = gaussian_kde(x, bw)
-
-        # Extract the numeric bandwidth from the KDE object
-        bw_used = kde.factor
-
-        # At this point, bw will be a numeric scale factor.
-        # To get the actual bandwidth of the kernel, we multiple by the
-        # unbiased standard deviation of the data, which we will use
-        # elsewhere to compute the range of the support.
-        bw_used = bw_used * x.std(ddof=1)
-
-        return kde, bw_used
-
-    def kde_support(self, x, bw, cut, gridsize):
-        """Define a grid of support for the violin."""
-        support_min = x.min() - bw * cut
-        support_max = x.max() + bw * cut
-        return np.linspace(support_min, support_max, gridsize)
-
-    def scale_area(self, density, max_density, scale_hue):
-        """Scale the relative area under the KDE curve.
-
-        This essentially preserves the "standard" KDE scaling, but the
-        resulting maximum density will be 1 so that the curve can be
-        properly multiplied by the violin width.
-
-        """
-        if self.hue_names is None:
-            for d in density:
-                if d.size > 1:
-                    d /= max_density.max()
-        else:
-            for i, group in enumerate(density):
-                for d in group:
-                    if scale_hue:
-                        max = max_density[i].max()
-                    else:
-                        max = max_density.max()
-                    if d.size > 1:
-                        d /= max
-
-    def scale_width(self, density):
-        """Scale each density curve to the same height."""
-        if self.hue_names is None:
-            for d in density:
-                d /= d.max()
-        else:
-            for group in density:
-                for d in group:
-                    d /= d.max()
-
-    def scale_count(self, density, counts, scale_hue):
-        """Scale each density curve by the number of observations."""
-        if self.hue_names is None:
-            if counts.max() == 0:
-                d = 0
-            else:
-                for count, d in zip(counts, density):
-                    d /= d.max()
-                    d *= count / counts.max()
-        else:
-            for i, group in enumerate(density):
-                for j, d in enumerate(group):
-                    if counts[i].max() == 0:
-                        d = 0
-                    else:
-                        count = counts[i, j]
-                        if scale_hue:
-                            scaler = count / counts[i].max()
-                        else:
-                            scaler = count / counts.max()
-                        d /= d.max()
-                        d *= scaler
-
-    @property
-    def dwidth(self):
-
-        if self.hue_names is None or not self.dodge:
-            return self.width / 2
-        elif self.split:
-            return self.width / 2
-        else:
-            return self.width / (2 * len(self.hue_names))
-
-    def draw_violins(self, ax):
-        """Draw the violins onto `ax`."""
-        fill_func = ax.fill_betweenx if self.orient == "v" else ax.fill_between
-        for i, group_data in enumerate(self.plot_data):
-
-            kws = dict(edgecolor=self.gray, linewidth=self.linewidth)
-
-            # Option 1: we have a single level of grouping
-            # --------------------------------------------
-
-            if self.plot_hues is None:
-
-                support, density = self.support[i], self.density[i]
-
-                # Handle special case of no observations in this bin
-                if support.size == 0:
-                    continue
-
-                # Handle special case of a single observation
-                elif support.size == 1:
-                    val = support.item()
-                    d = density.item()
-                    self.draw_single_observation(ax, i, val, d)
-                    continue
-
-                # Draw the violin for this group
-                grid = np.ones(self.gridsize) * i
-                fill_func(support,
-                          grid - density * self.dwidth,
-                          grid + density * self.dwidth,
-                          facecolor=self.colors[i],
-                          **kws)
-
-                # Draw the interior representation of the data
-                if self.inner is None:
-                    continue
-
-                # Get a nan-free vector of datapoints
-                violin_data = remove_na(group_data)
-
-                # Draw box and whisker information
-                if self.inner.startswith("box"):
-                    self.draw_box_lines(ax, violin_data, i)
-
-                # Draw quartile lines
-                elif self.inner.startswith("quart"):
-                    self.draw_quartiles(ax, violin_data, support, density, i)
-
-                # Draw stick observations
-                elif self.inner.startswith("stick"):
-                    self.draw_stick_lines(ax, violin_data, support, density, i)
-
-                # Draw point observations
-                elif self.inner.startswith("point"):
-                    self.draw_points(ax, violin_data, i)
-
-            # Option 2: we have nested grouping by a hue variable
-            # ---------------------------------------------------
-
-            else:
-                offsets = self.hue_offsets
-                for j, hue_level in enumerate(self.hue_names):
-
-                    support, density = self.support[i][j], self.density[i][j]
-                    kws["facecolor"] = self.colors[j]
-
-                    # Add legend data, but just for one set of violins
-                    if not i:
-                        self.add_legend_data(ax, self.colors[j], hue_level)
-
-                    # Handle the special case where we have no observations
-                    if support.size == 0:
-                        continue
-
-                    # Handle the special case where we have one observation
-                    elif support.size == 1:
-                        val = support.item()
-                        d = density.item()
-                        if self.split:
-                            d = d / 2
-                        at_group = i + offsets[j]
-                        self.draw_single_observation(ax, at_group, val, d)
-                        continue
-
-                    # Option 2a: we are drawing a single split violin
-                    # -----------------------------------------------
-
-                    if self.split:
-
-                        grid = np.ones(self.gridsize) * i
-                        if j:
-                            fill_func(support,
-                                      grid,
-                                      grid + density * self.dwidth,
-                                      **kws)
-                        else:
-                            fill_func(support,
-                                      grid - density * self.dwidth,
-                                      grid,
-                                      **kws)
-
-                        # Draw the interior representation of the data
-                        if self.inner is None:
-                            continue
-
-                        # Get a nan-free vector of datapoints
-                        hue_mask = self.plot_hues[i] == hue_level
-                        violin_data = remove_na(group_data[hue_mask])
-
-                        # Draw quartile lines
-                        if self.inner.startswith("quart"):
-                            self.draw_quartiles(ax, violin_data,
-                                                support, density, i,
-                                                ["left", "right"][j])
-
-                        # Draw stick observations
-                        elif self.inner.startswith("stick"):
-                            self.draw_stick_lines(ax, violin_data,
-                                                  support, density, i,
-                                                  ["left", "right"][j])
-
-                        # The box and point interior plots are drawn for
-                        # all data at the group level, so we just do that once
-                        if j and any(self.plot_hues[0] == hue_level):
-                            continue
-
-                        # Get the whole vector for this group level
-                        violin_data = remove_na(group_data)
-
-                        # Draw box and whisker information
-                        if self.inner.startswith("box"):
-                            self.draw_box_lines(ax, violin_data, i)
-
-                        # Draw point observations
-                        elif self.inner.startswith("point"):
-                            self.draw_points(ax, violin_data, i)
-
-                    # Option 2b: we are drawing full nested violins
-                    # -----------------------------------------------
-
-                    else:
-                        grid = np.ones(self.gridsize) * (i + offsets[j])
-                        fill_func(support,
-                                  grid - density * self.dwidth,
-                                  grid + density * self.dwidth,
-                                  **kws)
-
-                        # Draw the interior representation
-                        if self.inner is None:
-                            continue
-
-                        # Get a nan-free vector of datapoints
-                        hue_mask = self.plot_hues[i] == hue_level
-                        violin_data = remove_na(group_data[hue_mask])
-
-                        # Draw box and whisker information
-                        if self.inner.startswith("box"):
-                            self.draw_box_lines(ax, violin_data, i + offsets[j])
-
-                        # Draw quartile lines
-                        elif self.inner.startswith("quart"):
-                            self.draw_quartiles(ax, violin_data,
-                                                support, density,
-                                                i + offsets[j])
-
-                        # Draw stick observations
-                        elif self.inner.startswith("stick"):
-                            self.draw_stick_lines(ax, violin_data,
-                                                  support, density,
-                                                  i + offsets[j])
-
-                        # Draw point observations
-                        elif self.inner.startswith("point"):
-                            self.draw_points(ax, violin_data, i + offsets[j])
-
-    def draw_single_observation(self, ax, at_group, at_quant, density):
-        """Draw a line to mark a single observation."""
-        d_width = density * self.dwidth
-        if self.orient == "v":
-            ax.plot([at_group - d_width, at_group + d_width],
-                    [at_quant, at_quant],
-                    color=self.gray,
-                    linewidth=self.linewidth)
-        else:
-            ax.plot([at_quant, at_quant],
-                    [at_group - d_width, at_group + d_width],
-                    color=self.gray,
-                    linewidth=self.linewidth)
-
-    def draw_box_lines(self, ax, data, center):
-        """Draw boxplot information at center of the density."""
-        # Compute the boxplot statistics
-        q25, q50, q75 = np.percentile(data, [25, 50, 75])
-        whisker_lim = 1.5 * (q75 - q25)
-        h1 = np.min(data[data >= (q25 - whisker_lim)])
-        h2 = np.max(data[data <= (q75 + whisker_lim)])
-
-        # Draw a boxplot using lines and a point
-        if self.orient == "v":
-            ax.plot([center, center], [h1, h2],
-                    linewidth=self.linewidth,
-                    color=self.gray)
-            ax.plot([center, center], [q25, q75],
-                    linewidth=self.linewidth * 3,
-                    color=self.gray)
-            ax.scatter(center, q50,
-                       zorder=3,
-                       color="white",
-                       edgecolor=self.gray,
-                       s=np.square(self.linewidth * 2))
-        else:
-            ax.plot([h1, h2], [center, center],
-                    linewidth=self.linewidth,
-                    color=self.gray)
-            ax.plot([q25, q75], [center, center],
-                    linewidth=self.linewidth * 3,
-                    color=self.gray)
-            ax.scatter(q50, center,
-                       zorder=3,
-                       color="white",
-                       edgecolor=self.gray,
-                       s=np.square(self.linewidth * 2))
-
-    def draw_quartiles(self, ax, data, support, density, center, split=False):
-        """Draw the quartiles as lines at width of density."""
-        q25, q50, q75 = np.percentile(data, [25, 50, 75])
-
-        self.draw_to_density(ax, center, q25, support, density, split,
-                             linewidth=self.linewidth,
-                             dashes=[self.linewidth * 1.5] * 2)
-        self.draw_to_density(ax, center, q50, support, density, split,
-                             linewidth=self.linewidth,
-                             dashes=[self.linewidth * 3] * 2)
-        self.draw_to_density(ax, center, q75, support, density, split,
-                             linewidth=self.linewidth,
-                             dashes=[self.linewidth * 1.5] * 2)
-
-    def draw_points(self, ax, data, center):
-        """Draw individual observations as points at middle of the violin."""
-        kws = dict(s=np.square(self.linewidth * 2),
-                   color=self.gray,
-                   edgecolor=self.gray)
-
-        grid = np.ones(len(data)) * center
-
-        if self.orient == "v":
-            ax.scatter(grid, data, **kws)
-        else:
-            ax.scatter(data, grid, **kws)
-
-    def draw_stick_lines(self, ax, data, support, density,
-                         center, split=False):
-        """Draw individual observations as sticks at width of density."""
-        for val in data:
-            self.draw_to_density(ax, center, val, support, density, split,
-                                 linewidth=self.linewidth * .5)
-
-    def draw_to_density(self, ax, center, val, support, density, split, **kws):
-        """Draw a line orthogonal to the value axis at width of density."""
-        idx = np.argmin(np.abs(support - val))
-        width = self.dwidth * density[idx] * .99
-
-        kws["color"] = self.gray
-
-        if self.orient == "v":
-            if split == "left":
-                ax.plot([center - width, center], [val, val], **kws)
-            elif split == "right":
-                ax.plot([center, center + width], [val, val], **kws)
-            else:
-                ax.plot([center - width, center + width], [val, val], **kws)
-        else:
-            if split == "left":
-                ax.plot([val, val], [center - width, center], **kws)
-            elif split == "right":
-                ax.plot([val, val], [center, center + width], **kws)
-            else:
-                ax.plot([val, val], [center - width, center + width], **kws)
-
-    def plot(self, ax):
-        """Make the violin plot."""
-        self.draw_violins(ax)
-        self.annotate_axes(ax)
-        if self.orient == "h":
-            ax.invert_yaxis()
-
-
-class _CategoricalStatPlotter(_CategoricalPlotter):
-
-    require_numeric = True
-
-    @property
-    def nested_width(self):
-        """A float with the width of plot elements when hue nesting is used."""
-        if self.dodge:
-            width = self.width / len(self.hue_names)
-        else:
-            width = self.width
-        return width
-
-    def estimate_statistic(self, estimator, errorbar, n_boot, seed):
-
-        if self.hue_names is None:
-            statistic = []
-            confint = []
-        else:
-            statistic = [[] for _ in self.plot_data]
-            confint = [[] for _ in self.plot_data]
-
-        var = {"v": "y", "h": "x"}[self.orient]
-
-        agg = EstimateAggregator(estimator, errorbar, n_boot=n_boot, seed=seed)
-
-        for i, group_data in enumerate(self.plot_data):
-
-            # Option 1: we have a single layer of grouping
-            # --------------------------------------------
-            if self.plot_hues is None:
-
-                df = pd.DataFrame({var: group_data})
-                if self.plot_units is not None:
-                    df["units"] = self.plot_units[i]
-
-                res = agg(df, var)
-
-                statistic.append(res[var])
-                if errorbar is not None:
-                    confint.append((res[f"{var}min"], res[f"{var}max"]))
-
-            # Option 2: we are grouping by a hue layer
-            # ----------------------------------------
-
-            else:
-                for hue_level in self.hue_names:
-
-                    if not self.plot_hues[i].size:
-                        statistic[i].append(np.nan)
-                        if errorbar is not None:
-                            confint[i].append((np.nan, np.nan))
-                        continue
-
-                    hue_mask = self.plot_hues[i] == hue_level
-                    df = pd.DataFrame({var: group_data[hue_mask]})
-                    if self.plot_units is not None:
-                        df["units"] = self.plot_units[i][hue_mask]
-
-                    res = agg(df, var)
-
-                    statistic[i].append(res[var])
-                    if errorbar is not None:
-                        confint[i].append((res[f"{var}min"], res[f"{var}max"]))
-
-        # Save the resulting values for plotting
-        self.statistic = np.array(statistic)
-        self.confint = np.array(confint)
-
-    def draw_confints(self, ax, at_group, confint, colors,
-                      errwidth=None, capsize=None, **kws):
-
-        if errwidth is not None:
-            kws.setdefault("lw", errwidth)
-        else:
-            kws.setdefault("lw", mpl.rcParams["lines.linewidth"] * 1.8)
-
-        for at, (ci_low, ci_high), color in zip(at_group,
-                                                confint,
-                                                colors):
-            if self.orient == "v":
-                ax.plot([at, at], [ci_low, ci_high], color=color, **kws)
-                if capsize is not None:
-                    ax.plot([at - capsize / 2, at + capsize / 2],
-                            [ci_low, ci_low], color=color, **kws)
-                    ax.plot([at - capsize / 2, at + capsize / 2],
-                            [ci_high, ci_high], color=color, **kws)
-            else:
-                ax.plot([ci_low, ci_high], [at, at], color=color, **kws)
-                if capsize is not None:
-                    ax.plot([ci_low, ci_low],
-                            [at - capsize / 2, at + capsize / 2],
-                            color=color, **kws)
-                    ax.plot([ci_high, ci_high],
-                            [at - capsize / 2, at + capsize / 2],
-                            color=color, **kws)
-
-
-class _BarPlotter(_CategoricalStatPlotter):
-
-    def __init__(self, x, y, hue, data, order, hue_order,
-                 estimator, errorbar, n_boot, units, seed,
-                 orient, color, palette, saturation, width,
-                 errcolor, errwidth, capsize, dodge):
-        """Initialize the plotter."""
-        self.establish_variables(x, y, hue, data, orient,
-                                 order, hue_order, units)
-        self.establish_colors(color, palette, saturation)
-        self.estimate_statistic(estimator, errorbar, n_boot, seed)
-
-        self.dodge = dodge
-        self.width = width
-
-        self.errcolor = errcolor
-        self.errwidth = errwidth
-        self.capsize = capsize
-
-    def draw_bars(self, ax, kws):
-        """Draw the bars onto `ax`."""
-        # Get the right matplotlib function depending on the orientation
-        barfunc = ax.bar if self.orient == "v" else ax.barh
-        barpos = np.arange(len(self.statistic))
-
-        if self.plot_hues is None:
-
-            # Draw the bars
-            barfunc(barpos, self.statistic, self.width,
-                    color=self.colors, align="center", **kws)
-
-            # Draw the confidence intervals
-            errcolors = [self.errcolor] * len(barpos)
-            self.draw_confints(ax,
-                               barpos,
-                               self.confint,
-                               errcolors,
-                               self.errwidth,
-                               self.capsize)
-
-        else:
-
-            for j, hue_level in enumerate(self.hue_names):
-
-                # Draw the bars
-                offpos = barpos + self.hue_offsets[j]
-                barfunc(offpos, self.statistic[:, j], self.nested_width,
-                        color=self.colors[j], align="center",
-                        label=hue_level, **kws)
-
-                # Draw the confidence intervals
-                if self.confint.size:
-                    confint = self.confint[:, j]
-                    errcolors = [self.errcolor] * len(offpos)
-                    self.draw_confints(ax,
-                                       offpos,
-                                       confint,
-                                       errcolors,
-                                       self.errwidth,
-                                       self.capsize)
-
-    def plot(self, ax, bar_kws):
-        """Make the plot."""
-        self.draw_bars(ax, bar_kws)
-        self.annotate_axes(ax)
-        if self.orient == "h":
-            ax.invert_yaxis()
-
-
-class _PointPlotter(_CategoricalStatPlotter):
-
-    default_palette = "dark"
-
-    def __init__(self, x, y, hue, data, order, hue_order,
-                 estimator, errorbar, n_boot, units, seed,
-                 markers, linestyles, dodge, join, scale,
-                 orient, color, palette, errwidth, capsize, label):
-        """Initialize the plotter."""
-        self.establish_variables(x, y, hue, data, orient,
-                                 order, hue_order, units)
-        self.establish_colors(color, palette, 1)
-        self.estimate_statistic(estimator, errorbar, n_boot, seed)
-
-        # Override the default palette for single-color plots
-        if hue is None and color is None and palette is None:
-            self.colors = [color_palette()[0]] * len(self.colors)
-
-        # Don't join single-layer plots with different colors
-        if hue is None and palette is not None:
-            join = False
-
-        # Use a good default for `dodge=True`
-        if dodge is True and self.hue_names is not None:
-            dodge = .025 * len(self.hue_names)
-
-        # Make sure we have a marker for each hue level
-        if isinstance(markers, str):
-            markers = [markers] * len(self.colors)
-        self.markers = markers
-
-        # Make sure we have a line style for each hue level
-        if isinstance(linestyles, str):
-            linestyles = [linestyles] * len(self.colors)
-        self.linestyles = linestyles
-
-        # Set the other plot components
-        self.dodge = dodge
-        self.join = join
-        self.scale = scale
-        self.errwidth = errwidth
-        self.capsize = capsize
-        self.label = label
-
-    @property
-    def hue_offsets(self):
-        """Offsets relative to the center position for each hue level."""
-        if self.dodge:
-            offset = np.linspace(0, self.dodge, len(self.hue_names))
-            offset -= offset.mean()
-        else:
-            offset = np.zeros(len(self.hue_names))
-        return offset
-
-    def draw_points(self, ax):
-        """Draw the main data components of the plot."""
-        # Get the center positions on the categorical axis
-        pointpos = np.arange(len(self.statistic))
-
-        # Get the size of the plot elements
-        lw = mpl.rcParams["lines.linewidth"] * 1.8 * self.scale
-        mew = lw * .75
-        markersize = np.pi * np.square(lw) * 2
-
-        if self.plot_hues is None:
-
-            # Draw lines joining each estimate point
-            if self.join:
-                color = self.colors[0]
-                ls = self.linestyles[0]
-                if self.orient == "h":
-                    ax.plot(self.statistic, pointpos,
-                            color=color, ls=ls, lw=lw)
-                else:
-                    ax.plot(pointpos, self.statistic,
-                            color=color, ls=ls, lw=lw)
-
-            # Draw the confidence intervals
-            self.draw_confints(ax, pointpos, self.confint, self.colors,
-                               self.errwidth, self.capsize)
-
-            # Draw the estimate points
-            marker = self.markers[0]
-            colors = [mpl.colors.colorConverter.to_rgb(c) for c in self.colors]
-            if self.orient == "h":
-                x, y = self.statistic, pointpos
-            else:
-                x, y = pointpos, self.statistic
-            ax.scatter(x, y,
-                       linewidth=mew, marker=marker, s=markersize,
-                       facecolor=colors, edgecolor=colors, label=self.label)
-
-        else:
-
-            offsets = self.hue_offsets
-            for j, hue_level in enumerate(self.hue_names):
-
-                # Determine the values to plot for this level
-                statistic = self.statistic[:, j]
-
-                # Determine the position on the categorical and z axes
-                offpos = pointpos + offsets[j]
-                z = j + 1
-
-                # Draw lines joining each estimate point
-                if self.join:
-                    color = self.colors[j]
-                    ls = self.linestyles[j]
-                    if self.orient == "h":
-                        ax.plot(statistic, offpos, color=color,
-                                zorder=z, ls=ls, lw=lw)
-                    else:
-                        ax.plot(offpos, statistic, color=color,
-                                zorder=z, ls=ls, lw=lw)
-
-                # Draw the confidence intervals
-                if self.confint.size:
-                    confint = self.confint[:, j]
-                    errcolors = [self.colors[j]] * len(offpos)
-                    self.draw_confints(ax, offpos, confint, errcolors,
-                                       self.errwidth, self.capsize,
-                                       zorder=z)
-
-                # Draw the estimate points
-                n_points = len(remove_na(offpos))
-                marker = self.markers[j]
-                color = mpl.colors.colorConverter.to_rgb(self.colors[j])
-
-                if self.orient == "h":
-                    x, y = statistic, offpos
-                else:
-                    x, y = offpos, statistic
-
-                if not len(remove_na(statistic)):
-                    x = y = [np.nan] * n_points
-
-                ax.scatter(x, y, label=hue_level,
-                           facecolor=color, edgecolor=color,
-                           linewidth=mew, marker=marker, s=markersize,
-                           zorder=z)
-
-    def plot(self, ax):
-        """Make the plot."""
-        self.draw_points(ax)
-        self.annotate_axes(ax)
-        if self.orient == "h":
-            ax.invert_yaxis()
-
-
-class _CountPlotter(_BarPlotter):
-    require_numeric = False
-
-
-class _LVPlotter(_CategoricalPlotter):
-
-    def __init__(self, x, y, hue, data, order, hue_order,
-                 orient, color, palette, saturation,
-                 width, dodge, k_depth, linewidth, scale, outlier_prop,
-                 trust_alpha, showfliers=True):
-
-        self.width = width
-        self.dodge = dodge
-        self.saturation = saturation
-
-        k_depth_methods = ['proportion', 'tukey', 'trustworthy', 'full']
-        if not (k_depth in k_depth_methods or isinstance(k_depth, Number)):
-            msg = (f'k_depth must be one of {k_depth_methods} or a number, '
-                   f'but {k_depth} was passed.')
-            raise ValueError(msg)
-        self.k_depth = k_depth
-
-        if linewidth is None:
-            linewidth = mpl.rcParams["lines.linewidth"]
-        self.linewidth = linewidth
-
-        scales = ['linear', 'exponential', 'area']
-        if scale not in scales:
-            msg = f'scale must be one of {scales}, but {scale} was passed.'
-            raise ValueError(msg)
-        self.scale = scale
-
-        if ((outlier_prop > 1) or (outlier_prop <= 0)):
-            msg = f'outlier_prop {outlier_prop} not in range (0, 1]'
-            raise ValueError(msg)
-        self.outlier_prop = outlier_prop
-
-        if not 0 < trust_alpha < 1:
-            msg = f'trust_alpha {trust_alpha} not in range (0, 1)'
-            raise ValueError(msg)
-        self.trust_alpha = trust_alpha
-
-        self.showfliers = showfliers
-
-        self.establish_variables(x, y, hue, data, orient, order, hue_order)
-        self.establish_colors(color, palette, saturation)
+        self.establish_variables(x, y, hue, data, orient, order, hue_order)
+        self.establish_colors(color, palette, saturation)
 
     def _lv_box_ends(self, vals):
         """Get the number of data points and calculate `depth` of
@@ -1869,7 +1666,7 @@ class _LVPlotter(_CategoricalPlotter):
         for k, v in flier_default_kws.items():
             flier_kws.setdefault(k, v)
 
-        vert = self.orient == "v"
+        vert = self.orient == "x"
         x = positions[0]
         box_data = np.asarray(box_data)
 
@@ -2038,13 +1835,13 @@ class _LVPlotter(_CategoricalPlotter):
                                  line_kws=line_kws)
 
         # Autoscale the values axis to make sure all patches are visible
-        ax.autoscale_view(scalex=self.orient == "h", scaley=self.orient == "v")
+        ax.autoscale_view(scalex=self.orient == "y", scaley=self.orient == "x")
 
     def plot(self, ax, box_kws, flier_kws, line_kws):
         """Make the plot."""
         self.draw_letter_value_plot(ax, box_kws, flier_kws, line_kws)
         self.annotate_axes(ax)
-        if self.orient == "h":
+        if self.orient == "y":
             ax.invert_yaxis()
 
 
@@ -2064,23 +1861,23 @@ _categorical_docs = dict(
     .. note::
         By default, this function treats one of the variables as categorical
         and draws data at ordinal positions (0, 1, ... n) on the relevant axis.
-        This can be disabled with the `native_scale` parameter.
+        As of version 0.13.0, this can be disabled by setting `native_scale=True`.
 
-        See the :ref:`tutorial <categorical_tutorial>` for more information.\
+    See the :ref:`tutorial <categorical_tutorial>` for more information.\
     """),
 
     # Shared function parameters
     input_params=dedent("""\
-    x, y, hue : names of variables in ``data`` or vector data, optional
+    x, y, hue : names of variables in `data` or vector data
         Inputs for plotting long-form data. See examples for interpretation.\
     """),
     string_input_params=dedent("""\
-    x, y, hue : names of variables in ``data``
+    x, y, hue : names of variables in `data`
         Inputs for plotting long-form data. See examples for interpretation.\
     """),
     categorical_data=dedent("""\
-    data : DataFrame, array, or list of arrays, optional
-        Dataset for plotting. If ``x`` and ``y`` are absent, this is
+    data : DataFrame, Series, dict, array, or list of arrays
+        Dataset for plotting. If `x` and `y` are absent, this is
         interpreted as wide-form. Otherwise it is expected to be long-form.\
     """),
     long_form_data=dedent("""\
@@ -2089,94 +1886,149 @@ _categorical_docs = dict(
         to a variable, and each row should correspond to an observation.\
     """),
     order_vars=dedent("""\
-    order, hue_order : lists of strings, optional
+    order, hue_order : lists of strings
         Order to plot the categorical levels in; otherwise the levels are
         inferred from the data objects.\
     """),
     stat_api_params=dedent("""\
-    estimator : string or callable that maps vector -> scalar, optional
+    estimator : string or callable that maps vector -> scalar
         Statistical function to estimate within each categorical bin.
     errorbar : string, (string, number) tuple, callable or None
         Name of errorbar method (either "ci", "pi", "se", or "sd"), or a tuple
         with a method name and a level parameter, or a function that maps from a
         vector to a (min, max) interval, or None to hide errorbar.
-    n_boot : int, optional
+
+        .. versionadded:: v0.12.0
+    n_boot : int
         Number of bootstrap samples used to compute confidence intervals.
-    units : name of variable in ``data`` or vector data, optional
+    units : name of variable in `data` or vector data
         Identifier of sampling units, which will be used to perform a
         multilevel bootstrap and account for repeated measures design.
-    seed : int, numpy.random.Generator, or numpy.random.RandomState, optional
+    seed : int, `numpy.random.Generator`, or `numpy.random.RandomState`
         Seed or random number generator for reproducible bootstrapping.\
     """),
+    ci=dedent("""\
+    ci : float
+        Level of the confidence interval to show, in [0, 100].
+
+        .. deprecated:: v0.12.0
+            Use `errorbar=("ci", ...)`.\
+    """),
     orient=dedent("""\
-    orient : "v" | "h", optional
+    orient : "v" | "h" | "x" | "y"
         Orientation of the plot (vertical or horizontal). This is usually
         inferred based on the type of the input variables, but it can be used
         to resolve ambiguity when both `x` and `y` are numeric or when
-        plotting wide-form data.\
+        plotting wide-form data.
+
+        .. versionchanged:: v0.13.0
+            Added 'x'/'y' as options, equivalent to 'v'/'h'.\
     """),
     color=dedent("""\
-    color : matplotlib color, optional
+    color : matplotlib color
         Single color for the elements in the plot.\
     """),
     palette=dedent("""\
-    palette : palette name, list, or dict, optional
+    palette : palette name, list, dict, or :class:`matplotlib.colors.Colormap`
         Color palette that maps the hue variable. If the palette is a dictionary,
-        keys should be names of levels and values should be matplotlib colors.\
+        keys should be names of levels and values should be matplotlib colors.
+        The type/value will sometimes force a qualitative/quantitative mapping.\
     """),
     hue_norm=dedent("""\
     hue_norm : tuple or :class:`matplotlib.colors.Normalize` object
         Normalization in data units for colormap applied to the `hue`
-        variable when it is numeric. Not relevant if `hue` is categorical.\
+        variable when it is numeric. Not relevant if `hue` is categorical.
+
+        .. versionadded:: v0.12.0\
     """),
     saturation=dedent("""\
-    saturation : float, optional
-        Proportion of the original saturation to draw colors at. Large patches
-        often look better with slightly desaturated colors, but set this to
-        `1` if you want the plot colors to perfectly match the input color.\
+    saturation : float
+        Proportion of the original saturation to draw fill colors in. Large
+        patches often look better with desaturated colors, but set this to
+        `1` if you want the colors to perfectly match the input values.\
     """),
     capsize=dedent("""\
-    capsize : float, optional
-        Width of the "caps" on error bars.\
+    capsize : float
+        Width of the "caps" on error bars, relative to bar spacing.\
+    """),
+    errcolor=dedent("""\
+    errcolor : matplotlib color
+        Color used for the error bar lines.
+
+        .. deprecated:: 0.13.0
+            Use `err_kws={'color': ...}`.\
     """),
     errwidth=dedent("""\
-    errwidth : float, optional
-        Thickness of error bar lines (and caps).\
+    errwidth : float
+        Thickness of error bar lines (and caps), in points.
+
+        .. deprecated:: 0.13.0
+            Use `err_kws={'linewidth': ...}`.\
+    """),
+    fill=dedent("""\
+    fill : bool
+        If True, use a solid patch. Otherwise, draw as line art.
+
+        .. versionadded:: v0.13.0\
+    """),
+    gap=dedent("""\
+    gap : float
+        Shrink on the orient axis by this factor to add a gap between dodged elements.
+
+        .. versionadded:: 0.13.0\
     """),
     width=dedent("""\
-    width : float, optional
+    width : float
         Width of a full element when not using hue nesting, or width of all the
         elements for one level of the major grouping variable.\
     """),
     dodge=dedent("""\
-    dodge : bool, optional
+    dodge : bool
         When hue nesting is used, whether elements should be shifted along the
         categorical axis.\
     """),
     linewidth=dedent("""\
-    linewidth : float, optional
-        Width of the gray lines that frame the plot elements.\
+    linewidth : float
+        Width of the lines that frame the plot elements.\
+    """),
+    linecolor=dedent("""\
+    linecolor : color
+        Color to use for line elements, when `fill` is True.
+
+        .. versionadded:: v0.13.0\
     """),
     native_scale=dedent("""\
-    native_scale : bool, optional
+    native_scale : bool
         When True, numeric or datetime values on the categorical axis will maintain
-        their original scaling rather than being converted to fixed indices.\
+        their original scaling rather than being converted to fixed indices.
+
+        .. versionadded:: v0.13.0\
     """),
     formatter=dedent("""\
-    formatter : callable, optional
+    formatter : callable
         Function for converting categorical data into strings. Affects both grouping
-        and tick labels.\
+        and tick labels.
+
+        .. versionadded:: v0.13.0\
     """),
     legend=dedent("""\
-legend : "auto", "brief", "full", or False
-    How to draw the legend. If "brief", numeric `hue` and `size`
-    variables will be represented with a sample of evenly spaced values.
-    If "full", every group will get an entry in the legend. If "auto",
-    choose between brief or full representation based on number of levels.
-    If `False`, no legend data is added and no legend is drawn.
+    legend : "auto", "brief", "full", or False
+        How to draw the legend. If "brief", numeric `hue` and `size`
+        variables will be represented with a sample of evenly spaced values.
+        If "full", every group will get an entry in the legend. If "auto",
+        choose between brief or full representation based on number of levels.
+        If `False`, no legend data is added and no legend is drawn.
+
+        .. versionadded:: v0.13.0\
+    """),
+    err_kws=dedent("""\
+    err_kws : dict
+        Parameters of :class:`matplotlib.lines.Line2D`, for the error bar artists.
+
+        .. versionadded:: v0.13.0\
     """),
     ax_in=dedent("""\
-    ax : matplotlib Axes, optional
+    ax : matplotlib Axes
         Axes object to draw the plot onto, otherwise uses the current Axes.\
     """),
     ax_out=dedent("""\
@@ -2223,20 +2075,64 @@ _categorical_docs.update(_facet_docs)
 
 def boxplot(
     data=None, *, x=None, y=None, hue=None, order=None, hue_order=None,
-    orient=None, color=None, palette=None, saturation=.75, width=.8,
-    dodge=True, fliersize=5, linewidth=None, whis=1.5, ax=None,
-    **kwargs
+    orient=None, color=None, palette=None, saturation=.75, fill=True,
+    dodge="auto", width=.8, gap=0, whis=1.5, linecolor=None, linewidth=None,
+    fliersize=None, hue_norm=None, native_scale=False, formatter=None,
+    legend="auto", ax=None, **kwargs
 ):
 
-    plotter = _BoxPlotter(x, y, hue, data, order, hue_order,
-                          orient, color, palette, saturation,
-                          width, dodge, fliersize, linewidth)
+    p = _CategoricalPlotterNew(
+        data=data,
+        variables=_CategoricalPlotterNew.get_semantics(locals()),
+        order=order,
+        orient=orient,
+        require_numeric=False,
+        legend=legend,
+    )
 
     if ax is None:
         ax = plt.gca()
-    kwargs.update(dict(whis=whis))
 
-    plotter.plot(ax, kwargs)
+    if p.plot_data.empty:
+        return ax
+
+    if dodge == "auto":
+        # Needs to be before scale_categorical changes the coordinate series dtype
+        dodge = p._dodge_needed()
+
+    if p.var_types.get(p.orient) == "categorical" or not native_scale:
+        p.scale_categorical(p.orient, order=order, formatter=formatter)
+
+    p._attach(ax)
+
+    # Deprecations to remove in v0.14.0.
+    hue_order = p._palette_without_hue_backcompat(palette, hue_order)
+    palette, hue_order = p._hue_backcompat(color, palette, hue_order)
+
+    saturation = saturation if fill else 1
+    p.map_hue(palette=palette, order=hue_order, norm=hue_norm, saturation=saturation)
+    color = _default_color(
+        ax.fill_between, hue, color,
+        {k: v for k, v in kwargs.items() if k in ["c", "color", "fc", "facecolor"]},
+        saturation=saturation,
+    )
+
+    p.plot_boxes(
+        width=width,
+        dodge=dodge,
+        gap=gap,
+        fill=fill,
+        whis=whis,
+        color=color,
+        linecolor=linecolor,
+        linewidth=linewidth,
+        fliersize=fliersize,
+        plot_kws=kwargs,
+    )
+
+    p._add_axis_labels(ax)
+    p._adjust_cat_axis(ax, axis=p.orient)
+
     return ax
 
 
@@ -2250,7 +2146,7 @@ boxplot.__doc__ = dedent("""\
     except for points that are determined to be "outliers" using a method
     that is a function of the inter-quartile range.
 
-    {categorical_narrative}
+    {new_categorical_narrative}
 
     Parameters
     ----------
@@ -2261,15 +2157,22 @@ boxplot.__doc__ = dedent("""\
     {color}
     {palette}
     {saturation}
-    {width}
+    {fill}
     {dodge}
-    fliersize : float, optional
-        Size of the markers used to indicate outlier observations.
+    {width}
+    {gap}
+    whis : float or pair of floats
+        Paramater that controls whisker length. If scalar, whiskers are drawn
+        to the farthest datapoint within `whis * IQR` from the nearest hinge.
+        If a tuple, it is interpreted as percentiles that whiskers represent.
+    {linecolor}
     {linewidth}
-    whis : float, optional
-        Maximum length of the plot whiskers as proportion of the
-        interquartile range. Whiskers extend to the furthest datapoint
-        within that range. More extreme points are marked as outliers.
+    fliersize : float
+        Size of the markers used to indicate outlier observations.
+    {hue_norm}
+    {native_scale}
+    {formatter}
+    {legend}
     {ax_in}
     kwargs : key, value mappings
         Other keyword arguments are passed through to
@@ -2288,7 +2191,6 @@ boxplot.__doc__ = dedent("""\
 
     Examples
     --------
-
     .. include:: ../docstrings/boxplot.rst
 
     """).format(**_categorical_docs)
@@ -2296,86 +2198,181 @@ boxplot.__doc__ = dedent("""\
 
 def violinplot(
     data=None, *, x=None, y=None, hue=None, order=None, hue_order=None,
-    bw="scott", cut=2, scale="area", scale_hue=True, gridsize=100,
-    width=.8, inner="box", split=False, dodge=True, orient=None,
-    linewidth=None, color=None, palette=None, saturation=.75,
-    ax=None, **kwargs,
+    orient=None, color=None, palette=None, saturation=.75, fill=True,
+    inner="box", split=False, width=.8, dodge="auto", gap=0,
+    linewidth=None, linecolor=None, cut=2, gridsize=100,
+    bw_method="scott", bw_adjust=1, density_norm="area", common_norm=False,
+    hue_norm=None, formatter=None, native_scale=False, legend="auto",
+    scale=deprecated, scale_hue=deprecated, bw=deprecated,
+    inner_kws=None, ax=None, **kwargs,
 ):
 
-    plotter = _ViolinPlotter(x, y, hue, data, order, hue_order,
-                             bw, cut, scale, scale_hue, gridsize,
-                             width, inner, split, dodge, orient, linewidth,
-                             color, palette, saturation)
+    p = _CategoricalPlotterNew(
+        data=data,
+        variables=_CategoricalPlotterNew.get_semantics(locals()),
+        order=order,
+        orient=orient,
+        require_numeric=False,
+        legend=legend,
+    )
 
     if ax is None:
         ax = plt.gca()
 
-    plotter.plot(ax)
+    if p.plot_data.empty:
+        return ax
+
+    if dodge == "auto":
+        # Needs to be before scale_categorical changes the coordinate series dtype
+        dodge = p._dodge_needed()
+
+    if p.var_types.get(p.orient) == "categorical" or not native_scale:
+        p.scale_categorical(p.orient, order=order, formatter=formatter)
+
+    p._attach(ax)
+
+    # Deprecations to remove in v0.14.0.
+    hue_order = p._palette_without_hue_backcompat(palette, hue_order)
+    palette, hue_order = p._hue_backcompat(color, palette, hue_order)
+
+    saturation = saturation if fill else 1
+    p.map_hue(palette=palette, order=hue_order, norm=hue_norm, saturation=saturation)
+    color = _default_color(
+        ax.fill_between, hue, color,
+        {k: v for k, v in kwargs.items() if k in ["c", "color", "fc", "facecolor"]},
+        saturation=saturation,
+    )
+
+    density_norm, common_norm = p._scale_backcompat(
+        scale, scale_hue, density_norm, common_norm,
+    )
+
+    if bw is not deprecated:
+        msg = dedent(f"""\n
+        The `bw` parameter is deprecated in favor of `bw_method` and `bw_adjust`.
+        Setting `bw_method={bw!r}`, but please see the docs for the new parameters
+        and update your code. This will become an error in seaborn v0.15.0.
+        """)
+        warnings.warn(msg, FutureWarning, stacklevel=2)
+        bw_method = bw
+
+    kde_kws = dict(cut=cut, gridsize=gridsize, bw_method=bw_method, bw_adjust=bw_adjust)
+    inner_kws = {} if inner_kws is None else inner_kws.copy()
+
+    p.plot_violins(
+        width=width,
+        dodge=dodge,
+        gap=gap,
+        split=split,
+        color=color,
+        fill=fill,
+        linecolor=linecolor,
+        linewidth=linewidth,
+        inner=inner,
+        density_norm=density_norm,
+        common_norm=common_norm,
+        kde_kws=kde_kws,
+        inner_kws=inner_kws,
+        plot_kws=kwargs,
+    )
+
+    p._add_axis_labels(ax)
+    p._adjust_cat_axis(ax, axis=p.orient)
+
     return ax
 
 
 violinplot.__doc__ = dedent("""\
-    Draw a combination of boxplot and kernel density estimate.
+    Draw a patch representing a KDE and add observations or box plot statistics.
 
-    A violin plot plays a similar role as a box and whisker plot. It shows the
-    distribution of quantitative data across several levels of one (or more)
-    categorical variables such that those distributions can be compared. Unlike
-    a box plot, in which all of the plot components correspond to actual
-    datapoints, the violin plot features a kernel density estimation of the
-    underlying distribution.
+    A violin plot plays a similar role as a box-and-whisker plot. It shows the
+    distribution of data points after grouping by one (or more) variables.
+    Unlike a box plot, each violin is drawn using a kernel density estimate
+    of the underlying distribution.
 
-    This can be an effective and attractive way to show multiple distributions
-    of data at once, but keep in mind that the estimation procedure is
-    influenced by the sample size, and violins for relatively small samples
-    might look misleadingly smooth.
-
-    {categorical_narrative}
+    {new_categorical_narrative}
 
     Parameters
     ----------
     {categorical_data}
     {input_params}
     {order_vars}
-    bw : {{'scott', 'silverman', float}}, optional
-        Either the name of a reference rule or the scale factor to use when
-        computing the kernel bandwidth. The actual kernel size will be
-        determined by multiplying the scale factor by the standard deviation of
-        the data within each bin.
-    cut : float, optional
-        Distance, in units of bandwidth size, to extend the density past the
-        extreme datapoints. Set to 0 to limit the violin range within the range
-        of the observed data (i.e., to have the same effect as ``trim=True`` in
-        ``ggplot``.
-    scale : {{"area", "count", "width"}}, optional
-        The method used to scale the width of each violin. If ``area``, each
-        violin will have the same area. If ``count``, the width of the violins
-        will be scaled by the number of observations in that bin. If ``width``,
-        each violin will have the same width.
-    scale_hue : bool, optional
-        When nesting violins using a ``hue`` variable, this parameter
-        determines whether the scaling is computed within each level of the
-        major grouping variable (``scale_hue=True``) or across all the violins
-        on the plot (``scale_hue=False``).
-    gridsize : int, optional
-        Number of points in the discrete grid used to compute the kernel
-        density estimate.
-    {width}
-    inner : {{"box", "quartile", "point", "stick", None}}, optional
-        Representation of the datapoints in the violin interior. If ``box``,
-        draw a miniature boxplot. If ``quartiles``, draw the quartiles of the
-        distribution.  If ``point`` or ``stick``, show each underlying
-        datapoint. Using ``None`` will draw unadorned violins.
-    split : bool, optional
-        When using hue nesting with a variable that takes two levels, setting
-        ``split`` to True will draw half of a violin for each level. This can
-        make it easier to directly compare the distributions.
-    {dodge}
     {orient}
-    {linewidth}
     {color}
     {palette}
     {saturation}
+    {fill}
+    inner : {{"box", "quart", "point", "stick", None}}
+        Representation of the data in the violin interior. One of the following:
+
+        - `box`: draw a miniature box-and-whisker plot
+        - `quart`: show the quartiles of the data
+        - `point` or `stick`: show each observation
+    split : bool
+        Show an un-mirrored distribution, alternating sides when using `hue`.
+
+        .. versionchanged:: v0.13.0
+            Previously, this option required a `hue` variable with exactly two levels.
+    {width}
+    {dodge}
+    {gap}
+    {linewidth}
+    {linecolor}
+    cut : float
+        Distance, in units of bandwidth, to extend the density past extreme
+        datapoints. Set to 0 to limit the violin within the data range.
+    gridsize : int
+        Number of points in the discrete grid used to evaluate the KDE.
+    bw_method : {{"scott", "silverman", float}}
+        Either the name of a reference rule or the scale factor to use when
+        computing the kernel bandwidth. The actual kernel size will be
+        determined by multiplying the scale factor by the standard deviation of
+        the data within each group.
+
+        .. versionadded:: v0.13.0
+    bw_adjust: float
+        Factor that scales the bandwidth to use more or less smoothing.
+
+        .. versionadded:: v0.13.0
+    density_norm : {{"area", "count", "width"}}
+        Method that normalizes each density to determine the violin's width.
+        If `area`, each violin will have the same area. If `count`, the width
+        will be proportional to the number of observations. If `width`, each
+        violin will have the same width.
+
+        .. versionadded:: v0.13.0
+    common_norm : bool
+        When `True`, normalize the density across all violins.
+
+        .. versionadded:: v0.13.0
+    {hue_norm}
+    {formatter}
+    {native_scale}
+    {legend}
+    scale : {{"area", "count", "width"}}
+        .. deprecated:: v0.13.0
+            See `density_norm`.
+    scale_hue : bool
+        .. deprecated:: v0.13.0
+            See `common_norm`.
+    bw : {{'scott', 'silverman', float}}
+        .. deprecated:: v0.13.0
+            See `bw_method` and `bw_adjust`.
+    inner_kws : dict of key, value mappings
+        Keyword arguments for the "inner" plot, passed to one of:
+
+        - :class:`matplotlib.collections.LineCollection` (with `inner="stick"`)
+        - :meth:`matplotlib.axes.Axes.scatter` (with `inner="point"`)
+        - :meth:`matplotlib.axes.Axes.plot` (with `inner="quart"` or `kind="box"`)
+
+        Additionally, with `inner="box"`, the keywords `box_width`, `whis_width`,
+        and `marker` receive special handling for the components of the "box" plot.
+
+        .. versionadded:: v0.13.0
     {ax_in}
+    kwargs : key, value mappings
+        Keyword arguments for the violin patches, passsed through to
+        :meth:`matplotlib.axes.Axes.fill_between`.
 
     Returns
     -------
@@ -2390,7 +2387,6 @@ violinplot.__doc__ = dedent("""\
 
     Examples
     --------
-
     .. include:: ../docstrings/violinplot.rst
 
     """).format(**_categorical_docs)
@@ -2446,31 +2442,31 @@ boxenplot.__doc__ = dedent("""\
         All methods are detailed in Wickham's paper. Each makes different
         assumptions about the number of outliers and leverages different
         statistical properties. If "proportion", draw no more than
-        `outlier_prop` extreme observations. If "full", draw `log(n)+1` boxes.
+        `outlier_prop` extreme observations. If "full", draw `log(n) +1` boxes.
     {linewidth}
-    scale : {{"exponential", "linear", "area"}}, optional
+    scale : {{"exponential", "linear", "area"}}
         Method to use for the width of the letter value boxes. All give similar
         results visually. "linear" reduces the width by a constant linear
         factor, "exponential" uses the proportion of data not covered, "area"
         is proportional to the percentage of data covered.
-    outlier_prop : float, optional
+    outlier_prop : float
         Proportion of data believed to be outliers. Must be in the range
         (0, 1]. Used to determine the number of boxes to plot when
         `k_depth="proportion"`.
-    trust_alpha : float, optional
+    trust_alpha : float
         Confidence level for a box to be plotted. Used to determine the
         number of boxes to plot when `k_depth="trustworthy"`. Must be in the
         range (0, 1).
-    showfliers : bool, optional
+    showfliers : bool
         If False, suppress the plotting of outliers.
     {ax_in}
-    box_kws: dict, optional
+    box_kws: dict
         Keyword arguments for the box artists; passed to
         :class:`matplotlib.patches.Rectangle`.
-    line_kws: dict, optional
+    line_kws: dict
         Keyword arguments for the line denoting the median; passed to
         :meth:`matplotlib.axes.Axes.plot`.
-    flier_kws: dict, optional
+    flier_kws: dict
         Keyword arguments for the scatter denoting the outlier observations;
         passed to :meth:`matplotlib.axes.Axes.scatter`.
 
@@ -2512,11 +2508,15 @@ def stripplot(
     if ax is None:
         ax = plt.gca()
 
-    if p.var_types.get(p.cat_axis) == "categorical" or not native_scale:
-        p.scale_categorical(p.cat_axis, order=order, formatter=formatter)
+    if p.plot_data.empty:
+        return ax
+
+    if p.var_types.get(p.orient) == "categorical" or not native_scale:
+        p.scale_categorical(p.orient, order=order, formatter=formatter)
 
     p._attach(ax)
 
+    # Deprecations to remove in v0.14.0.
     hue_order = p._palette_without_hue_backcompat(palette, hue_order)
     palette, hue_order = p._hue_backcompat(color, palette, hue_order)
 
@@ -2528,10 +2528,10 @@ def stripplot(
     kwargs.setdefault("zorder", 3)
     size = kwargs.get("s", size)
 
-    kwargs.update(dict(
+    kwargs.update(
         s=size ** 2,
         edgecolor=edgecolor,
-        linewidth=linewidth)
+        linewidth=linewidth,
     )
 
     p.plot_strips(
@@ -2546,7 +2546,7 @@ def stripplot(
     # but maybe it's better out here? Alternatively, we have an open issue
     # suggesting that _attach could add default axes labels, which seems smart.
     p._add_axis_labels(ax)
-    p._adjust_cat_axis(ax, axis=p.cat_axis)
+    p._adjust_cat_axis(ax, axis=p.orient)
 
     return ax
 
@@ -2562,16 +2562,16 @@ stripplot.__doc__ = dedent("""\
 
     Parameters
     ----------
-    {input_params}
     {categorical_data}
+    {input_params}
     {order_vars}
-    jitter : float, ``True``/``1`` is special-cased, optional
+    jitter : float, ``True``/``1`` is special-cased
         Amount of jitter (only along the categorical axis) to apply. This
         can be useful when you have many points and they overlap, so that
         it is easier to see the distribution. You can specify the amount
         of jitter (half the width of the uniform random variable support),
         or just use ``True`` for a good default.
-    dodge : bool, optional
+    dodge : bool
         When using ``hue`` nesting, setting this to ``True`` will separate
         the strips for different hue levels along the categorical axis.
         Otherwise, the points for each level will be plotted on top of
@@ -2579,14 +2579,15 @@ stripplot.__doc__ = dedent("""\
     {orient}
     {color}
     {palette}
-    size : float, optional
+    size : float
         Radius of the markers, in points.
-    edgecolor : matplotlib color, "gray" is special-cased, optional
+    edgecolor : matplotlib color, "gray" is special-cased
         Color of the lines around each point. If you pass ``"gray"``, the
         brightness is determined by the color palette used for the body
         of the points. Note that `stripplot` has `linewidth=0` by default,
         so edge colors are only visible with nonzero line width.
     {linewidth}
+    {hue_norm}
     {native_scale}
     {formatter}
     {legend}
@@ -2634,14 +2635,18 @@ def swarmplot(
     if ax is None:
         ax = plt.gca()
 
-    if p.var_types.get(p.cat_axis) == "categorical" or not native_scale:
-        p.scale_categorical(p.cat_axis, order=order, formatter=formatter)
+    if p.plot_data.empty:
+        return ax
+
+    if p.var_types.get(p.orient) == "categorical" or not native_scale:
+        p.scale_categorical(p.orient, order=order, formatter=formatter)
 
     p._attach(ax)
 
     if not p.has_xy_data:
         return ax
 
+    # Deprecations to remove in v0.14.0.
     hue_order = p._palette_without_hue_backcompat(palette, hue_order)
     palette, hue_order = p._hue_backcompat(color, palette, hue_order)
 
@@ -2670,7 +2675,7 @@ def swarmplot(
     )
 
     p._add_axis_labels(ax)
-    p._adjust_cat_axis(ax, axis=p.cat_axis)
+    p._adjust_cat_axis(ax, axis=p.orient)
 
     return ax
 
@@ -2695,16 +2700,16 @@ swarmplot.__doc__ = dedent("""\
     {categorical_data}
     {input_params}
     {order_vars}
-    dodge : bool, optional
+    dodge : bool
         When using ``hue`` nesting, setting this to ``True`` will separate
         the strips for different hue levels along the categorical axis.
         Otherwise, the points for each level will be plotted in one swarm.
     {orient}
     {color}
     {palette}
-    size : float, optional
+    size : float
         Radius of the markers, in points.
-    edgecolor : matplotlib color, "gray" is special-cased, optional
+    edgecolor : matplotlib color, "gray" is special-cased
         Color of the lines around each point. If you pass ``"gray"``, the
         brightness is determined by the color palette used for the body
         of the points.
@@ -2739,10 +2744,10 @@ swarmplot.__doc__ = dedent("""\
 def barplot(
     data=None, *, x=None, y=None, hue=None, order=None, hue_order=None,
     estimator="mean", errorbar=("ci", 95), n_boot=1000, units=None, seed=None,
-    orient=None, color=None, palette=None, saturation=.75, width=.8,
-    errcolor=".26", errwidth=None, capsize=None, dodge=True, ci="deprecated",
-    ax=None,
-    **kwargs,
+    orient=None, color=None, palette=None, saturation=.75, fill=True, hue_norm=None,
+    width=.8, dodge="auto", gap=0, native_scale=False, formatter=None, legend="auto",
+    capsize=0, err_kws=None, ci=deprecated, errcolor=deprecated, errwidth=deprecated,
+    ax=None, **kwargs,
 ):
 
     errorbar = utils._deprecate_ci(errorbar, ci)
@@ -2752,39 +2757,72 @@ def barplot(
     if estimator is len:
         estimator = "size"
 
-    plotter = _BarPlotter(x, y, hue, data, order, hue_order,
-                          estimator, errorbar, n_boot, units, seed,
-                          orient, color, palette, saturation,
-                          width, errcolor, errwidth, capsize, dodge)
+    p = _CategoricalAggPlotter(
+        data=data,
+        variables=_CategoricalAggPlotter.get_semantics(locals()),
+        order=order,
+        orient=orient,
+        require_numeric=False,
+        legend=legend,
+    )
 
     if ax is None:
         ax = plt.gca()
 
-    plotter.plot(ax, kwargs)
+    if p.plot_data.empty:
+        return ax
+
+    if dodge == "auto":
+        # Needs to be before scale_categorical changes the coordinate series dtype
+        dodge = p._dodge_needed()
+
+    if p.var_types.get(p.orient) == "categorical" or not native_scale:
+        p.scale_categorical(p.orient, order=order, formatter=formatter)
+
+    p._attach(ax)
+
+    # Deprecations to remove in v0.14.0.
+    hue_order = p._palette_without_hue_backcompat(palette, hue_order)
+    palette, hue_order = p._hue_backcompat(color, palette, hue_order)
+
+    saturation = saturation if fill else 1
+    p.map_hue(palette=palette, order=hue_order, norm=hue_norm, saturation=saturation)
+    color = _default_color(ax.bar, hue, color, kwargs, saturation=saturation)
+
+    aggregator = EstimateAggregator(estimator, errorbar, n_boot=n_boot, seed=seed)
+    err_kws = {} if err_kws is None else _normalize_kwargs(err_kws, mpl.lines.Line2D)
+
+    # Deprecations to remove in v0.15.0.
+    err_kws, capsize = p._err_kws_backcompat(err_kws, errcolor, errwidth, capsize)
+
+    p.plot_bars(
+        aggregator=aggregator,
+        dodge=dodge,
+        width=width,
+        gap=gap,
+        color=color,
+        fill=fill,
+        capsize=capsize,
+        err_kws=err_kws,
+        plot_kws=kwargs,
+    )
+
+    p._add_axis_labels(ax)
+    p._adjust_cat_axis(ax, axis=p.orient)
+
     return ax
 
 
 barplot.__doc__ = dedent("""\
     Show point estimates and errors as rectangular bars.
 
-    A bar plot represents an estimate of central tendency for a numeric
-    variable with the height of each rectangle and provides some indication of
-    the uncertainty around that estimate using error bars. Bar plots include 0
-    in the quantitative axis range, and they are a good choice when 0 is a
-    meaningful value for the quantitative variable, and you want to make
-    comparisons against it.
-
-    For datasets where 0 is not a meaningful value, a point plot will allow you
-    to focus on differences between levels of one or more categorical
-    variables.
-
-    It is also important to keep in mind that a bar plot shows only the mean
-    (or other estimator) value, but in many cases it may be more informative to
-    show the distribution of values at each level of the categorical variables.
-    In that case, other approaches such as a box or violin plot may be more
-    appropriate.
+    A bar plot represents an aggregate or statistical estimate for a numeric
+    variable with the height of each rectangle and indicates the uncertainty
+    around that estimate using an error bar. Bar plots include 0 in the
+    axis range, and they are a good choice when 0 is a meaningful value
+    for the variable to take.
 
-    {categorical_narrative}
+    {new_categorical_narrative}
 
     Parameters
     ----------
@@ -2796,16 +2834,22 @@ barplot.__doc__ = dedent("""\
     {color}
     {palette}
     {saturation}
+    {fill}
+    {hue_norm}
     {width}
-    errcolor : matplotlib color
-        Color used for the error bar lines.
-    {errwidth}
-    {capsize}
     {dodge}
+    {gap}
+    {native_scale}
+    {formatter}
+    {legend}
+    {capsize}
+    {err_kws}
+    {ci}
+    {errcolor}
+    {errwidth}
     {ax_in}
     kwargs : key, value mappings
-        Other keyword arguments are passed through to
-        :meth:`matplotlib.axes.Axes.bar`.
+        Other parameters are passed through to :class:`matplotlib.patches.Rectangle`.
 
     Returns
     -------
@@ -2817,39 +2861,92 @@ barplot.__doc__ = dedent("""\
     {pointplot}
     {catplot}
 
+    Notes
+    -----
+
+    For datasets where 0 is not a meaningful value, a :func:`pointplot` will
+    allow you to focus on differences between levels of one or more categorical
+    variables.
+
+    It is also important to keep in mind that a bar plot shows only the mean (or
+    other aggregate) value, but it is often more informative to show the
+    distribution of values at each level of the categorical variables. In those
+    cases, approaches such as a :func:`boxplot` or :func:`violinplot` may be
+    more appropriate.
+
     Examples
     --------
-
     .. include:: ../docstrings/barplot.rst
 
-
     """).format(**_categorical_docs)
 
 
 def pointplot(
     data=None, *, x=None, y=None, hue=None, order=None, hue_order=None,
     estimator="mean", errorbar=("ci", 95), n_boot=1000, units=None, seed=None,
-    markers="o", linestyles="-", dodge=False, join=True, scale=1,
-    orient=None, color=None, palette=None, errwidth=None, ci="deprecated",
-    capsize=None, label=None, ax=None,
+    color=None, palette=None, hue_norm=None, markers=default, linestyles=default,
+    dodge=False, native_scale=False, orient=None, capsize=0,
+    formatter=None, legend="auto", err_kws=None,
+    ci=deprecated, errwidth=deprecated, join=deprecated, scale=deprecated,
+    ax=None,
+    **kwargs,
 ):
 
     errorbar = utils._deprecate_ci(errorbar, ci)
 
-    plotter = _PointPlotter(x, y, hue, data, order, hue_order,
-                            estimator, errorbar, n_boot, units, seed,
-                            markers, linestyles, dodge, join, scale,
-                            orient, color, palette, errwidth, capsize, label)
+    p = _CategoricalAggPlotter(
+        data=data,
+        variables=_CategoricalAggPlotter.get_semantics(locals()),
+        order=order,
+        orient=orient,
+        require_numeric=False,
+        legend=legend,
+    )
 
     if ax is None:
         ax = plt.gca()
 
-    plotter.plot(ax)
+    if p.plot_data.empty:
+        return ax
+
+    if p.var_types.get(p.orient) == "categorical" or not native_scale:
+        p.scale_categorical(p.orient, order=order, formatter=formatter)
+
+    p._attach(ax)
+
+    # Deprecations to remove in v0.14.0.
+    hue_order = p._palette_without_hue_backcompat(palette, hue_order)
+    palette, hue_order = p._hue_backcompat(color, palette, hue_order)
+
+    p.map_hue(palette=palette, order=hue_order, norm=hue_norm)
+    color = _default_color(ax.plot, hue, color, kwargs)
+
+    aggregator = EstimateAggregator(estimator, errorbar, n_boot=n_boot, seed=seed)
+    err_kws = {} if err_kws is None else _normalize_kwargs(err_kws, mpl.lines.Line2D)
+
+    # Deprecations to remove in v0.15.0.
+    p._point_kwargs_backcompat(scale, join, kwargs)
+    err_kws, capsize = p._err_kws_backcompat(err_kws, None, errwidth, capsize)
+
+    p.plot_points(
+        aggregator=aggregator,
+        markers=markers,
+        linestyles=linestyles,
+        dodge=dodge,
+        color=color,
+        capsize=capsize,
+        err_kws=err_kws,
+        plot_kws=kwargs,
+    )
+
+    p._add_axis_labels(ax)
+    p._adjust_cat_axis(ax, axis=p.orient)
+
     return ax
 
 
 pointplot.__doc__ = dedent("""\
-    Show point estimates and errors using dot marks.
+    Show point estimates and errors using lines with markers.
 
     A point plot represents an estimate of central tendency for a numeric
     variable by the position of the dot and provides some indication of the
@@ -2864,13 +2961,7 @@ pointplot.__doc__ = dedent("""\
     easier for the eyes than comparing the heights of several groups of points
     or bars.
 
-    It is important to keep in mind that a point plot shows only the mean (or
-    other estimator) value, but in many cases it may be more informative to
-    show the distribution of values at each level of the categorical variables.
-    In that case, other approaches such as a box or violin plot may be more
-    appropriate.
-
-    {categorical_narrative}
+    {new_categorical_narrative}
 
     Parameters
     ----------
@@ -2878,26 +2969,38 @@ pointplot.__doc__ = dedent("""\
     {input_params}
     {order_vars}
     {stat_api_params}
-    markers : string or list of strings, optional
-        Markers to use for each of the ``hue`` levels.
-    linestyles : string or list of strings, optional
-        Line styles to use for each of the ``hue`` levels.
-    dodge : bool or float, optional
+    {color}
+    {palette}
+    markers : string or list of strings
+        Markers to use for each of the `hue` levels.
+    linestyles : string or list of strings
+        Line styles to use for each of the `hue` levels.
+    dodge : bool or float
         Amount to separate the points for each level of the ``hue`` variable
         along the categorical axis.
-    join : bool, optional
-        If ``True``, lines will be drawn between point estimates at the same
-        ``hue`` level.
-    scale : float, optional
-        Scale factor for the plot elements.
+    {native_scale}
     {orient}
-    {color}
-    {palette}
-    {errwidth}
     {capsize}
-    label : string, optional
-        Label to represent the plot in a legend, only relevant when not using `hue`.
+    {formatter}
+    {legend}
+    {err_kws}
+    {ci}
+    {errwidth}
+    join : bool
+        If `True`, draw lines will be drawn between point estimates.
+
+        .. deprecated:: v0.13.0
+            Set `linestyle="none"` to remove the lines between the points.
+    scale : float
+        Scale factor for the plot elements.
+
+        .. deprecated:: v0.13.0
+            Control element sizes with :class:`matplotlib.lines.Line2D` parameters.
     {ax_in}
+    kwargs : key, value mappings
+        Other parameters are passed through to :class:`matplotlib.lines.Line2D`.
+
+        .. versionadded:: v0.13.0
 
     Returns
     -------
@@ -2908,9 +3011,16 @@ pointplot.__doc__ = dedent("""\
     {barplot}
     {catplot}
 
+    Notes
+    -----
+    It is important to keep in mind that a point plot shows only the mean (or
+    other estimator) value, but in many cases it may be more informative to
+    show the distribution of values at each level of the categorical variables.
+    In that case, other approaches such as a box or violin plot may be more
+    appropriate.
+
     Examples
     --------
-
     .. include:: ../docstrings/pointplot.rst
 
     """).format(**_categorical_docs)
@@ -2918,41 +3028,79 @@ pointplot.__doc__ = dedent("""\
 
 def countplot(
     data=None, *, x=None, y=None, hue=None, order=None, hue_order=None,
-    orient=None, color=None, palette=None, saturation=.75, width=.8,
-    dodge=True, ax=None, **kwargs
+    orient=None, color=None, palette=None, saturation=.75, fill=True, hue_norm=None,
+    stat="count", width=.8, dodge="auto", gap=0, native_scale=False, formatter=None,
+    legend="auto", ax=None, **kwargs
 ):
 
-    estimator = "size"
-    errorbar = None
-    n_boot = 0
-    units = None
-    seed = None
-    errcolor = None
-    errwidth = None
-    capsize = None
-
     if x is None and y is not None:
-        orient = "h"
-        x = y
-    elif y is None and x is not None:
-        orient = "v"
-        y = x
+        orient = "y"
+        x = 1
+    elif x is not None and y is None:
+        orient = "x"
+        y = 1
     elif x is not None and y is not None:
-        raise ValueError("Cannot pass values for both `x` and `y`")
+        raise TypeError("Cannot pass values for both `x` and `y`.")
 
-    plotter = _CountPlotter(
-        x, y, hue, data, order, hue_order,
-        estimator, errorbar, n_boot, units, seed,
-        orient, color, palette, saturation,
-        width, errcolor, errwidth, capsize, dodge
+    p = _CategoricalAggPlotter(
+        data=data,
+        variables=_CategoricalAggPlotter.get_semantics(locals()),
+        order=order,
+        orient=orient,
+        require_numeric=False,
+        legend=legend,
     )
 
-    plotter.value_label = "count"
-
     if ax is None:
         ax = plt.gca()
 
-    plotter.plot(ax, kwargs)
+    if p.plot_data.empty:
+        return ax
+
+    if dodge == "auto":
+        # Needs to be before scale_categorical changes the coordinate series dtype
+        dodge = p._dodge_needed()
+
+    if p.var_types.get(p.orient) == "categorical" or not native_scale:
+        p.scale_categorical(p.orient, order=order, formatter=formatter)
+
+    p._attach(ax)
+
+    # Deprecations to remove in v0.14.0.
+    hue_order = p._palette_without_hue_backcompat(palette, hue_order)
+    palette, hue_order = p._hue_backcompat(color, palette, hue_order)
+
+    saturation = saturation if fill else 1
+    p.map_hue(palette=palette, order=hue_order, norm=hue_norm, saturation=saturation)
+    color = _default_color(ax.bar, hue, color, kwargs, saturation)
+
+    count_axis = {"x": "y", "y": "x"}[p.orient]
+    if p.input_format == "wide":
+        p.plot_data[count_axis] = 1
+
+    _check_argument("stat", ["count", "percent", "probability", "proportion"], stat)
+    p.variables[count_axis] = stat
+    if stat != "count":
+        denom = 100 if stat == "percent" else 1
+        p.plot_data[count_axis] /= len(p.plot_data) / denom
+
+    aggregator = EstimateAggregator("sum", errorbar=None)
+
+    p.plot_bars(
+        aggregator=aggregator,
+        dodge=dodge,
+        width=width,
+        gap=gap,
+        color=color,
+        fill=fill,
+        capsize=0,
+        err_kws={},
+        plot_kws=kwargs,
+    )
+
+    p._add_axis_labels(ax)
+    p._adjust_cat_axis(ax, axis=p.orient)
+
     return ax
 
 
@@ -2963,10 +3111,10 @@ countplot.__doc__ = dedent("""\
     of quantitative, variable. The basic API and options are identical to those
     for :func:`barplot`, so you can compare counts across nested variables.
 
-    Note that the newer :func:`histplot` function offers more functionality, although
-    its default behavior is somewhat different.
+    Note that :func:`histplot` function offers similar functionality with additional
+    features (e.g. bar stacking), although its default behavior is somewhat different.
 
-    {categorical_narrative}
+    {new_categorical_narrative}
 
     Parameters
     ----------
@@ -2977,11 +3125,20 @@ countplot.__doc__ = dedent("""\
     {color}
     {palette}
     {saturation}
+    {hue_norm}
+    stat : {{'count', 'percent', 'proportion', 'probability'}}
+        Statistic to compute; when not `'count'`, bar heights will be normalized so that
+        they sum to 100 (for `'percent'`) or 1 (otherwise) across the plot.
+
+        .. versionadded:: v0.13.0
+    {width}
     {dodge}
+    {native_scale}
+    {formatter}
+    {legend}
     {ax_in}
     kwargs : key, value mappings
-        Other keyword arguments are passed through to
-        :meth:`matplotlib.axes.Axes.bar`.
+        Other parameters are passed through to :class:`matplotlib.patches.Rectangle`.
 
     Returns
     -------
@@ -2989,6 +3146,7 @@ countplot.__doc__ = dedent("""\
 
     See Also
     --------
+    histplot : Bin and count observations with additional options.
     {barplot}
     {catplot}
 
@@ -2996,7 +3154,6 @@ countplot.__doc__ = dedent("""\
     --------
 
     .. include:: ../docstrings/countplot.rst
-
     """).format(**_categorical_docs)
 
 
@@ -3025,12 +3182,30 @@ def catplot(
         warnings.warn(msg, UserWarning)
         kwargs.pop("ax")
 
-    refactored_kinds = ["strip", "swarm"]
+    refactored_kinds = ["strip", "swarm", "point", "bar", "count", "box", "violin"]
+    desaturated_kinds = ["bar", "count", "box", "violin"]
+    undodged_kinds = ["strip", "swarm", "point"]
+
     if kind in refactored_kinds:
 
-        p = _CategoricalFacetPlotter(
+        if kind in ["bar", "point", "count"]:
+            Plotter = _CategoricalAggFacetPlotter
+        else:
+            Plotter = _CategoricalFacetPlotter
+
+        if kind == "count":
+            if x is None and y is not None:
+                orient = "y"
+                x = 1
+            elif x is not None and y is None:
+                orient = "x"
+                y = 1
+            elif x is not None and y is not None:
+                raise ValueError("Cannot pass values for both `x` and `y`.")
+
+        p = Plotter(
             data=data,
-            variables=_CategoricalFacetPlotter.get_semantics(locals()),
+            variables=Plotter.get_semantics(locals()),
             order=order,
             orient=orient,
             require_numeric=False,
@@ -3068,33 +3243,46 @@ def catplot(
         # happen or if disabling that is the cleaner solution.
         has_xy_data = p.has_xy_data
 
-        if not native_scale or p.var_types[p.cat_axis] == "categorical":
-            p.scale_categorical(p.cat_axis, order=order, formatter=formatter)
+        if not native_scale or p.var_types[p.orient] == "categorical":
+            p.scale_categorical(p.orient, order=order, formatter=formatter)
 
         p._attach(g)
 
         if not has_xy_data:
             return g
 
+        # Deprecations to remove in v0.14.0.
         hue_order = p._palette_without_hue_backcompat(palette, hue_order)
         palette, hue_order = p._hue_backcompat(color, palette, hue_order)
-        p.map_hue(palette=palette, order=hue_order, norm=hue_norm)
+
+        saturation = kwargs.pop(
+            "saturation",
+            0.75 if kind in desaturated_kinds and kwargs.get("fill", True) else 1
+        )
+        p.map_hue(
+            palette=palette, order=hue_order, norm=hue_norm, saturation=saturation
+        )
 
         # Set a default color
         # Otherwise each artist will be plotted separately and trip the color cycle
-        if hue is None and color is None:
-            color = "C0"
+        if hue is None:
+            color = "C0" if color is None else color
+            if saturation < 1:
+                color = desaturate(color, saturation)
+        edgecolor = kwargs.pop("edgecolor", "gray")  # XXX TODO default
+
+        width = kwargs.pop("width", 0.8)
+        dodge = kwargs.pop("dodge", False if kind in undodged_kinds else "auto")
+        if dodge == "auto":
+            dodge = p._dodge_needed()
 
         if kind == "strip":
 
             # TODO get these defaults programmatically?
             jitter = kwargs.pop("jitter", True)
-            dodge = kwargs.pop("dodge", False)
-            edgecolor = kwargs.pop("edgecolor", "gray")  # XXX TODO default
-
-            plot_kws = kwargs.copy()
 
             # XXX Copying possibly bad default decisions from original code for now
+            plot_kws = kwargs.copy()
             plot_kws.setdefault("zorder", 3)
             plot_kws.setdefault("s", plot_kws.pop("size", 5) ** 2)
             plot_kws.setdefault("linewidth", 0)
@@ -3110,13 +3298,10 @@ def catplot(
         elif kind == "swarm":
 
             # TODO get these defaults programmatically?
-            dodge = kwargs.pop("dodge", False)
-            edgecolor = kwargs.pop("edgecolor", "gray")  # XXX TODO default
             warn_thresh = kwargs.pop("warn_thresh", .05)
 
-            plot_kws = kwargs.copy()
-
             # XXX Copying possibly bad default decisions from original code for now
+            plot_kws = kwargs.copy()
             plot_kws.setdefault("zorder", 3)
             plot_kws.setdefault("s", plot_kws.pop("size", 5) ** 2)
 
@@ -3131,24 +3316,186 @@ def catplot(
                 plot_kws=plot_kws,
             )
 
-        # XXX best way to do this housekeeping?
+        elif kind == "box":
+
+            plot_kws = kwargs.copy()
+            gap = plot_kws.pop("gap", 0)
+            fill = plot_kws.pop("fill", True)
+            whis = plot_kws.pop("whis", 1.5)
+            linecolor = plot_kws.pop("linecolor", None)
+            linewidth = plot_kws.pop("linewidth", None)
+            fliersize = plot_kws.pop("fliersize", 5)
+
+            p.plot_boxes(
+                width=width,
+                dodge=dodge,
+                gap=gap,
+                fill=fill,
+                whis=whis,
+                color=color,
+                linecolor=linecolor,
+                linewidth=linewidth,
+                fliersize=fliersize,
+                plot_kws=plot_kws,
+            )
+
+        elif kind == "violin":
+
+            plot_kws = kwargs.copy()
+            gap = plot_kws.pop("gap", 0)
+            fill = plot_kws.pop("fill", True)
+            split = plot_kws.pop("split", False)
+            inner = plot_kws.pop("inner", "box")
+            density_norm = plot_kws.pop("density_norm", "area")
+            common_norm = plot_kws.pop("common_norm", False)
+
+            scale = plot_kws.pop("scale", deprecated)
+            scale_hue = plot_kws.pop("scale_hue", deprecated)
+            density_norm, common_norm = p._scale_backcompat(
+                scale, scale_hue, density_norm, common_norm,
+            )
+
+            kde_kws = dict(
+                cut=plot_kws.pop("cut", 2),
+                gridsize=plot_kws.pop("gridsize", 100),
+                bw_method=plot_kws.pop("bw_method", "scott"),
+                bw_adjust=plot_kws.pop("bw_adjust", 1),
+            )
+            bw = plot_kws.pop("bw", deprecated)
+            msg = dedent(f"""\n
+            The `bw` parameter is deprecated in favor of `bw_method` and `bw_adjust`.
+            Setting `bw_method={bw!r}`, but please see the docs for the new parameters
+            and update your code. This will become an error in seaborn v0.15.0.
+            """)
+            if bw is not deprecated:
+                warnings.warn(msg, FutureWarning, stacklevel=2)
+                kde_kws["bw_method"] = bw
+
+            inner_kws = plot_kws.pop("inner_kws", {}).copy()
+            linecolor = plot_kws.pop("linecolor", None)
+            linewidth = plot_kws.pop("linewidth", None)
+
+            p.plot_violins(
+                width=width,
+                dodge=dodge,
+                gap=gap,
+                split=split,
+                color=color,
+                fill=fill,
+                linecolor=linecolor,
+                linewidth=linewidth,
+                inner=inner,
+                density_norm=density_norm,
+                common_norm=common_norm,
+                kde_kws=kde_kws,
+                inner_kws=inner_kws,
+                plot_kws=plot_kws,
+            )
+
+        elif kind == "point":
+
+            aggregator = EstimateAggregator(
+                estimator, errorbar, n_boot=n_boot, seed=seed
+            )
+
+            markers = kwargs.pop("markers", default)
+            linestyles = kwargs.pop("linestyles", default)
+
+            # Deprecations to remove in v0.15.0.
+            # TODO Uncomment when removing deprecation backcompat
+            # capsize = kwargs.pop("capsize", 0)
+            # err_kws = _normalize_kwargs(kwargs.pop("err_kws", {}), mpl.lines.Line2D)
+            p._point_kwargs_backcompat(
+                kwargs.pop("scale", deprecated),
+                kwargs.pop("join", deprecated),
+                kwargs
+            )
+            err_kws, capsize = p._err_kws_backcompat(
+                _normalize_kwargs(kwargs.pop("err_kws", {}), mpl.lines.Line2D),
+                None,
+                errwidth=kwargs.pop("errwidth", deprecated),
+                capsize=kwargs.pop("capsize", 0),
+            )
+
+            p.plot_points(
+                aggregator=aggregator,
+                markers=markers,
+                linestyles=linestyles,
+                dodge=dodge,
+                color=color,
+                capsize=capsize,
+                err_kws=err_kws,
+                plot_kws=kwargs,
+            )
+
+        elif kind == "bar":
+
+            aggregator = EstimateAggregator(
+                estimator, errorbar, n_boot=n_boot, seed=seed
+            )
+            err_kws, capsize = p._err_kws_backcompat(
+                _normalize_kwargs(kwargs.pop("err_kws", {}), mpl.lines.Line2D),
+                errcolor=kwargs.pop("errcolor", deprecated),
+                errwidth=kwargs.pop("errwidth", deprecated),
+                capsize=kwargs.pop("capsize", 0),
+            )
+            gap = kwargs.pop("gap", 0)
+            fill = kwargs.pop("fill", True)
+
+            p.plot_bars(
+                aggregator=aggregator,
+                dodge=dodge,
+                width=width,
+                gap=gap,
+                color=color,
+                fill=fill,
+                capsize=capsize,
+                err_kws=err_kws,
+                plot_kws=kwargs,
+            )
+
+        elif kind == "count":
+
+            aggregator = EstimateAggregator("sum", errorbar=None)
+
+            count_axis = {"x": "y", "y": "x"}[p.orient]
+            p.plot_data[count_axis] = 1
+
+            stat_options = ["count", "percent", "probability", "proportion"]
+            stat = _check_argument("stat", stat_options, kwargs.pop("stat", "count"))
+            p.variables[count_axis] = stat
+            if stat != "count":
+                denom = 100 if stat == "percent" else 1
+                p.plot_data[count_axis] /= len(p.plot_data) / denom
+
+            gap = kwargs.pop("gap", 0)
+            fill = kwargs.pop("fill", True)
+
+            p.plot_bars(
+                aggregator=aggregator,
+                dodge=dodge,
+                width=width,
+                gap=gap,
+                color=color,
+                fill=fill,
+                capsize=0,
+                err_kws={},
+                plot_kws=kwargs,
+            )
+
         for ax in g.axes.flat:
-            p._adjust_cat_axis(ax, axis=p.cat_axis)
+            p._adjust_cat_axis(ax, axis=p.orient)
 
-        g.set_axis_labels(
-            p.variables.get("x", None),
-            p.variables.get("y", None),
-        )
+        g.set_axis_labels(p.variables.get("x"), p.variables.get("y"))
         g.set_titles()
         g.tight_layout()
 
-        # XXX Hack to get the legend data in the right place
         for ax in g.axes.flat:
             g._update_legend_data(ax)
             ax.legend_ = None
 
-        if legend and (hue is not None) and (hue not in [x, row, col]):
-            g.add_legend(title=hue, label_order=hue_order)
+        if legend and "hue" in p.variables:
+            g.add_legend(title=p.variables.get("hue"), label_order=hue_order)
 
         return g
 
@@ -3164,9 +3511,9 @@ def catplot(
     # correctly in the case of a count plot
     if kind == "count":
         if x is None and y is not None:
-            x_, y_, orient = y, y, "h"
+            x_, y_, orient = y, y, "y"
         elif y is None and x is not None:
-            x_, y_, orient = x, x, "v"
+            x_, y_, orient = x, x, "x"
         else:
             raise ValueError("Either `x` or `y` must be None for kind='count'")
     else:
@@ -3174,21 +3521,14 @@ def catplot(
 
     # Determine the order for the whole dataset, which will be used in all
     # facets to ensure representation of all data in the final plot
-    plotter_class = {
-        "box": _BoxPlotter,
-        "violin": _ViolinPlotter,
-        "boxen": _LVPlotter,
-        "bar": _BarPlotter,
-        "point": _PointPlotter,
-        "count": _CountPlotter,
-    }[kind]
+    plotter_class = {"boxen": _LVPlotter}[kind]
     p = _CategoricalPlotter()
     p.require_numeric = plotter_class.require_numeric
     p.establish_variables(x_, y_, hue, data, orient, order, hue_order)
     if (
         order is not None
-        or (sharex and p.orient == "v")
-        or (sharey and p.orient == "h")
+        or (sharex and p.orient == "x")
+        or (sharey and p.orient == "y")
     ):
         # Sync categorical axis between facets to have the same categories
         order = p.group_names
@@ -3197,9 +3537,9 @@ def catplot(
             "Setting `{}=False` with `color=None` may cause different levels of the "
             "`{}` variable to share colors. This will change in a future version."
         )
-        if not sharex and p.orient == "v":
+        if not sharex and p.orient == "x":
             warnings.warn(msg.format("sharex", "x"), UserWarning)
-        if not sharey and p.orient == "h":
+        if not sharey and p.orient == "y":
             warnings.warn(msg.format("sharey", "y"), UserWarning)
 
     hue_order = p.hue_names
@@ -3230,31 +3570,19 @@ def catplot(
     )
     plot_kws.update(kwargs)
 
-    if kind in ["bar", "point"]:
-        errorbar = utils._deprecate_ci(errorbar, ci)
-        plot_kws.update(
-            estimator=estimator, errorbar=errorbar,
-            n_boot=n_boot, units=units, seed=seed,
-        )
-
     # Initialize the facets
     g = FacetGrid(**facet_kws)
 
     # Draw the plot onto the facets
+    if not plot_kws.get("order"):
+        plot_kws.pop("order", None)
     g.map_dataframe(plot_func, x=x, y=y, hue=hue, **plot_kws)
 
-    if p.orient == "h":
+    if p.orient == "y":
         g.set_axis_labels(p.value_label, p.group_label)
     else:
         g.set_axis_labels(p.group_label, p.value_label)
 
-    # Special case axis labels for a count type plot
-    if kind == "count":
-        if x is None:
-            g.set_axis_labels(x_var="count")
-        if y is None:
-            g.set_axis_labels(y_var="count")
-
     if legend and (hue is not None) and (hue not in [x, row, col]):
         hue_order = list(map(utils.to_utf8, hue_order))
         g.add_legend(title=hue, label_order=hue_order)
@@ -3303,17 +3631,17 @@ catplot.__doc__ = dedent("""\
     ----------
     {long_form_data}
     {string_input_params}
-    row, col : names of variables in `data`, optional
+    row, col : names of variables in `data`
         Categorical variables that will determine the faceting of the grid.
     {col_wrap}
     {stat_api_params}
     {order_vars}
-    row_order, col_order : lists of strings, optional
+    row_order, col_order : lists of strings
         Order to organize the rows and/or columns of the grid in, otherwise the
         orders are inferred from the data objects.
     {height}
     {aspect}
-    kind : str, optional
+    kind : str
         The kind of plot to draw, corresponds to the name of a categorical
         axes-level plotting function. Options are: "strip", "swarm", "box", "violin",
         "boxen", "point", "bar", or "count".
@@ -3323,13 +3651,13 @@ catplot.__doc__ = dedent("""\
     {color}
     {palette}
     {hue_norm}
-    legend : str or bool, optional
+    legend : str or bool
         Set to `False` to disable the legend. With `strip` or `swarm` plots,
         this also accepts a string, as described in the axes-level docstrings.
     {legend_out}
     {share_xy}
     {margin_titles}
-    facet_kws : dict, optional
+    facet_kws : dict
         Dictionary of other keyword arguments to pass to :class:`FacetGrid`.
     kwargs : key, value pairings
         Other keyword arguments are passed through to the underlying plotting
@@ -3351,9 +3679,7 @@ catplot.__doc__ = dedent("""\
 
 class Beeswarm:
     """Modifies a scatterplot artist to show a beeswarm plot."""
-    def __init__(self, orient="v", width=0.8, warn_thresh=.05):
-
-        # XXX should we keep the orient parameterization or specify the swarm axis?
+    def __init__(self, orient="x", width=0.8, warn_thresh=.05):
 
         self.orient = orient
         self.width = width
@@ -3370,7 +3696,7 @@ class Beeswarm:
         orig_xy_data = points.get_offsets()
 
         # Reset the categorical positions to the center line
-        cat_idx = 1 if self.orient == "h" else 0
+        cat_idx = 1 if self.orient == "y" else 0
         orig_xy_data[:, cat_idx] = center
 
         # Transform the data coordinates to point coordinates.
@@ -3380,7 +3706,7 @@ class Beeswarm:
         orig_xy = ax.transData.transform(orig_xy_data)
 
         # Order the variables so that x is the categorical axis
-        if self.orient == "h":
+        if self.orient == "y":
             orig_xy = orig_xy[:, [1, 0]]
 
         # Add a column with each point's radius
@@ -3400,23 +3726,22 @@ class Beeswarm:
         new_xyr[sorter] = self.beeswarm(orig_xyr)
 
         # Transform the point coordinates back to data coordinates
-        if self.orient == "h":
+        if self.orient == "y":
             new_xy = new_xyr[:, [1, 0]]
         else:
             new_xy = new_xyr[:, :2]
         new_x_data, new_y_data = ax.transData.inverted().transform(new_xy).T
 
-        swarm_axis = {"h": "y", "v": "x"}[self.orient]
-        log_scale = getattr(ax, f"get_{swarm_axis}scale")() == "log"
+        log_scale = getattr(ax, f"get_{self.orient}scale")() == "log"
 
         # Add gutters
-        if self.orient == "h":
+        if self.orient == "y":
             self.add_gutters(new_y_data, center, log_scale=log_scale)
         else:
             self.add_gutters(new_x_data, center, log_scale=log_scale)
 
         # Reposition the points so they do not overlap
-        if self.orient == "h":
+        if self.orient == "y":
             points.set_offsets(np.c_[orig_x_data, new_y_data])
         else:
             points.set_offsets(np.c_[new_x_data, orig_y_data])
@@ -3544,3 +3869,58 @@ class Beeswarm:
             warnings.warn(msg, UserWarning)
 
         return points
+
+
+BoxPlotArtists = namedtuple("BoxPlotArtists", "box median whiskers caps fliers mean")
+
+
+class BoxPlotContainer:
+
+    def __init__(self, artist_dict):
+
+        self.boxes = artist_dict["boxes"]
+        self.medians = artist_dict["medians"]
+        self.whiskers = artist_dict["whiskers"]
+        self.caps = artist_dict["caps"]
+        self.fliers = artist_dict["fliers"]
+        self.means = artist_dict["means"]
+
+        self._label = None
+        self._children = [
+            *self.boxes,
+            *self.medians,
+            *self.whiskers,
+            *self.caps,
+            *self.fliers,
+            *self.means,
+        ]
+
+    def __repr__(self):
+        return f"<BoxPlotContainer object with {len(self.boxes)} boxes>"
+
+    def __getitem__(self, idx):
+        pair_slice = slice(2 * idx, 2 * idx + 2)
+        return BoxPlotArtists(
+            self.boxes[idx] if self.boxes else [],
+            self.medians[idx] if self.medians else [],
+            self.whiskers[pair_slice] if self.whiskers else [],
+            self.caps[pair_slice] if self.caps else [],
+            self.fliers[idx] if self.fliers else [],
+            self.means[idx]if self.means else [],
+        )
+
+    def __iter__(self):
+        yield from (self[i] for i in range(len(self.boxes)))
+
+    def get_label(self):
+        return self._label
+
+    def set_label(self, value):
+        self._label = value
+
+    def get_children(self):
+        return self._children
+
+    def remove(self):
+        for child in self._children:
+            child.remove()
diff --git a/seaborn/distributions.py b/seaborn/distributions.py
index 9f0cfacb..b1a4c9da 100644
--- a/seaborn/distributions.py
+++ b/seaborn/distributions.py
@@ -253,6 +253,7 @@ class _DistributionPlotter(VectorPlotter):
                 column_groups[facet_key].append(i)
 
             baselines = curves.copy()
+
             for col_idxs in column_groups.values():
                 cols = curves.columns[col_idxs]
 
@@ -2330,6 +2331,8 @@ Parameters
 {params.core.xy}
 {params.core.hue}
 {params.facets.rowcol}
+weights : vector or key in ``data``
+    Observation weights used for computing the distribution function.
 kind : {{"hist", "kde", "ecdf"}}
     Approach for visualizing the data. Selects the underlying plotting function
     and determines the additional set of valid parameters.
diff --git a/seaborn/matrix.py b/seaborn/matrix.py
index 6a79b386..f2362cac 100644
--- a/seaborn/matrix.py
+++ b/seaborn/matrix.py
@@ -554,7 +554,7 @@ class _DendrogramPlotter:
         try:
             return self._calculate_linkage_fastcluster()
         except ImportError:
-            if np.product(self.shape) >= 10000:
+            if np.prod(self.shape) >= 10000:
                 msg = ("Clustering large matrix with scipy. Installing "
                        "`fastcluster` may give better performance.")
                 warnings.warn(msg)
diff --git a/seaborn/palettes.py b/seaborn/palettes.py
index f1214b2a..061d94a8 100644
--- a/seaborn/palettes.py
+++ b/seaborn/palettes.py
@@ -388,7 +388,7 @@ def mpl_palette(name, n_colors=6, as_cmap=False):
 
     Examples
     --------
-    .. include: ../docstrings/mpl_palette.rst
+    .. include:: ../docstrings/mpl_palette.rst
 
     """
     if name.endswith("_d"):
@@ -839,4 +839,3 @@ def set_color_codes(palette="deep"):
     for code, color in zip("bgrmyck", colors):
         rgb = mpl.colors.colorConverter.to_rgb(color)
         mpl.colors.colorConverter.colors[code] = rgb
-        mpl.colors.colorConverter.cache[code] = rgb
diff --git a/seaborn/rcmod.py b/seaborn/rcmod.py
index ca70a446..978dc175 100644
--- a/seaborn/rcmod.py
+++ b/seaborn/rcmod.py
@@ -504,7 +504,7 @@ def set_palette(palette, n_colors=None, desat=None, color_codes=False):
 
     Parameters
     ----------
-    palette : seaborn color paltte | matplotlib colormap | hls | husl
+    palette : seaborn color palette | matplotlib colormap | hls | husl
         Palette definition. Should be something :func:`color_palette` can process.
     n_colors : int
         Number of colors in the cycle. The default number of colors will depend
diff --git a/seaborn/regression.py b/seaborn/regression.py
index 1c7d804e..c6b81a17 100644
--- a/seaborn/regression.py
+++ b/seaborn/regression.py
@@ -264,14 +264,20 @@ class _RegressionPlotter(_LinearPlotter):
 
     def fit_statsmodels(self, grid, model, **kwargs):
         """More general regression function using statsmodels objects."""
-        import statsmodels.genmod.generalized_linear_model as glm
+        import statsmodels.tools.sm_exceptions as sme
         X, y = np.c_[np.ones(len(self.x)), self.x], self.y
         grid = np.c_[np.ones(len(grid)), grid]
 
         def reg_func(_x, _y):
+            err_classes = (sme.PerfectSeparationError,)
             try:
-                yhat = model(_y, _x, **kwargs).fit().predict(grid)
-            except glm.PerfectSeparationError:
+                with warnings.catch_warnings():
+                    if hasattr(sme, "PerfectSeparationWarning"):
+                        # statsmodels>=0.14.0
+                        warnings.simplefilter("error", sme.PerfectSeparationWarning)
+                        err_classes = (*err_classes, sme.PerfectSeparationWarning)
+                    yhat = model(_y, _x, **kwargs).fit().predict(grid)
+            except err_classes:
                 yhat = np.empty(len(grid))
                 yhat.fill(np.nan)
             return yhat
@@ -830,7 +836,7 @@ regplot.__doc__ = dedent("""\
     Examples
     --------
 
-    .. include: ../docstrings/regplot.rst
+    .. include:: ../docstrings/regplot.rst
 
     """).format(**_regression_docs)
 
diff --git a/seaborn/relational.py b/seaborn/relational.py
index 18e18bb6..de3cf683 100644
--- a/seaborn/relational.py
+++ b/seaborn/relational.py
@@ -190,7 +190,7 @@ class _RelationalPlotter(VectorPlotter):
     # TODO where best to define default parameters?
     sort = True
 
-    def add_legend_data(self, ax):
+    def add_legend_data(self, ax, func=None, common_kws=None, semantic_kws=None):
         """Add labeled artists to represent the different plot semantics."""
         verbosity = self.legend
         if isinstance(verbosity, str) and verbosity not in ["auto", "brief", "full"]:
@@ -199,8 +199,10 @@ class _RelationalPlotter(VectorPlotter):
         elif verbosity is True:
             verbosity = "auto"
 
-        legend_kwargs = {}
         keys = []
+        legend_kws = {}
+        common_kws = {} if common_kws is None else common_kws
+        semantic_kws = {} if semantic_kws is None else semantic_kws
 
         # Assign a legend title if there is only going to be one sub-legend,
         # otherwise, subtitles will be inserted into the texts list with an
@@ -210,11 +212,7 @@ class _RelationalPlotter(VectorPlotter):
             (self.variables.get(v, None) for v in ["hue", "size", "style"])
             if title is not None
         }
-        if len(titles) == 1:
-            legend_title = titles.pop()
-        else:
-            legend_title = ""
-
+        title = "" if len(titles) != 1 else titles.pop()
         title_kws = dict(
             visible=False, color="w", s=0, linewidth=0, marker="", dashes=""
         )
@@ -222,103 +220,20 @@ class _RelationalPlotter(VectorPlotter):
         def update(var_name, val_name, **kws):
 
             key = var_name, val_name
-            if key in legend_kwargs:
-                legend_kwargs[key].update(**kws)
+            if key in legend_kws:
+                legend_kws[key].update(**kws)
             else:
                 keys.append(key)
+                legend_kws[key] = dict(**kws)
 
-                legend_kwargs[key] = dict(**kws)
-
-        # Define the maximum number of ticks to use for "brief" legends
-        brief_ticks = 6
-
-        # -- Add a legend for hue semantics
-        brief_hue = self._hue_map.map_type == "numeric" and (
-            verbosity == "brief"
-            or (verbosity == "auto" and len(self._hue_map.levels) > brief_ticks)
-        )
-        if brief_hue:
-            if isinstance(self._hue_map.norm, mpl.colors.LogNorm):
-                locator = mpl.ticker.LogLocator(numticks=brief_ticks)
-            else:
-                locator = mpl.ticker.MaxNLocator(nbins=brief_ticks)
-            limits = min(self._hue_map.levels), max(self._hue_map.levels)
-            hue_levels, hue_formatted_levels = locator_to_legend_entries(
-                locator, limits, self.plot_data["hue"].infer_objects().dtype
-            )
-        elif self._hue_map.levels is None:
-            hue_levels = hue_formatted_levels = []
-        else:
-            hue_levels = hue_formatted_levels = self._hue_map.levels
-
-        # Add the hue semantic subtitle
-        if not legend_title and self.variables.get("hue", None) is not None:
-            update((self.variables["hue"], "title"),
-                   self.variables["hue"], **title_kws)
-
-        # Add the hue semantic labels
-        for level, formatted_level in zip(hue_levels, hue_formatted_levels):
-            if level is not None:
-                color = self._hue_map(level)
-                update(self.variables["hue"], formatted_level, color=color)
-
-        # -- Add a legend for size semantics
-        brief_size = self._size_map.map_type == "numeric" and (
-            verbosity == "brief"
-            or (verbosity == "auto" and len(self._size_map.levels) > brief_ticks)
-        )
-        if brief_size:
-            # Define how ticks will interpolate between the min/max data values
-            if isinstance(self._size_map.norm, mpl.colors.LogNorm):
-                locator = mpl.ticker.LogLocator(numticks=brief_ticks)
-            else:
-                locator = mpl.ticker.MaxNLocator(nbins=brief_ticks)
-            # Define the min/max data values
-            limits = min(self._size_map.levels), max(self._size_map.levels)
-            size_levels, size_formatted_levels = locator_to_legend_entries(
-                locator, limits, self.plot_data["size"].infer_objects().dtype
+        legend_attrs = {"hue": "color", "size": ["linewidth", "s"], "style": None}
+        for var, names in legend_attrs.items():
+            self._update_legend_data(
+                update, var, verbosity, title, title_kws, names, semantic_kws.get(var),
             )
-        elif self._size_map.levels is None:
-            size_levels = size_formatted_levels = []
-        else:
-            size_levels = size_formatted_levels = self._size_map.levels
-
-        # Add the size semantic subtitle
-        if not legend_title and self.variables.get("size", None) is not None:
-            update((self.variables["size"], "title"),
-                   self.variables["size"], **title_kws)
-
-        # Add the size semantic labels
-        for level, formatted_level in zip(size_levels, size_formatted_levels):
-            if level is not None:
-                size = self._size_map(level)
-                update(
-                    self.variables["size"],
-                    formatted_level,
-                    linewidth=size,
-                    s=size,
-                )
-
-        # -- Add a legend for style semantics
-
-        # Add the style semantic title
-        if not legend_title and self.variables.get("style", None) is not None:
-            update((self.variables["style"], "title"),
-                   self.variables["style"], **title_kws)
-
-        # Add the style semantic labels
-        if self._style_map.levels is not None:
-            for level in self._style_map.levels:
-                if level is not None:
-                    attrs = self._style_map(level)
-                    update(
-                        self.variables["style"],
-                        level,
-                        marker=attrs.get("marker", ""),
-                        dashes=attrs.get("dashes", ""),
-                    )
 
-        func = getattr(ax, self._legend_func)
+        if func is None:
+            func = getattr(ax, self._legend_func)
 
         legend_data = {}
         legend_order = []
@@ -326,22 +241,74 @@ class _RelationalPlotter(VectorPlotter):
         for key in keys:
 
             _, label = key
-            kws = legend_kwargs[key]
+            kws = legend_kws[key]
             kws.setdefault("color", ".2")
-            use_kws = {}
-            for attr in self._legend_attributes + ["visible"]:
+            level_kws = {}
+            use_attrs = [
+                *self._legend_attributes,
+                *common_kws,
+                *[attr for var_attrs in semantic_kws.values() for attr in var_attrs],
+            ]
+            for attr in use_attrs:
                 if attr in kws:
-                    use_kws[attr] = kws[attr]
-            artist = func([], [], label=label, **use_kws)
-            if self._legend_func == "plot":
+                    level_kws[attr] = kws[attr]
+            artist = func([], [], label=label, **{**common_kws, **level_kws})
+            if func.__name__ == "plot":
                 artist = artist[0]
             legend_data[key] = artist
             legend_order.append(key)
 
-        self.legend_title = legend_title
+        self.legend_title = title
         self.legend_data = legend_data
         self.legend_order = legend_order
 
+    def _update_legend_data(
+        self,
+        update,
+        var,
+        verbosity,
+        title,
+        title_kws,
+        attr_names,
+        other_props,
+    ):
+
+        brief_ticks = 6
+        mapper = getattr(self, f"_{var}_map")
+
+        brief = mapper.map_type == "numeric" and (
+            verbosity == "brief"
+            or (verbosity == "auto" and len(mapper.levels) > brief_ticks)
+        )
+        if brief:
+            if isinstance(mapper.norm, mpl.colors.LogNorm):
+                locator = mpl.ticker.LogLocator(numticks=brief_ticks)
+            else:
+                locator = mpl.ticker.MaxNLocator(nbins=brief_ticks)
+            limits = min(mapper.levels), max(mapper.levels)
+            levels, formatted_levels = locator_to_legend_entries(
+                locator, limits, self.plot_data[var].infer_objects().dtype
+            )
+        elif mapper.levels is None:
+            levels = formatted_levels = []
+        else:
+            levels = formatted_levels = mapper.levels
+
+        if not title and self.variables.get(var, None) is not None:
+            update((self.variables[var], "title"), self.variables[var], **title_kws)
+
+        other_props = {} if other_props is None else other_props
+
+        for level, formatted_level in zip(levels, formatted_levels):
+            if level is not None:
+                attr = mapper(level)
+                if isinstance(attr_names, list):
+                    attr = {name: attr for name in attr_names}
+                elif attr_names is not None:
+                    attr = {attr_names: attr}
+                attr.update({k: v[level] for k, v in other_props.items() if level in v})
+                update(self.variables[var], formatted_level, **attr)
+
 
 class _LinePlotter(_RelationalPlotter):
 
@@ -629,7 +596,7 @@ def lineplot(
     if ax is None:
         ax = plt.gca()
 
-    if style is None and not {"ls", "linestyle"} & set(kwargs):  # XXX
+    if "style" not in p.variables and not {"ls", "linestyle"} & set(kwargs):  # XXX
         kwargs["dashes"] = "" if dashes is None or isinstance(dashes, bool) else dashes
 
     if not p.has_xy_data:
diff --git a/seaborn/utils.py b/seaborn/utils.py
index 7f838776..c5acc5e2 100644
--- a/seaborn/utils.py
+++ b/seaborn/utils.py
@@ -1,6 +1,5 @@
 """Utility functions, mostly for internal use."""
 import os
-import re
 import inspect
 import warnings
 import colorsys
@@ -15,12 +14,16 @@ from matplotlib.colors import to_rgb
 import matplotlib.pyplot as plt
 from matplotlib.cbook import normalize_kwargs
 
+from seaborn._core.typing import deprecated
 from seaborn.external.version import Version
 from seaborn.external.appdirs import user_cache_dir
 
 __all__ = ["desaturate", "saturate", "set_hls_values", "move_legend",
            "despine", "get_dataset_names", "get_data_home", "load_dataset"]
 
+DATASET_SOURCE = "https://raw.githubusercontent.com/mwaskom/seaborn-data/master"
+DATASET_NAMES_URL = f"{DATASET_SOURCE}/dataset_names.txt"
+
 
 def ci_to_errsize(cis, heights):
     """Convert intervals to error arguments relative to plot heights.
@@ -86,7 +89,7 @@ def _draw_figure(fig):
             pass
 
 
-def _default_color(method, hue, color, kws):
+def _default_color(method, hue, color, kws, saturation=1):
     """If needed, get a default color by using the matplotlib property cycle."""
 
     if hue is not None:
@@ -101,6 +104,8 @@ def _default_color(method, hue, color, kws):
     kws.pop("label", None)
 
     if color is not None:
+        if saturation < 1:
+            color = desaturate(color, saturation)
         return color
 
     elif method.__name__ == "plot":
@@ -145,6 +150,8 @@ def _default_color(method, hue, color, kws):
         scout, = method([np.nan], [np.nan], **kws)
         color = to_rgb(scout.get_facecolor())
         scout.remove()
+        # Axes.bar adds both a patch and a container
+        method.__self__.containers.pop(-1)
 
     elif method.__name__ == "fill_between":
 
@@ -154,6 +161,9 @@ def _default_color(method, hue, color, kws):
         color = to_rgb(facecolor[0])
         scout.remove()
 
+    if saturation < 1:
+        color = desaturate(color, saturation)
+
     return color
 
 
@@ -180,6 +190,10 @@ def desaturate(color, prop):
     # Get rgb tuple rep
     rgb = to_rgb(color)
 
+    # Short circuit to avoid floating point issues
+    if prop == 1:
+        return rgb
+
     # Convert to hls
     h, l, s = colorsys.rgb_to_hls(*rgb)
 
@@ -441,7 +455,9 @@ def move_legend(obj, loc, **kwargs):
         raise ValueError(err)
 
     # Extract the components of the legend we need to reuse
-    handles = old_legend.legendHandles
+    # Import here to avoid a circular import
+    from seaborn._compat import get_legend_handles
+    handles = get_legend_handles(old_legend)
     labels = [t.get_text() for t in old_legend.get_texts()]
 
     # Extract legend properties that can be passed to the recreation method
@@ -496,13 +512,11 @@ def get_dataset_names():
     Requires an internet connection.
 
     """
-    url = "https://github.com/mwaskom/seaborn-data"
-    with urlopen(url) as resp:
-        html = resp.read()
+    with urlopen(DATASET_NAMES_URL) as resp:
+        txt = resp.read()
 
-    pat = r"/mwaskom/seaborn-data/blob/master/(\w*).csv"
-    datasets = re.findall(pat, html.decode())
-    return datasets
+    dataset_names = [name.strip() for name in txt.decode().split("\n")]
+    return list(filter(None, dataset_names))
 
 
 def get_data_home(data_home=None):
@@ -566,7 +580,7 @@ def load_dataset(name, cache=True, data_home=None, **kws):
         )
         raise TypeError(err)
 
-    url = f"https://raw.githubusercontent.com/mwaskom/seaborn-data/master/{name}.csv"
+    url = f"{DATASET_SOURCE}/{name}.csv"
 
     if cache:
         cache_path = os.path.join(get_data_home(data_home), os.path.basename(url))
@@ -773,12 +787,18 @@ def _normalize_kwargs(kws, artist):
     return kws
 
 
-def _check_argument(param, options, value):
+def _check_argument(param, options, value, prefix=False):
     """Raise if value for param is not in options."""
-    if value not in options:
+    if prefix and value is not None:
+        failure = not any(value.startswith(p) for p in options if isinstance(p, str))
+    else:
+        failure = value not in options
+    if failure:
         raise ValueError(
-            f"`{param}` must be one of {options}, but {repr(value)} was passed."
+            f"The value for `{param}` must be one of {options}, "
+            f"but {repr(value)} was passed."
         )
+    return value
 
 
 def _assign_default_kwargs(kws, call_func, source_func):
@@ -829,7 +849,7 @@ def _deprecate_ci(errorbar, ci):
     (and extracted from kwargs) after one cycle.
 
     """
-    if ci != "deprecated":
+    if ci is not deprecated and ci != "deprecated":
         if ci is None:
             errorbar = None
         elif ci == "sd":
@@ -845,6 +865,13 @@ def _deprecate_ci(errorbar, ci):
     return errorbar
 
 
+def _get_transform_functions(ax, axis):
+    """Return the forward and inverse transforms for a given axis."""
+    axis_obj = getattr(ax, f"{axis}axis")
+    transform = axis_obj.get_transform()
+    return transform.transform, transform.inverted().transform
+
+
 @contextmanager
 def _disable_autolayout():
     """Context manager for preventing rc-controlled auto-layout behavior."""

test_patch: diff --git a/tests/_core/test_data.py b/tests/_core/test_data.py
index b3e0026c..aeffc9ed 100644
--- a/tests/_core/test_data.py
+++ b/tests/_core/test_data.py
@@ -187,8 +187,9 @@ class TestPlotData:
 
         p = PlotData(None, {"x": x, "y": y})
 
-        x_col_expected = pd.Series([10, 20, 30, np.nan, np.nan], np.arange(1, 6))
-        y_col_expected = pd.Series([np.nan, np.nan, 300, 400, 500], np.arange(1, 6))
+        idx_expected = [1, 2, 3, 4, 5]
+        x_col_expected = pd.Series([10, 20, 30, np.nan, np.nan], idx_expected)
+        y_col_expected = pd.Series([np.nan, np.nan, 300, 400, 500], idx_expected)
         assert_vector_equal(p.frame["x"], x_col_expected)
         assert_vector_equal(p.frame["y"], y_col_expected)
 
diff --git a/tests/_core/test_plot.py b/tests/_core/test_plot.py
index d7a950b6..3da7aab5 100644
--- a/tests/_core/test_plot.py
+++ b/tests/_core/test_plot.py
@@ -14,7 +14,7 @@ import pytest
 from pandas.testing import assert_frame_equal, assert_series_equal
 from numpy.testing import assert_array_equal, assert_array_almost_equal
 
-from seaborn._core.plot import Plot, Default
+from seaborn._core.plot import Plot, PlotConfig, Default
 from seaborn._core.scales import Continuous, Nominal, Temporal
 from seaborn._core.moves import Move, Shift, Dodge
 from seaborn._core.rules import categorical_order
@@ -922,6 +922,16 @@ class TestPlotting:
         with pytest.raises(TypeError, match=r"theme\(\) takes 1 positional"):
             p.theme("arg1", "arg2")
 
+    def test_theme_validation(self):
+
+        p = Plot()
+        # You'd think matplotlib would raise a TypeError here, but it doesn't
+        with pytest.raises(ValueError, match="Key axes.linewidth:"):
+            p.theme({"axes.linewidth": "thick"})
+
+        with pytest.raises(KeyError, match="not.a.key is not a valid rc"):
+            p.theme({"not.a.key": True})
+
     def test_stat(self, long_df):
 
         orig_df = long_df.copy(deep=True)
@@ -1046,18 +1056,6 @@ class TestPlotting:
         if not gui_backend:
             assert msg
 
-    def test_png_repr(self):
-
-        p = Plot()
-        data, metadata = p._repr_png_()
-        img = Image.open(io.BytesIO(data))
-
-        assert not hasattr(p, "_figure")
-        assert isinstance(data, bytes)
-        assert img.format == "PNG"
-        assert sorted(metadata) == ["height", "width"]
-        # TODO test retina scaling
-
     def test_save(self):
 
         buf = io.BytesIO()
@@ -2126,3 +2124,148 @@ class TestDefaultObject:
     def test_default_repr(self):
 
         assert repr(Default()) == "<default>"
+
+
+class TestThemeConfig:
+
+    @pytest.fixture(autouse=True)
+    def reset_config(self):
+        yield
+        Plot.config.theme.reset()
+
+    def test_default(self):
+
+        p = Plot().plot()
+        ax = p._figure.axes[0]
+        expected = Plot.config.theme["axes.facecolor"]
+        assert mpl.colors.same_color(ax.get_facecolor(), expected)
+
+    def test_setitem(self):
+
+        color = "#CCC"
+        Plot.config.theme["axes.facecolor"] = color
+        p = Plot().plot()
+        ax = p._figure.axes[0]
+        assert mpl.colors.same_color(ax.get_facecolor(), color)
+
+    def test_update(self):
+
+        color = "#DDD"
+        Plot.config.theme.update({"axes.facecolor": color})
+        p = Plot().plot()
+        ax = p._figure.axes[0]
+        assert mpl.colors.same_color(ax.get_facecolor(), color)
+
+    def test_reset(self):
+
+        orig = Plot.config.theme["axes.facecolor"]
+        Plot.config.theme.update({"axes.facecolor": "#EEE"})
+        Plot.config.theme.reset()
+        p = Plot().plot()
+        ax = p._figure.axes[0]
+        assert mpl.colors.same_color(ax.get_facecolor(), orig)
+
+    def test_copy(self):
+
+        key, val = "axes.facecolor", ".95"
+        orig = Plot.config.theme[key]
+        theme = Plot.config.theme.copy()
+        theme.update({key: val})
+        assert Plot.config.theme[key] == orig
+
+    def test_html_repr(self):
+
+        res = Plot.config.theme._repr_html_()
+        for tag in ["div", "table", "tr", "td"]:
+            assert res.count(f"<{tag}") == res.count(f"</{tag}")
+
+        for key in Plot.config.theme:
+            assert f"<td>{key}:</td>" in res
+
+
+class TestDisplayConfig:
+
+    @pytest.fixture(autouse=True)
+    def reset_config(self):
+        yield
+        Plot.config.display.update(PlotConfig().display)
+
+    def test_png_format(self):
+
+        Plot.config.display["format"] = "png"
+
+        assert Plot()._repr_svg_() is None
+        assert Plot().plot()._repr_svg_() is None
+
+        def assert_valid_png(p):
+            data, metadata = p._repr_png_()
+            img = Image.open(io.BytesIO(data))
+            assert img.format == "PNG"
+            assert sorted(metadata) == ["height", "width"]
+
+        assert_valid_png(Plot())
+        assert_valid_png(Plot().plot())
+
+    def test_svg_format(self):
+
+        Plot.config.display["format"] = "svg"
+
+        assert Plot()._repr_png_() is None
+        assert Plot().plot()._repr_png_() is None
+
+        def assert_valid_svg(p):
+            res = p._repr_svg_()
+            root = xml.etree.ElementTree.fromstring(res)
+            assert root.tag == "{http://www.w3.org/2000/svg}svg"
+
+        assert_valid_svg(Plot())
+        assert_valid_svg(Plot().plot())
+
+    def test_png_scaling(self):
+
+        Plot.config.display["scaling"] = 1.
+        res1, meta1 = Plot()._repr_png_()
+
+        Plot.config.display["scaling"] = .5
+        res2, meta2 = Plot()._repr_png_()
+
+        assert meta1["width"] / 2 == meta2["width"]
+        assert meta1["height"] / 2 == meta2["height"]
+
+        img1 = Image.open(io.BytesIO(res1))
+        img2 = Image.open(io.BytesIO(res2))
+        assert img1.size == img2.size
+
+    def test_svg_scaling(self):
+
+        Plot.config.display["format"] = "svg"
+
+        Plot.config.display["scaling"] = 1.
+        res1 = Plot()._repr_svg_()
+
+        Plot.config.display["scaling"] = .5
+        res2 = Plot()._repr_svg_()
+
+        root1 = xml.etree.ElementTree.fromstring(res1)
+        root2 = xml.etree.ElementTree.fromstring(res2)
+
+        def getdim(root, dim):
+            return float(root.attrib[dim][:-2])
+
+        assert getdim(root1, "width") / 2 == getdim(root2, "width")
+        assert getdim(root1, "height") / 2 == getdim(root2, "height")
+
+    def test_png_hidpi(self):
+
+        res1, meta1 = Plot()._repr_png_()
+
+        Plot.config.display["hidpi"] = False
+        res2, meta2 = Plot()._repr_png_()
+
+        assert meta1["width"] == meta2["width"]
+        assert meta1["height"] == meta2["height"]
+
+        img1 = Image.open(io.BytesIO(res1))
+        img2 = Image.open(io.BytesIO(res2))
+        assert img1.size[0] // 2 == img2.size[0]
+        assert img1.size[1] // 2 == img2.size[1]
diff --git a/tests/_core/test_rules.py b/tests/_core/test_rules.py
index 3eec1a6d..c7617ecd 100644
--- a/tests/_core/test_rules.py
+++ b/tests/_core/test_rules.py
@@ -28,8 +28,6 @@ def test_variable_type():
     assert variable_type(s) == "numeric"
     assert variable_type(s.astype(int)) == "numeric"
     assert variable_type(s.astype(object)) == "numeric"
-    assert variable_type(s.to_numpy()) == "numeric"
-    assert variable_type(s.to_list()) == "numeric"
 
     s = pd.Series([1, 2, 3, np.nan], dtype=object)
     assert variable_type(s) == "numeric"
@@ -42,8 +40,6 @@ def test_variable_type():
 
     s = pd.Series(["1", "2", "3"])
     assert variable_type(s) == "categorical"
-    assert variable_type(s.to_numpy()) == "categorical"
-    assert variable_type(s.to_list()) == "categorical"
 
     s = pd.Series([True, False, False])
     assert variable_type(s) == "numeric"
@@ -62,8 +58,6 @@ def test_variable_type():
     s = pd.Series([pd.Timestamp(1), pd.Timestamp(2)])
     assert variable_type(s) == "datetime"
     assert variable_type(s.astype(object)) == "datetime"
-    assert variable_type(s.to_numpy()) == "datetime"
-    assert variable_type(s.to_list()) == "datetime"
 
 
 def test_categorical_order():
diff --git a/tests/_stats/test_regression.py b/tests/_stats/test_regression.py
index 16807fd7..aea49ec4 100644
--- a/tests/_stats/test_regression.py
+++ b/tests/_stats/test_regression.py
@@ -58,4 +58,4 @@ class TestPolyFit:
         df.iloc[5:10] = np.nan
         res1 = PolyFit()(df[["x", "y"]], groupby, "x", {})
         res2 = PolyFit()(df[["x", "y"]].dropna(), groupby, "x", {})
-        assert_frame_equal(res1, res2)
\ No newline at end of file
+        assert_frame_equal(res1, res2)
diff --git a/tests/conftest.py b/tests/conftest.py
index 0366d164..01d93a49 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -155,7 +155,7 @@ def repeated_df(rng):
 
 
 @pytest.fixture
-def missing_df(rng, long_df):
+def null_df(rng, long_df):
 
     df = long_df.copy()
     for col in df:
diff --git a/tests/test_axisgrid.py b/tests/test_axisgrid.py
index af8bf19d..ce8b0eaa 100644
--- a/tests/test_axisgrid.py
+++ b/tests/test_axisgrid.py
@@ -6,10 +6,7 @@ import matplotlib.pyplot as plt
 import pytest
 import numpy.testing as npt
 from numpy.testing import assert_array_equal, assert_array_almost_equal
-try:
-    import pandas.testing as tm
-except ImportError:
-    import pandas.util.testing as tm
+import pandas.testing as tm
 
 from seaborn._oldcore import categorical_order
 from seaborn import rcmod
@@ -22,6 +19,7 @@ from seaborn._testing import (
     assert_plots_equal,
     assert_colors_equal,
 )
+from seaborn._compat import get_legend_handles
 
 rs = np.random.RandomState(0)
 
@@ -331,7 +329,7 @@ class TestFacetGrid:
         g = ag.FacetGrid(self.df, despine=False,
                          subplot_kws=dict(projection="polar"))
         for ax in g.axes.flat:
-            assert "PolarAxesSubplot" in str(type(ax))
+            assert "PolarAxes" in ax.__class__.__name__
 
     def test_gridspec_kws(self):
         ratios = [3, 1, 2]
@@ -1280,7 +1278,7 @@ class TestPairGrid:
         g.map_offdiag(histplot)
         g.add_legend()
 
-        assert len(g._legend.legendHandles) == len(self.df["a"].unique())
+        assert len(get_legend_handles(g._legend)) == len(self.df["a"].unique())
 
     def test_pairplot(self):
 
@@ -1417,8 +1415,8 @@ class TestPairGrid:
         vars = ["x", "y", "z"]
         markers = ["o", "X", "s"]
         g = ag.pairplot(self.df, hue="a", vars=vars, markers=markers)
-        m1 = g._legend.legendHandles[0].get_paths()[0]
-        m2 = g._legend.legendHandles[1].get_paths()[0]
+        m1 = get_legend_handles(g._legend)[0].get_paths()[0]
+        m2 = get_legend_handles(g._legend)[1].get_paths()[0]
         assert m1 != m2
 
         with pytest.warns(UserWarning):
diff --git a/tests/test_categorical.py b/tests/test_categorical.py
index 955d1f0b..b6485396 100644
--- a/tests/test_categorical.py
+++ b/tests/test_categorical.py
@@ -6,7 +6,7 @@ import numpy as np
 import pandas as pd
 import matplotlib as mpl
 import matplotlib.pyplot as plt
-from matplotlib.colors import rgb2hex, same_color, to_rgb, to_rgba
+from matplotlib.colors import same_color, to_rgb, to_rgba
 
 import pytest
 from pytest import approx
@@ -14,32 +14,41 @@ import numpy.testing as npt
 from numpy.testing import (
     assert_array_equal,
     assert_array_less,
+    assert_array_almost_equal,
 )
 
 from seaborn import categorical as cat
 from seaborn import palettes
 
-from seaborn.utils import _version_predates
+from seaborn.utils import _version_predates, desaturate
 from seaborn._oldcore import categorical_order
-from seaborn.axisgrid import FacetGrid
 from seaborn.categorical import (
     _CategoricalPlotterNew,
     Beeswarm,
+    BoxPlotContainer,
     catplot,
+    barplot,
+    boxplot,
+    countplot,
     pointplot,
     stripplot,
     swarmplot,
+    violinplot,
 )
 from seaborn.palettes import color_palette
-from seaborn.utils import _normal_quantile_func, _draw_figure
-from seaborn._compat import get_colormap
+from seaborn.utils import _draw_figure
+from seaborn._compat import get_colormap, get_legend_handles
 from seaborn._testing import assert_plots_equal
 
 
 PLOT_FUNCS = [
     catplot,
+    barplot,
+    boxplot,
+    pointplot,
     stripplot,
     swarmplot,
+    violinplot,
 ]
 
 
@@ -137,14 +146,14 @@ class TestCategoricalPlotter(CategoricalFixture):
             npt.assert_array_equal(x, y)
 
         # Check semantic attributes
-        assert p.orient == "v"
+        assert p.orient == "x"
         assert p.plot_hues is None
         assert p.group_label == "big"
         assert p.value_label is None
 
         # Test wide dataframe with forced horizontal orientation
         p.establish_variables(data=self.x_df, orient="horiz")
-        assert p.orient == "h"
+        assert p.orient == "y"
 
         # Test exception by trying to hue-group with a wide dataframe
         with pytest.raises(ValueError):
@@ -244,21 +253,21 @@ class TestCategoricalPlotter(CategoricalFixture):
         # Test passing a series to the x variable
         p.establish_variables(x=self.y)
         npt.assert_equal(p.plot_data, [self.y])
-        assert p.orient == "h"
+        assert p.orient == "y"
         assert p.value_label == "y_data"
         assert p.group_label is None
 
         # Test passing a series to the y variable
         p.establish_variables(y=self.y)
         npt.assert_equal(p.plot_data, [self.y])
-        assert p.orient == "v"
+        assert p.orient == "x"
         assert p.value_label == "y_data"
         assert p.group_label is None
 
         # Test passing an array to the y variable
         p.establish_variables(y=self.y.values)
         npt.assert_equal(p.plot_data, [self.y])
-        assert p.orient == "v"
+        assert p.orient == "x"
         assert p.group_label is None
         assert p.value_label is None
 
@@ -275,14 +284,14 @@ class TestCategoricalPlotter(CategoricalFixture):
         # Test referencing a DataFrame series in the x variable
         p.establish_variables(x="y", data=self.df)
         npt.assert_equal(p.plot_data, [self.y])
-        assert p.orient == "h"
+        assert p.orient == "y"
         assert p.value_label == "y"
         assert p.group_label is None
 
         # Test referencing a DataFrame series in the y variable
         p.establish_variables(y="y", data=self.df)
         npt.assert_equal(p.plot_data, [self.y])
-        assert p.orient == "v"
+        assert p.orient == "x"
         assert p.value_label == "y"
         assert p.group_label is None
 
@@ -294,7 +303,7 @@ class TestCategoricalPlotter(CategoricalFixture):
         p.establish_variables("g", "y", hue="h", data=self.df)
         assert len(p.plot_data) == 3
         assert len(p.plot_hues) == 3
-        assert p.orient == "v"
+        assert p.orient == "x"
         assert p.value_label == "y"
         assert p.group_label == "g"
         assert p.hue_title == "h"
@@ -327,7 +336,7 @@ class TestCategoricalPlotter(CategoricalFixture):
         p.establish_variables("y", "g", hue="h", data=df)
         assert len(p.plot_data) == 3
         assert len(p.plot_hues) == 3
-        assert p.orient == "h"
+        assert p.orient == "y"
         assert p.value_label == "y"
         assert p.group_label == "g"
         assert p.hue_title == "h"
@@ -515,1400 +524,372 @@ class TestCategoricalPlotter(CategoricalFixture):
         assert p.colors == [(.25, .25, .75), (.75, .25, .25), (1, 1, 1)]
 
 
-class TestCategoricalStatPlotter(CategoricalFixture):
+# ====================================================================================
+# ====================================================================================
 
-    def test_no_bootstrappig(self):
 
-        p = cat._CategoricalStatPlotter()
-        p.establish_variables("g", "y", data=self.df)
-        p.estimate_statistic("mean", None, 100, None)
-        npt.assert_array_equal(p.confint, np.array([]))
+class SharedAxesLevelTests:
 
-        p.establish_variables("g", "y", hue="h", data=self.df)
-        p.estimate_statistic(np.mean, None, 100, None)
-        npt.assert_array_equal(p.confint, np.array([[], [], []]))
+    def orient_indices(self, orient):
+        pos_idx = ["x", "y"].index(orient)
+        val_idx = ["y", "x"].index(orient)
+        return pos_idx, val_idx
 
-    def test_single_layer_stats(self):
+    @pytest.fixture
+    def common_kws(self):
+        return {}
 
-        p = cat._CategoricalStatPlotter()
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_labels_long(self, long_df, orient):
 
-        g = pd.Series(np.repeat(list("abc"), 100))
-        y = pd.Series(np.random.RandomState(0).randn(300))
+        depend = {"x": "y", "y": "x"}[orient]
+        kws = {orient: "a", depend: "y", "hue": "b"}
 
-        p.establish_variables(g, y)
-        p.estimate_statistic("mean", ("ci", 95), 10000, None)
+        ax = self.func(long_df, **kws)
 
-        assert p.statistic.shape == (3,)
-        assert p.confint.shape == (3, 2)
+        # To populate texts; only needed on older matplotlibs
+        _draw_figure(ax.figure)
 
-        npt.assert_array_almost_equal(p.statistic,
-                                      y.groupby(g).mean())
+        assert getattr(ax, f"get_{orient}label")() == kws[orient]
+        assert getattr(ax, f"get_{depend}label")() == kws[depend]
 
-        for ci, (_, grp_y) in zip(p.confint, y.groupby(g)):
-            sem = grp_y.std() / np.sqrt(len(grp_y))
-            mean = grp_y.mean()
-            half_ci = _normal_quantile_func(.975) * sem
-            ci_want = mean - half_ci, mean + half_ci
-            npt.assert_array_almost_equal(ci_want, ci, 2)
+        get_ori_labels = getattr(ax, f"get_{orient}ticklabels")
+        ori_labels = [t.get_text() for t in get_ori_labels()]
+        ori_levels = categorical_order(long_df[kws[orient]])
+        assert ori_labels == ori_levels
 
-    def test_single_layer_stats_with_units(self):
+        legend = ax.get_legend()
+        assert legend.get_title().get_text() == kws["hue"]
 
-        p = cat._CategoricalStatPlotter()
+        hue_labels = [t.get_text() for t in legend.texts]
+        hue_levels = categorical_order(long_df[kws["hue"]])
+        assert hue_labels == hue_levels
 
-        g = pd.Series(np.repeat(list("abc"), 90))
-        y = pd.Series(np.random.RandomState(0).randn(270))
-        u = pd.Series(np.repeat(np.tile(list("xyz"), 30), 3))
-        y[u == "x"] -= 3
-        y[u == "y"] += 3
+    def test_labels_wide(self, wide_df):
 
-        p.establish_variables(g, y)
-        p.estimate_statistic("mean", ("ci", 95), 10000, None)
-        stat1, ci1 = p.statistic, p.confint
+        wide_df = wide_df.rename_axis("cols", axis=1)
+        ax = self.func(wide_df)
 
-        p.establish_variables(g, y, units=u)
-        p.estimate_statistic("mean", ("ci", 95), 10000, None)
-        stat2, ci2 = p.statistic, p.confint
+        # To populate texts; only needed on older matplotlibs
+        _draw_figure(ax.figure)
 
-        npt.assert_array_equal(stat1, stat2)
-        ci1_size = ci1[:, 1] - ci1[:, 0]
-        ci2_size = ci2[:, 1] - ci2[:, 0]
-        npt.assert_array_less(ci1_size, ci2_size)
+        assert ax.get_xlabel() == wide_df.columns.name
+        labels = [t.get_text() for t in ax.get_xticklabels()]
+        for label, level in zip(labels, wide_df.columns):
+            assert label == level
 
-    def test_single_layer_stats_with_missing_data(self):
+    def test_color(self, long_df, common_kws):
+        common_kws.update(data=long_df, x="a", y="y")
 
-        p = cat._CategoricalStatPlotter()
+        ax = plt.figure().subplots()
+        self.func(ax=ax, **common_kws)
+        assert self.get_last_color(ax) == to_rgba("C0")
 
-        g = pd.Series(np.repeat(list("abc"), 100))
-        y = pd.Series(np.random.RandomState(0).randn(300))
+        ax = plt.figure().subplots()
+        self.func(ax=ax, **common_kws)
+        self.func(ax=ax, **common_kws)
+        assert self.get_last_color(ax) == to_rgba("C1")
 
-        p.establish_variables(g, y, order=list("abdc"))
-        p.estimate_statistic("mean", ("ci", 95), 10000, None)
+        ax = plt.figure().subplots()
+        self.func(color="C2", ax=ax, **common_kws)
+        assert self.get_last_color(ax) == to_rgba("C2")
 
-        assert p.statistic.shape == (4,)
-        assert p.confint.shape == (4, 2)
+        ax = plt.figure().subplots()
+        self.func(color="C3", ax=ax, **common_kws)
+        assert self.get_last_color(ax) == to_rgba("C3")
 
-        rows = g == "b"
-        mean = y[rows].mean()
-        sem = y[rows].std() / np.sqrt(rows.sum())
-        half_ci = _normal_quantile_func(.975) * sem
-        ci = mean - half_ci, mean + half_ci
-        npt.assert_almost_equal(p.statistic[1], mean)
-        npt.assert_array_almost_equal(p.confint[1], ci, 2)
+    def test_two_calls(self):
 
-        npt.assert_equal(p.statistic[2], np.nan)
-        npt.assert_array_equal(p.confint[2], (np.nan, np.nan))
+        ax = plt.figure().subplots()
+        self.func(x=["a", "b", "c"], y=[1, 2, 3], ax=ax)
+        self.func(x=["e", "f"], y=[4, 5], ax=ax)
+        assert ax.get_xlim() == (-.5, 4.5)
 
-    def test_nested_stats(self):
+    def test_redundant_hue_legend(self, long_df):
 
-        p = cat._CategoricalStatPlotter()
+        ax = self.func(long_df, x="a", y="y", hue="a")
+        assert ax.get_legend() is None
+        ax.clear()
 
-        g = pd.Series(np.repeat(list("abc"), 100))
-        h = pd.Series(np.tile(list("xy"), 150))
-        y = pd.Series(np.random.RandomState(0).randn(300))
+        self.func(long_df, x="a", y="y", hue="a", legend=True)
+        assert ax.get_legend() is not None
 
-        p.establish_variables(g, y, h)
-        p.estimate_statistic("mean", ("ci", 95), 50000, None)
 
-        assert p.statistic.shape == (3, 2)
-        assert p.confint.shape == (3, 2, 2)
+class SharedScatterTests(SharedAxesLevelTests):
+    """Tests functionality common to stripplot and swarmplot."""
 
-        npt.assert_array_almost_equal(p.statistic,
-                                      y.groupby([g, h]).mean().unstack())
+    def get_last_color(self, ax):
 
-        for ci_g, (_, grp_y) in zip(p.confint, y.groupby(g)):
-            for ci, hue_y in zip(ci_g, [grp_y.iloc[::2], grp_y.iloc[1::2]]):
-                sem = hue_y.std() / np.sqrt(len(hue_y))
-                mean = hue_y.mean()
-                half_ci = _normal_quantile_func(.975) * sem
-                ci_want = mean - half_ci, mean + half_ci
-                npt.assert_array_almost_equal(ci_want, ci, 2)
+        colors = ax.collections[-1].get_facecolors()
+        unique_colors = np.unique(colors, axis=0)
+        assert len(unique_colors) == 1
+        return to_rgba(unique_colors.squeeze())
 
-    def test_bootstrap_seed(self):
+    # ------------------------------------------------------------------------------
 
-        p = cat._CategoricalStatPlotter()
+    def test_color(self, long_df, common_kws):
 
-        g = pd.Series(np.repeat(list("abc"), 100))
-        h = pd.Series(np.tile(list("xy"), 150))
-        y = pd.Series(np.random.RandomState(0).randn(300))
+        super().test_color(long_df, common_kws)
 
-        p.establish_variables(g, y, h)
-        p.estimate_statistic("mean", ("ci", 95), 1000, 0)
-        confint_1 = p.confint
-        p.estimate_statistic("mean", ("ci", 95), 1000, 0)
-        confint_2 = p.confint
+        ax = plt.figure().subplots()
+        self.func(data=long_df, x="a", y="y", facecolor="C4", ax=ax)
+        assert self.get_last_color(ax) == to_rgba("C4")
 
-        npt.assert_array_equal(confint_1, confint_2)
+        ax = plt.figure().subplots()
+        self.func(data=long_df, x="a", y="y", fc="C5", ax=ax)
+        assert self.get_last_color(ax) == to_rgba("C5")
 
-    def test_nested_stats_with_units(self):
+    def test_supplied_color_array(self, long_df):
 
-        p = cat._CategoricalStatPlotter()
+        cmap = get_colormap("Blues")
+        norm = mpl.colors.Normalize()
+        colors = cmap(norm(long_df["y"].to_numpy()))
 
-        g = pd.Series(np.repeat(list("abc"), 90))
-        h = pd.Series(np.tile(list("xy"), 135))
-        u = pd.Series(np.repeat(list("ijkijk"), 45))
-        y = pd.Series(np.random.RandomState(0).randn(270))
-        y[u == "i"] -= 3
-        y[u == "k"] += 3
+        keys = ["c", "fc", "facecolor", "facecolors"]
 
-        p.establish_variables(g, y, h)
-        p.estimate_statistic("mean", ("ci", 95), 10000, None)
-        stat1, ci1 = p.statistic, p.confint
+        for key in keys:
 
-        p.establish_variables(g, y, h, units=u)
-        p.estimate_statistic("mean", ("ci", 95), 10000, None)
-        stat2, ci2 = p.statistic, p.confint
+            ax = plt.figure().subplots()
+            self.func(x=long_df["y"], **{key: colors})
+            _draw_figure(ax.figure)
+            assert_array_equal(ax.collections[0].get_facecolors(), colors)
 
-        npt.assert_array_equal(stat1, stat2)
-        ci1_size = ci1[:, 0, 1] - ci1[:, 0, 0]
-        ci2_size = ci2[:, 0, 1] - ci2[:, 0, 0]
-        npt.assert_array_less(ci1_size, ci2_size)
+        ax = plt.figure().subplots()
+        self.func(x=long_df["y"], c=long_df["y"], cmap=cmap)
+        _draw_figure(ax.figure)
+        assert_array_equal(ax.collections[0].get_facecolors(), colors)
 
-    def test_nested_stats_with_missing_data(self):
+    @pytest.mark.parametrize(
+        "orient,data_type", [
+            ("h", "dataframe"), ("h", "dict"),
+            ("v", "dataframe"), ("v", "dict"),
+            ("y", "dataframe"), ("y", "dict"),
+            ("x", "dataframe"), ("x", "dict"),
+        ]
+    )
+    def test_wide(self, wide_df, orient, data_type):
 
-        p = cat._CategoricalStatPlotter()
+        if data_type == "dict":
+            wide_df = {k: v.to_numpy() for k, v in wide_df.items()}
 
-        g = pd.Series(np.repeat(list("abc"), 100))
-        y = pd.Series(np.random.RandomState(0).randn(300))
-        h = pd.Series(np.tile(list("xy"), 150))
+        ax = self.func(data=wide_df, orient=orient)
+        _draw_figure(ax.figure)
 
-        p.establish_variables(g, y, h,
-                              order=list("abdc"),
-                              hue_order=list("zyx"))
-        p.estimate_statistic("mean", ("ci", 95), 50000, None)
+        cat_idx = 0 if orient in "vx" else 1
+        val_idx = int(not cat_idx)
 
-        assert p.statistic.shape == (4, 3)
-        assert p.confint.shape == (4, 3, 2)
+        axis_objs = ax.xaxis, ax.yaxis
+        cat_axis = axis_objs[cat_idx]
 
-        rows = (g == "b") & (h == "x")
-        mean = y[rows].mean()
-        sem = y[rows].std() / np.sqrt(rows.sum())
-        half_ci = _normal_quantile_func(.975) * sem
-        ci = mean - half_ci, mean + half_ci
-        npt.assert_almost_equal(p.statistic[1, 2], mean)
-        npt.assert_array_almost_equal(p.confint[1, 2], ci, 2)
+        for i, label in enumerate(cat_axis.get_majorticklabels()):
 
-        npt.assert_array_equal(p.statistic[:, 0], [np.nan] * 4)
-        npt.assert_array_equal(p.statistic[2], [np.nan] * 3)
-        npt.assert_array_equal(p.confint[:, 0],
-                               np.zeros((4, 2)) * np.nan)
-        npt.assert_array_equal(p.confint[2],
-                               np.zeros((3, 2)) * np.nan)
+            key = label.get_text()
+            points = ax.collections[i]
+            point_pos = points.get_offsets().T
+            val_pos = point_pos[val_idx]
+            cat_pos = point_pos[cat_idx]
 
-    def test_sd_error_bars(self):
+            assert_array_equal(cat_pos.round(), i)
+            assert_array_equal(val_pos, wide_df[key])
 
-        p = cat._CategoricalStatPlotter()
+            for point_color in points.get_facecolors():
+                assert tuple(point_color) == to_rgba("C0")
 
-        g = pd.Series(np.repeat(list("abc"), 100))
-        y = pd.Series(np.random.RandomState(0).randn(300))
+    @pytest.mark.parametrize("orient", ["h", "v"])
+    def test_flat(self, flat_series, orient):
 
-        p.establish_variables(g, y)
-        p.estimate_statistic(np.mean, "sd", None, None)
+        ax = self.func(data=flat_series, orient=orient)
+        _draw_figure(ax.figure)
 
-        assert p.statistic.shape == (3,)
-        assert p.confint.shape == (3, 2)
+        cat_idx = ["v", "h"].index(orient)
+        val_idx = int(not cat_idx)
 
-        npt.assert_array_almost_equal(p.statistic,
-                                      y.groupby(g).mean())
+        points = ax.collections[0]
+        pos = points.get_offsets().T
 
-        for ci, (_, grp_y) in zip(p.confint, y.groupby(g)):
-            mean = grp_y.mean()
-            half_ci = np.std(grp_y)
-            ci_want = mean - half_ci, mean + half_ci
-            npt.assert_array_almost_equal(ci_want, ci, 2)
+        assert_array_equal(pos[cat_idx].round(), np.zeros(len(flat_series)))
+        assert_array_equal(pos[val_idx], flat_series)
 
-    def test_nested_sd_error_bars(self):
+    @pytest.mark.parametrize(
+        "variables,orient",
+        [
+            # Order matters for assigning to x/y
+            ({"cat": "a", "val": "y", "hue": None}, None),
+            ({"val": "y", "cat": "a", "hue": None}, None),
+            ({"cat": "a", "val": "y", "hue": "a"}, None),
+            ({"val": "y", "cat": "a", "hue": "a"}, None),
+            ({"cat": "a", "val": "y", "hue": "b"}, None),
+            ({"val": "y", "cat": "a", "hue": "x"}, None),
+            ({"cat": "s", "val": "y", "hue": None}, None),
+            ({"val": "y", "cat": "s", "hue": None}, "h"),
+            ({"cat": "a", "val": "b", "hue": None}, None),
+            ({"val": "a", "cat": "b", "hue": None}, "h"),
+            ({"cat": "a", "val": "t", "hue": None}, None),
+            ({"val": "t", "cat": "a", "hue": None}, None),
+            ({"cat": "d", "val": "y", "hue": None}, None),
+            ({"val": "y", "cat": "d", "hue": None}, None),
+            ({"cat": "a_cat", "val": "y", "hue": None}, None),
+            ({"val": "y", "cat": "s_cat", "hue": None}, None),
+        ],
+    )
+    def test_positions(self, long_df, variables, orient):
 
-        p = cat._CategoricalStatPlotter()
+        cat_var = variables["cat"]
+        val_var = variables["val"]
+        hue_var = variables["hue"]
+        var_names = list(variables.values())
+        x_var, y_var, *_ = var_names
 
-        g = pd.Series(np.repeat(list("abc"), 100))
-        h = pd.Series(np.tile(list("xy"), 150))
-        y = pd.Series(np.random.RandomState(0).randn(300))
+        ax = self.func(
+            data=long_df, x=x_var, y=y_var, hue=hue_var, orient=orient,
+        )
 
-        p.establish_variables(g, y, h)
-        p.estimate_statistic(np.mean, "sd", None, None)
+        _draw_figure(ax.figure)
 
-        assert p.statistic.shape == (3, 2)
-        assert p.confint.shape == (3, 2, 2)
+        cat_idx = var_names.index(cat_var)
+        val_idx = var_names.index(val_var)
 
-        npt.assert_array_almost_equal(p.statistic,
-                                      y.groupby([g, h]).mean().unstack())
+        axis_objs = ax.xaxis, ax.yaxis
+        cat_axis = axis_objs[cat_idx]
+        val_axis = axis_objs[val_idx]
 
-        for ci_g, (_, grp_y) in zip(p.confint, y.groupby(g)):
-            for ci, hue_y in zip(ci_g, [grp_y.iloc[::2], grp_y.iloc[1::2]]):
-                mean = hue_y.mean()
-                half_ci = np.std(hue_y)
-                ci_want = mean - half_ci, mean + half_ci
-                npt.assert_array_almost_equal(ci_want, ci, 2)
+        cat_data = long_df[cat_var]
+        cat_levels = categorical_order(cat_data)
 
-    def test_draw_cis(self):
+        for i, label in enumerate(cat_levels):
 
-        p = cat._CategoricalStatPlotter()
+            vals = long_df.loc[cat_data == label, val_var]
 
-        # Test vertical CIs
-        p.orient = "v"
+            points = ax.collections[i].get_offsets().T
+            cat_pos = points[var_names.index(cat_var)]
+            val_pos = points[var_names.index(val_var)]
 
-        f, ax = plt.subplots()
-        at_group = [0, 1]
-        confints = [(.5, 1.5), (.25, .8)]
-        colors = [".2", ".3"]
-        p.draw_confints(ax, at_group, confints, colors)
-
-        lines = ax.lines
-        for line, at, ci, c in zip(lines, at_group, confints, colors):
-            x, y = line.get_xydata().T
-            npt.assert_array_equal(x, [at, at])
-            npt.assert_array_equal(y, ci)
-            assert line.get_color() == c
+            assert_array_equal(val_pos, val_axis.convert_units(vals))
+            assert_array_equal(cat_pos.round(), i)
+            assert 0 <= np.ptp(cat_pos) <= .8
 
-        plt.close("all")
+            label = pd.Index([label]).astype(str)[0]
+            assert cat_axis.get_majorticklabels()[i].get_text() == label
 
-        # Test horizontal CIs
-        p.orient = "h"
+    @pytest.mark.parametrize(
+        "variables",
+        [
+            # Order matters for assigning to x/y
+            {"cat": "a", "val": "y", "hue": "b"},
+            {"val": "y", "cat": "a", "hue": "c"},
+            {"cat": "a", "val": "y", "hue": "f"},
+        ],
+    )
+    def test_positions_dodged(self, long_df, variables):
 
-        f, ax = plt.subplots()
-        p.draw_confints(ax, at_group, confints, colors)
+        cat_var = variables["cat"]
+        val_var = variables["val"]
+        hue_var = variables["hue"]
+        var_names = list(variables.values())
+        x_var, y_var, *_ = var_names
 
-        lines = ax.lines
-        for line, at, ci, c in zip(lines, at_group, confints, colors):
-            x, y = line.get_xydata().T
-            npt.assert_array_equal(x, ci)
-            npt.assert_array_equal(y, [at, at])
-            assert line.get_color() == c
+        ax = self.func(
+            data=long_df, x=x_var, y=y_var, hue=hue_var, dodge=True,
+        )
 
-        plt.close("all")
+        cat_vals = categorical_order(long_df[cat_var])
+        hue_vals = categorical_order(long_df[hue_var])
 
-        # Test vertical CIs with endcaps
-        p.orient = "v"
+        n_hue = len(hue_vals)
+        offsets = np.linspace(0, .8, n_hue + 1)[:-1]
+        offsets -= offsets.mean()
+        nest_width = .8 / n_hue
 
-        f, ax = plt.subplots()
-        p.draw_confints(ax, at_group, confints, colors, capsize=0.3)
-        capline = ax.lines[len(ax.lines) - 1]
-        caplinestart = capline.get_xdata()[0]
-        caplineend = capline.get_xdata()[1]
-        caplinelength = abs(caplineend - caplinestart)
-        assert caplinelength == approx(0.3)
-        assert len(ax.lines) == 6
+        for i, cat_val in enumerate(cat_vals):
+            for j, hue_val in enumerate(hue_vals):
+                rows = (long_df[cat_var] == cat_val) & (long_df[hue_var] == hue_val)
+                vals = long_df.loc[rows, val_var]
 
-        plt.close("all")
+                points = ax.collections[n_hue * i + j].get_offsets().T
+                cat_pos = points[var_names.index(cat_var)]
+                val_pos = points[var_names.index(val_var)]
 
-        # Test horizontal CIs with endcaps
-        p.orient = "h"
+                if pd.api.types.is_datetime64_any_dtype(vals):
+                    vals = mpl.dates.date2num(vals)
 
-        f, ax = plt.subplots()
-        p.draw_confints(ax, at_group, confints, colors, capsize=0.3)
-        capline = ax.lines[len(ax.lines) - 1]
-        caplinestart = capline.get_ydata()[0]
-        caplineend = capline.get_ydata()[1]
-        caplinelength = abs(caplineend - caplinestart)
-        assert caplinelength == approx(0.3)
-        assert len(ax.lines) == 6
-
-        # Test extra keyword arguments
-        f, ax = plt.subplots()
-        p.draw_confints(ax, at_group, confints, colors, lw=4)
-        line = ax.lines[0]
-        assert line.get_linewidth() == 4
+                assert_array_equal(val_pos, vals)
 
-        plt.close("all")
+                assert_array_equal(cat_pos.round(), i)
+                assert_array_equal((cat_pos - (i + offsets[j])).round() / nest_width, 0)
+                assert 0 <= np.ptp(cat_pos) <= nest_width
 
-        # Test errwidth is set appropriately
-        f, ax = plt.subplots()
-        p.draw_confints(ax, at_group, confints, colors, errwidth=2)
-        capline = ax.lines[len(ax.lines) - 1]
-        assert capline._linewidth == 2
-        assert len(ax.lines) == 2
+    @pytest.mark.parametrize("cat_var", ["a", "s", "d"])
+    def test_positions_unfixed(self, long_df, cat_var):
 
-        plt.close("all")
+        long_df = long_df.sort_values(cat_var)
 
+        kws = dict(size=.001)
+        if "stripplot" in str(self.func):  # can't use __name__ with partial
+            kws["jitter"] = False
 
-class TestBoxPlotter(CategoricalFixture):
+        ax = self.func(data=long_df, x=cat_var, y="y", native_scale=True, **kws)
 
-    default_kws = dict(x=None, y=None, hue=None, data=None,
-                       order=None, hue_order=None,
-                       orient=None, color=None, palette=None,
-                       saturation=.75, width=.8, dodge=True,
-                       fliersize=5, linewidth=None)
+        for i, (cat_level, cat_data) in enumerate(long_df.groupby(cat_var)):
 
-    def test_nested_width(self):
+            points = ax.collections[i].get_offsets().T
+            cat_pos = points[0]
+            val_pos = points[1]
 
-        kws = self.default_kws.copy()
-        p = cat._BoxPlotter(**kws)
-        p.establish_variables("g", "y", hue="h", data=self.df)
-        assert p.nested_width == .4 * .98
+            assert_array_equal(val_pos, cat_data["y"])
 
-        kws = self.default_kws.copy()
-        kws["width"] = .6
-        p = cat._BoxPlotter(**kws)
-        p.establish_variables("g", "y", hue="h", data=self.df)
-        assert p.nested_width == .3 * .98
+            comp_level = np.squeeze(ax.xaxis.convert_units(cat_level)).item()
+            assert_array_equal(cat_pos.round(), comp_level)
 
-        kws = self.default_kws.copy()
-        kws["dodge"] = False
-        p = cat._BoxPlotter(**kws)
-        p.establish_variables("g", "y", hue="h", data=self.df)
-        assert p.nested_width == .8
+    @pytest.mark.parametrize(
+        "x_type,order",
+        [
+            (str, None),
+            (str, ["a", "b", "c"]),
+            (str, ["c", "a"]),
+            (str, ["a", "b", "c", "d"]),
+            (int, None),
+            (int, [3, 1, 2]),
+            (int, [3, 1]),
+            (int, [1, 2, 3, 4]),
+            (int, ["3", "1", "2"]),
+        ]
+    )
+    def test_order(self, x_type, order):
 
-    def test_hue_offsets(self):
+        if x_type is str:
+            x = ["b", "a", "c"]
+        else:
+            x = [2, 1, 3]
+        y = [1, 2, 3]
 
-        p = cat._BoxPlotter(**self.default_kws)
-        p.establish_variables("g", "y", hue="h", data=self.df)
-        npt.assert_array_equal(p.hue_offsets, [-.2, .2])
+        ax = self.func(x=x, y=y, order=order)
+        _draw_figure(ax.figure)
 
-        kws = self.default_kws.copy()
-        kws["width"] = .6
-        p = cat._BoxPlotter(**kws)
-        p.establish_variables("g", "y", hue="h", data=self.df)
-        npt.assert_array_equal(p.hue_offsets, [-.15, .15])
+        if order is None:
+            order = x
+            if x_type is int:
+                order = np.sort(order)
 
-        p = cat._BoxPlotter(**kws)
-        p.establish_variables("h", "y", "g", data=self.df)
-        npt.assert_array_almost_equal(p.hue_offsets, [-.2, 0, .2])
+        assert len(ax.collections) == len(order)
+        tick_labels = ax.xaxis.get_majorticklabels()
 
-    def test_axes_data(self):
+        assert ax.get_xlim()[1] == (len(order) - .5)
 
-        ax = cat.boxplot(x="g", y="y", data=self.df)
-        assert len(self.get_box_artists(ax)) == 3
+        for i, points in enumerate(ax.collections):
+            cat = order[i]
+            assert tick_labels[i].get_text() == str(cat)
 
-        plt.close("all")
+            positions = points.get_offsets()
+            if x_type(cat) in x:
+                val = y[x.index(x_type(cat))]
+                assert positions[0, 1] == val
+            else:
+                assert not positions.size
 
-        ax = cat.boxplot(x="g", y="y", hue="h", data=self.df)
-        assert len(self.get_box_artists(ax)) == 6
+    @pytest.mark.parametrize("hue_var", ["a", "b"])
+    def test_hue_categorical(self, long_df, hue_var):
 
-        plt.close("all")
-
-    def test_box_colors(self):
-
-        ax = cat.boxplot(x="g", y="y", data=self.df, saturation=1)
-        pal = palettes.color_palette(n_colors=3)
-        assert same_color([patch.get_facecolor() for patch in self.get_box_artists(ax)],
-                          pal)
-
-        plt.close("all")
-
-        ax = cat.boxplot(x="g", y="y", hue="h", data=self.df, saturation=1)
-        pal = palettes.color_palette(n_colors=2)
-        assert same_color([patch.get_facecolor() for patch in self.get_box_artists(ax)],
-                          pal * 3)
-
-        plt.close("all")
-
-    def test_draw_missing_boxes(self):
-
-        ax = cat.boxplot(x="g", y="y", data=self.df,
-                         order=["a", "b", "c", "d"])
-        assert len(self.get_box_artists(ax)) == 3
-
-    def test_missing_data(self):
-
-        x = ["a", "a", "b", "b", "c", "c", "d", "d"]
-        h = ["x", "y", "x", "y", "x", "y", "x", "y"]
-        y = self.rs.randn(8)
-        y[-2:] = np.nan
-
-        ax = cat.boxplot(x=x, y=y)
-        assert len(self.get_box_artists(ax)) == 3
-
-        plt.close("all")
-
-        y[-1] = 0
-        ax = cat.boxplot(x=x, y=y, hue=h)
-        assert len(self.get_box_artists(ax)) == 7
-
-        plt.close("all")
-
-    def test_unaligned_index(self):
-
-        f, (ax1, ax2) = plt.subplots(2)
-        cat.boxplot(x=self.g, y=self.y, ax=ax1)
-        cat.boxplot(x=self.g, y=self.y_perm, ax=ax2)
-        for l1, l2 in zip(ax1.lines, ax2.lines):
-            assert np.array_equal(l1.get_xydata(), l2.get_xydata())
-
-        f, (ax1, ax2) = plt.subplots(2)
-        hue_order = self.h.unique()
-        cat.boxplot(x=self.g, y=self.y, hue=self.h,
-                    hue_order=hue_order, ax=ax1)
-        cat.boxplot(x=self.g, y=self.y_perm, hue=self.h,
-                    hue_order=hue_order, ax=ax2)
-        for l1, l2 in zip(ax1.lines, ax2.lines):
-            assert np.array_equal(l1.get_xydata(), l2.get_xydata())
-
-    def test_boxplots(self):
-
-        # Smoke test the high level boxplot options
-
-        cat.boxplot(x="y", data=self.df)
-        plt.close("all")
-
-        cat.boxplot(y="y", data=self.df)
-        plt.close("all")
-
-        cat.boxplot(x="g", y="y", data=self.df)
-        plt.close("all")
-
-        cat.boxplot(x="y", y="g", data=self.df, orient="h")
-        plt.close("all")
-
-        cat.boxplot(x="g", y="y", hue="h", data=self.df)
-        plt.close("all")
-
-        cat.boxplot(x="g", y="y", hue="h", order=list("nabc"), data=self.df)
-        plt.close("all")
-
-        cat.boxplot(x="g", y="y", hue="h", hue_order=list("omn"), data=self.df)
-        plt.close("all")
-
-        cat.boxplot(x="y", y="g", hue="h", data=self.df, orient="h")
-        plt.close("all")
-
-    def test_axes_annotation(self):
-
-        ax = cat.boxplot(x="g", y="y", data=self.df)
-        assert ax.get_xlabel() == "g"
-        assert ax.get_ylabel() == "y"
-        assert ax.get_xlim() == (-.5, 2.5)
-        npt.assert_array_equal(ax.get_xticks(), [0, 1, 2])
-        npt.assert_array_equal([l.get_text() for l in ax.get_xticklabels()],
-                               ["a", "b", "c"])
-
-        plt.close("all")
-
-        ax = cat.boxplot(x="g", y="y", hue="h", data=self.df)
-        assert ax.get_xlabel() == "g"
-        assert ax.get_ylabel() == "y"
-        npt.assert_array_equal(ax.get_xticks(), [0, 1, 2])
-        npt.assert_array_equal([l.get_text() for l in ax.get_xticklabels()],
-                               ["a", "b", "c"])
-        npt.assert_array_equal([l.get_text() for l in ax.legend_.get_texts()],
-                               ["m", "n"])
-
-        plt.close("all")
-
-        ax = cat.boxplot(x="y", y="g", data=self.df, orient="h")
-        assert ax.get_xlabel() == "y"
-        assert ax.get_ylabel() == "g"
-        assert ax.get_ylim() == (2.5, -.5)
-        npt.assert_array_equal(ax.get_yticks(), [0, 1, 2])
-        npt.assert_array_equal([l.get_text() for l in ax.get_yticklabels()],
-                               ["a", "b", "c"])
-
-        plt.close("all")
-
-
-class TestViolinPlotter(CategoricalFixture):
-
-    default_kws = dict(x=None, y=None, hue=None, data=None,
-                       order=None, hue_order=None,
-                       bw="scott", cut=2, scale="area", scale_hue=True,
-                       gridsize=100, width=.8, inner="box", split=False,
-                       dodge=True, orient=None, linewidth=None,
-                       color=None, palette=None, saturation=.75)
-
-    def test_split_error(self):
-
-        kws = self.default_kws.copy()
-        kws.update(dict(x="h", y="y", hue="g", data=self.df, split=True))
-
-        with pytest.raises(ValueError):
-            cat._ViolinPlotter(**kws)
-
-    def test_no_observations(self):
-
-        p = cat._ViolinPlotter(**self.default_kws)
-
-        x = ["a", "a", "b"]
-        y = self.rs.randn(3)
-        y[-1] = np.nan
-        p.establish_variables(x, y)
-        p.estimate_densities("scott", 2, "area", True, 20)
-
-        assert len(p.support[0]) == 20
-        assert len(p.support[1]) == 0
-
-        assert len(p.density[0]) == 20
-        assert len(p.density[1]) == 1
-
-        assert p.density[1].item() == 1
-
-        p.estimate_densities("scott", 2, "count", True, 20)
-        assert p.density[1].item() == 0
-
-        x = ["a"] * 4 + ["b"] * 2
-        y = self.rs.randn(6)
-        h = ["m", "n"] * 2 + ["m"] * 2
-
-        p.establish_variables(x, y, hue=h)
-        p.estimate_densities("scott", 2, "area", True, 20)
-
-        assert len(p.support[1][0]) == 20
-        assert len(p.support[1][1]) == 0
-
-        assert len(p.density[1][0]) == 20
-        assert len(p.density[1][1]) == 1
-
-        assert p.density[1][1].item() == 1
-
-        p.estimate_densities("scott", 2, "count", False, 20)
-        assert p.density[1][1].item() == 0
-
-    def test_single_observation(self):
-
-        p = cat._ViolinPlotter(**self.default_kws)
-
-        x = ["a", "a", "b"]
-        y = self.rs.randn(3)
-        p.establish_variables(x, y)
-        p.estimate_densities("scott", 2, "area", True, 20)
-
-        assert len(p.support[0]) == 20
-        assert len(p.support[1]) == 1
-
-        assert len(p.density[0]) == 20
-        assert len(p.density[1]) == 1
-
-        assert p.density[1].item() == 1
-
-        p.estimate_densities("scott", 2, "count", True, 20)
-        assert p.density[1].item() == .5
-
-        x = ["b"] * 4 + ["a"] * 3
-        y = self.rs.randn(7)
-        h = (["m", "n"] * 4)[:-1]
-
-        p.establish_variables(x, y, hue=h)
-        p.estimate_densities("scott", 2, "area", True, 20)
-
-        assert len(p.support[1][0]) == 20
-        assert len(p.support[1][1]) == 1
-
-        assert len(p.density[1][0]) == 20
-        assert len(p.density[1][1]) == 1
-
-        assert p.density[1][1].item() == 1
-
-        p.estimate_densities("scott", 2, "count", False, 20)
-        assert p.density[1][1].item() == .5
-
-    def test_dwidth(self):
-
-        kws = self.default_kws.copy()
-        kws.update(dict(x="g", y="y", data=self.df))
-
-        p = cat._ViolinPlotter(**kws)
-        assert p.dwidth == .4
-
-        kws.update(dict(width=.4))
-        p = cat._ViolinPlotter(**kws)
-        assert p.dwidth == .2
-
-        kws.update(dict(hue="h", width=.8))
-        p = cat._ViolinPlotter(**kws)
-        assert p.dwidth == .2
-
-        kws.update(dict(split=True))
-        p = cat._ViolinPlotter(**kws)
-        assert p.dwidth == .4
-
-    def test_scale_area(self):
-
-        kws = self.default_kws.copy()
-        kws["scale"] = "area"
-        p = cat._ViolinPlotter(**kws)
-
-        # Test single layer of grouping
-        p.hue_names = None
-        density = [self.rs.uniform(0, .8, 50), self.rs.uniform(0, .2, 50)]
-        max_before = np.array([d.max() for d in density])
-        p.scale_area(density, max_before, False)
-        max_after = np.array([d.max() for d in density])
-        assert max_after[0] == 1
-
-        before_ratio = max_before[1] / max_before[0]
-        after_ratio = max_after[1] / max_after[0]
-        assert before_ratio == after_ratio
-
-        # Test nested grouping scaling across all densities
-        p.hue_names = ["foo", "bar"]
-        density = [[self.rs.uniform(0, .8, 50), self.rs.uniform(0, .2, 50)],
-                   [self.rs.uniform(0, .1, 50), self.rs.uniform(0, .02, 50)]]
-
-        max_before = np.array([[r.max() for r in row] for row in density])
-        p.scale_area(density, max_before, False)
-        max_after = np.array([[r.max() for r in row] for row in density])
-        assert max_after[0, 0] == 1
-
-        before_ratio = max_before[1, 1] / max_before[0, 0]
-        after_ratio = max_after[1, 1] / max_after[0, 0]
-        assert before_ratio == after_ratio
-
-        # Test nested grouping scaling within hue
-        p.hue_names = ["foo", "bar"]
-        density = [[self.rs.uniform(0, .8, 50), self.rs.uniform(0, .2, 50)],
-                   [self.rs.uniform(0, .1, 50), self.rs.uniform(0, .02, 50)]]
-
-        max_before = np.array([[r.max() for r in row] for row in density])
-        p.scale_area(density, max_before, True)
-        max_after = np.array([[r.max() for r in row] for row in density])
-        assert max_after[0, 0] == 1
-        assert max_after[1, 0] == 1
-
-        before_ratio = max_before[1, 1] / max_before[1, 0]
-        after_ratio = max_after[1, 1] / max_after[1, 0]
-        assert before_ratio == after_ratio
-
-    def test_scale_width(self):
-
-        kws = self.default_kws.copy()
-        kws["scale"] = "width"
-        p = cat._ViolinPlotter(**kws)
-
-        # Test single layer of grouping
-        p.hue_names = None
-        density = [self.rs.uniform(0, .8, 50), self.rs.uniform(0, .2, 50)]
-        p.scale_width(density)
-        max_after = np.array([d.max() for d in density])
-        npt.assert_array_equal(max_after, [1, 1])
-
-        # Test nested grouping
-        p.hue_names = ["foo", "bar"]
-        density = [[self.rs.uniform(0, .8, 50), self.rs.uniform(0, .2, 50)],
-                   [self.rs.uniform(0, .1, 50), self.rs.uniform(0, .02, 50)]]
-
-        p.scale_width(density)
-        max_after = np.array([[r.max() for r in row] for row in density])
-        npt.assert_array_equal(max_after, [[1, 1], [1, 1]])
-
-    def test_scale_count(self):
-
-        kws = self.default_kws.copy()
-        kws["scale"] = "count"
-        p = cat._ViolinPlotter(**kws)
-
-        # Test single layer of grouping
-        p.hue_names = None
-        density = [self.rs.uniform(0, .8, 20), self.rs.uniform(0, .2, 40)]
-        counts = np.array([20, 40])
-        p.scale_count(density, counts, False)
-        max_after = np.array([d.max() for d in density])
-        npt.assert_array_equal(max_after, [.5, 1])
-
-        # Test nested grouping scaling across all densities
-        p.hue_names = ["foo", "bar"]
-        density = [[self.rs.uniform(0, .8, 5), self.rs.uniform(0, .2, 40)],
-                   [self.rs.uniform(0, .1, 100), self.rs.uniform(0, .02, 50)]]
-
-        counts = np.array([[5, 40], [100, 50]])
-        p.scale_count(density, counts, False)
-        max_after = np.array([[r.max() for r in row] for row in density])
-        npt.assert_array_equal(max_after, [[.05, .4], [1, .5]])
-
-        # Test nested grouping scaling within hue
-        p.hue_names = ["foo", "bar"]
-        density = [[self.rs.uniform(0, .8, 5), self.rs.uniform(0, .2, 40)],
-                   [self.rs.uniform(0, .1, 100), self.rs.uniform(0, .02, 50)]]
-
-        counts = np.array([[5, 40], [100, 50]])
-        p.scale_count(density, counts, True)
-        max_after = np.array([[r.max() for r in row] for row in density])
-        npt.assert_array_equal(max_after, [[.125, 1], [1, .5]])
-
-    def test_bad_scale(self):
-
-        kws = self.default_kws.copy()
-        kws["scale"] = "not_a_scale_type"
-        with pytest.raises(ValueError):
-            cat._ViolinPlotter(**kws)
-
-    def test_kde_fit(self):
-
-        p = cat._ViolinPlotter(**self.default_kws)
-        data = self.y
-        data_std = data.std(ddof=1)
-
-        # Test reference rule bandwidth
-        kde, bw = p.fit_kde(data, "scott")
-        assert kde.factor == kde.scotts_factor()
-        assert bw == kde.scotts_factor() * data_std
-
-        # Test numeric scale factor
-        kde, bw = p.fit_kde(self.y, .2)
-        assert kde.factor == .2
-        assert bw == .2 * data_std
-
-    def test_draw_to_density(self):
-
-        p = cat._ViolinPlotter(**self.default_kws)
-        # p.dwidth will be 1 for easier testing
-        p.width = 2
-
-        # Test vertical plots
-        support = np.array([.2, .6])
-        density = np.array([.1, .4])
-
-        # Test full vertical plot
-        _, ax = plt.subplots()
-        p.draw_to_density(ax, 0, .5, support, density, False)
-        x, y = ax.lines[0].get_xydata().T
-        npt.assert_array_equal(x, [.99 * -.4, .99 * .4])
-        npt.assert_array_equal(y, [.5, .5])
-        plt.close("all")
-
-        # Test left vertical plot
-        _, ax = plt.subplots()
-        p.draw_to_density(ax, 0, .5, support, density, "left")
-        x, y = ax.lines[0].get_xydata().T
-        npt.assert_array_equal(x, [.99 * -.4, 0])
-        npt.assert_array_equal(y, [.5, .5])
-        plt.close("all")
-
-        # Test right vertical plot
-        _, ax = plt.subplots()
-        p.draw_to_density(ax, 0, .5, support, density, "right")
-        x, y = ax.lines[0].get_xydata().T
-        npt.assert_array_equal(x, [0, .99 * .4])
-        npt.assert_array_equal(y, [.5, .5])
-        plt.close("all")
-
-        # Switch orientation to test horizontal plots
-        p.orient = "h"
-        support = np.array([.2, .5])
-        density = np.array([.3, .7])
-
-        # Test full horizontal plot
-        _, ax = plt.subplots()
-        p.draw_to_density(ax, 0, .6, support, density, False)
-        x, y = ax.lines[0].get_xydata().T
-        npt.assert_array_equal(x, [.6, .6])
-        npt.assert_array_equal(y, [.99 * -.7, .99 * .7])
-        plt.close("all")
-
-        # Test left horizontal plot
-        _, ax = plt.subplots()
-        p.draw_to_density(ax, 0, .6, support, density, "left")
-        x, y = ax.lines[0].get_xydata().T
-        npt.assert_array_equal(x, [.6, .6])
-        npt.assert_array_equal(y, [.99 * -.7, 0])
-        plt.close("all")
-
-        # Test right horizontal plot
-        _, ax = plt.subplots()
-        p.draw_to_density(ax, 0, .6, support, density, "right")
-        x, y = ax.lines[0].get_xydata().T
-        npt.assert_array_equal(x, [.6, .6])
-        npt.assert_array_equal(y, [0, .99 * .7])
-        plt.close("all")
-
-    def test_draw_single_observations(self):
-
-        p = cat._ViolinPlotter(**self.default_kws)
-        p.width = 2
-
-        # Test vertical plot
-        _, ax = plt.subplots()
-        p.draw_single_observation(ax, 1, 1.5, 1)
-        x, y = ax.lines[0].get_xydata().T
-        npt.assert_array_equal(x, [0, 2])
-        npt.assert_array_equal(y, [1.5, 1.5])
-        plt.close("all")
-
-        # Test horizontal plot
-        p.orient = "h"
-        _, ax = plt.subplots()
-        p.draw_single_observation(ax, 2, 2.2, .5)
-        x, y = ax.lines[0].get_xydata().T
-        npt.assert_array_equal(x, [2.2, 2.2])
-        npt.assert_array_equal(y, [1.5, 2.5])
-        plt.close("all")
-
-    def test_draw_box_lines(self):
-
-        # Test vertical plot
-        kws = self.default_kws.copy()
-        kws.update(dict(y="y", data=self.df, inner=None))
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_box_lines(ax, self.y, 0)
-        assert len(ax.lines) == 2
-
-        q25, q50, q75 = np.percentile(self.y, [25, 50, 75])
-        _, y = ax.lines[1].get_xydata().T
-        npt.assert_array_equal(y, [q25, q75])
-
-        _, y = ax.collections[0].get_offsets().T
-        assert y == q50
-
-        plt.close("all")
-
-        # Test horizontal plot
-        kws = self.default_kws.copy()
-        kws.update(dict(x="y", data=self.df, inner=None))
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_box_lines(ax, self.y, 0)
-        assert len(ax.lines) == 2
-
-        q25, q50, q75 = np.percentile(self.y, [25, 50, 75])
-        x, _ = ax.lines[1].get_xydata().T
-        npt.assert_array_equal(x, [q25, q75])
-
-        x, _ = ax.collections[0].get_offsets().T
-        assert x == q50
-
-        plt.close("all")
-
-    def test_draw_quartiles(self):
-
-        kws = self.default_kws.copy()
-        kws.update(dict(y="y", data=self.df, inner=None))
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_quartiles(ax, self.y, p.support[0], p.density[0], 0)
-        for val, line in zip(np.percentile(self.y, [25, 50, 75]), ax.lines):
-            _, y = line.get_xydata().T
-            npt.assert_array_equal(y, [val, val])
-
-    def test_draw_points(self):
-
-        p = cat._ViolinPlotter(**self.default_kws)
-
-        # Test vertical plot
-        _, ax = plt.subplots()
-        p.draw_points(ax, self.y, 0)
-        x, y = ax.collections[0].get_offsets().T
-        npt.assert_array_equal(x, np.zeros_like(self.y))
-        npt.assert_array_equal(y, self.y)
-        plt.close("all")
-
-        # Test horizontal plot
-        p.orient = "h"
-        _, ax = plt.subplots()
-        p.draw_points(ax, self.y, 0)
-        x, y = ax.collections[0].get_offsets().T
-        npt.assert_array_equal(x, self.y)
-        npt.assert_array_equal(y, np.zeros_like(self.y))
-        plt.close("all")
-
-    def test_draw_sticks(self):
-
-        kws = self.default_kws.copy()
-        kws.update(dict(y="y", data=self.df, inner=None))
-        p = cat._ViolinPlotter(**kws)
-
-        # Test vertical plot
-        _, ax = plt.subplots()
-        p.draw_stick_lines(ax, self.y, p.support[0], p.density[0], 0)
-        for val, line in zip(self.y, ax.lines):
-            _, y = line.get_xydata().T
-            npt.assert_array_equal(y, [val, val])
-        plt.close("all")
-
-        # Test horizontal plot
-        p.orient = "h"
-        _, ax = plt.subplots()
-        p.draw_stick_lines(ax, self.y, p.support[0], p.density[0], 0)
-        for val, line in zip(self.y, ax.lines):
-            x, _ = line.get_xydata().T
-            npt.assert_array_equal(x, [val, val])
-        plt.close("all")
-
-    def test_validate_inner(self):
-
-        kws = self.default_kws.copy()
-        kws.update(dict(inner="bad_inner"))
-        with pytest.raises(ValueError):
-            cat._ViolinPlotter(**kws)
-
-    def test_draw_violinplots(self):
-
-        kws = self.default_kws.copy()
-
-        # Test single vertical violin
-        kws.update(dict(y="y", data=self.df, inner=None,
-                        saturation=1, color=(1, 0, 0, 1)))
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 1
-        npt.assert_array_equal(ax.collections[0].get_facecolors(),
-                               [(1, 0, 0, 1)])
-        plt.close("all")
-
-        # Test single horizontal violin
-        kws.update(dict(x="y", y=None, color=(0, 1, 0, 1)))
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 1
-        npt.assert_array_equal(ax.collections[0].get_facecolors(),
-                               [(0, 1, 0, 1)])
-        plt.close("all")
-
-        # Test multiple vertical violins
-        kws.update(dict(x="g", y="y", color=None,))
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 3
-        for violin, color in zip(ax.collections, palettes.color_palette()):
-            npt.assert_array_equal(violin.get_facecolors()[0, :-1], color)
-        plt.close("all")
-
-        # Test multiple violins with hue nesting
-        kws.update(dict(hue="h"))
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 6
-        for violin, color in zip(ax.collections,
-                                 palettes.color_palette(n_colors=2) * 3):
-            npt.assert_array_equal(violin.get_facecolors()[0, :-1], color)
-        plt.close("all")
-
-        # Test multiple split violins
-        kws.update(dict(split=True, palette="muted"))
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 6
-        for violin, color in zip(ax.collections,
-                                 palettes.color_palette("muted",
-                                                        n_colors=2) * 3):
-            npt.assert_array_equal(violin.get_facecolors()[0, :-1], color)
-        plt.close("all")
-
-    def test_draw_violinplots_no_observations(self):
-
-        kws = self.default_kws.copy()
-        kws["inner"] = None
-
-        # Test single layer of grouping
-        x = ["a", "a", "b"]
-        y = self.rs.randn(3)
-        y[-1] = np.nan
-        kws.update(x=x, y=y)
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 1
-        assert len(ax.lines) == 0
-        plt.close("all")
-
-        # Test nested hue grouping
-        x = ["a"] * 4 + ["b"] * 2
-        y = self.rs.randn(6)
-        h = ["m", "n"] * 2 + ["m"] * 2
-        kws.update(x=x, y=y, hue=h)
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 3
-        assert len(ax.lines) == 0
-        plt.close("all")
-
-    def test_draw_violinplots_single_observations(self):
-
-        kws = self.default_kws.copy()
-        kws["inner"] = None
-
-        # Test single layer of grouping
-        x = ["a", "a", "b"]
-        y = self.rs.randn(3)
-        kws.update(x=x, y=y)
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 1
-        assert len(ax.lines) == 1
-        plt.close("all")
-
-        # Test nested hue grouping
-        x = ["b"] * 4 + ["a"] * 3
-        y = self.rs.randn(7)
-        h = (["m", "n"] * 4)[:-1]
-        kws.update(x=x, y=y, hue=h)
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 3
-        assert len(ax.lines) == 1
-        plt.close("all")
-
-        # Test nested hue grouping with split
-        kws["split"] = True
-        p = cat._ViolinPlotter(**kws)
-
-        _, ax = plt.subplots()
-        p.draw_violins(ax)
-        assert len(ax.collections) == 3
-        assert len(ax.lines) == 1
-        plt.close("all")
-
-    def test_violinplots(self):
-
-        # Smoke test the high level violinplot options
-
-        cat.violinplot(x="y", data=self.df)
-        plt.close("all")
-
-        cat.violinplot(y="y", data=self.df)
-        plt.close("all")
-
-        cat.violinplot(x="g", y="y", data=self.df)
-        plt.close("all")
-
-        cat.violinplot(x="y", y="g", data=self.df, orient="h")
-        plt.close("all")
-
-        cat.violinplot(x="g", y="y", hue="h", data=self.df)
-        plt.close("all")
-
-        order = list("nabc")
-        cat.violinplot(x="g", y="y", hue="h", order=order, data=self.df)
-        plt.close("all")
-
-        order = list("omn")
-        cat.violinplot(x="g", y="y", hue="h", hue_order=order, data=self.df)
-        plt.close("all")
-
-        cat.violinplot(x="y", y="g", hue="h", data=self.df, orient="h")
-        plt.close("all")
-
-        for inner in ["box", "quart", "point", "stick", None]:
-            cat.violinplot(x="g", y="y", data=self.df, inner=inner)
-            plt.close("all")
-
-            cat.violinplot(x="g", y="y", hue="h", data=self.df, inner=inner)
-            plt.close("all")
-
-            cat.violinplot(x="g", y="y", hue="h", data=self.df,
-                           inner=inner, split=True)
-            plt.close("all")
-
-    def test_split_one_each(self, rng):
-
-        x = np.repeat([0, 1], 5)
-        y = rng.normal(0, 1, 10)
-        ax = cat.violinplot(x=x, y=y, hue=x, split=True, inner="box")
-        assert len(ax.lines) == 4
-
-
-# ====================================================================================
-# ====================================================================================
-
-
-class SharedAxesLevelTests:
-
-    def test_color(self, long_df):
-
-        ax = plt.figure().subplots()
-        self.func(data=long_df, x="a", y="y", ax=ax)
-        assert self.get_last_color(ax) == to_rgba("C0")
-
-        ax = plt.figure().subplots()
-        self.func(data=long_df, x="a", y="y", ax=ax)
-        self.func(data=long_df, x="a", y="y", ax=ax)
-        assert self.get_last_color(ax) == to_rgba("C1")
-
-        ax = plt.figure().subplots()
-        self.func(data=long_df, x="a", y="y", color="C2", ax=ax)
-        assert self.get_last_color(ax) == to_rgba("C2")
-
-        ax = plt.figure().subplots()
-        self.func(data=long_df, x="a", y="y", color="C3", ax=ax)
-        assert self.get_last_color(ax) == to_rgba("C3")
-
-    def test_two_calls(self):
-
-        ax = plt.figure().subplots()
-        self.func(x=["a", "b", "c"], y=[1, 2, 3], ax=ax)
-        self.func(x=["e", "f"], y=[4, 5], ax=ax)
-        assert ax.get_xlim() == (-.5, 4.5)
-
-
-class SharedScatterTests(SharedAxesLevelTests):
-    """Tests functionality common to stripplot and swarmplot."""
-
-    def get_last_color(self, ax):
-
-        colors = ax.collections[-1].get_facecolors()
-        unique_colors = np.unique(colors, axis=0)
-        assert len(unique_colors) == 1
-        return to_rgba(unique_colors.squeeze())
-
-    # ------------------------------------------------------------------------------
-
-    def test_color(self, long_df):
-
-        super().test_color(long_df)
-
-        ax = plt.figure().subplots()
-        self.func(data=long_df, x="a", y="y", facecolor="C4", ax=ax)
-        assert self.get_last_color(ax) == to_rgba("C4")
-
-        ax = plt.figure().subplots()
-        self.func(data=long_df, x="a", y="y", fc="C5", ax=ax)
-        assert self.get_last_color(ax) == to_rgba("C5")
-
-    def test_supplied_color_array(self, long_df):
-
-        cmap = get_colormap("Blues")
-        norm = mpl.colors.Normalize()
-        colors = cmap(norm(long_df["y"].to_numpy()))
-
-        keys = ["c", "fc", "facecolor", "facecolors"]
-
-        for key in keys:
-
-            ax = plt.figure().subplots()
-            self.func(x=long_df["y"], **{key: colors})
-            _draw_figure(ax.figure)
-            assert_array_equal(ax.collections[0].get_facecolors(), colors)
-
-        ax = plt.figure().subplots()
-        self.func(x=long_df["y"], c=long_df["y"], cmap=cmap)
-        _draw_figure(ax.figure)
-        assert_array_equal(ax.collections[0].get_facecolors(), colors)
-
-    @pytest.mark.parametrize(
-        "orient,data_type",
-        itertools.product(["h", "v"], ["dataframe", "dict"]),
-    )
-    def test_wide(self, wide_df, orient, data_type):
-
-        if data_type == "dict":
-            wide_df = {k: v.to_numpy() for k, v in wide_df.items()}
-
-        ax = self.func(data=wide_df, orient=orient)
-        _draw_figure(ax.figure)
-        palette = color_palette()
-
-        cat_idx = 0 if orient == "v" else 1
-        val_idx = int(not cat_idx)
-
-        axis_objs = ax.xaxis, ax.yaxis
-        cat_axis = axis_objs[cat_idx]
-
-        for i, label in enumerate(cat_axis.get_majorticklabels()):
-
-            key = label.get_text()
-            points = ax.collections[i]
-            point_pos = points.get_offsets().T
-            val_pos = point_pos[val_idx]
-            cat_pos = point_pos[cat_idx]
-
-            assert_array_equal(cat_pos.round(), i)
-            assert_array_equal(val_pos, wide_df[key])
-
-            for point_color in points.get_facecolors():
-                assert tuple(point_color) == to_rgba(palette[i])
-
-    @pytest.mark.parametrize("orient", ["h", "v"])
-    def test_flat(self, flat_series, orient):
-
-        ax = self.func(data=flat_series, orient=orient)
-        _draw_figure(ax.figure)
-
-        cat_idx = ["v", "h"].index(orient)
-        val_idx = int(not cat_idx)
-
-        points = ax.collections[0]
-        pos = points.get_offsets().T
-
-        assert_array_equal(pos[cat_idx].round(), np.zeros(len(flat_series)))
-        assert_array_equal(pos[val_idx], flat_series)
-
-    @pytest.mark.parametrize(
-        "variables,orient",
-        [
-            # Order matters for assigning to x/y
-            ({"cat": "a", "val": "y", "hue": None}, None),
-            ({"val": "y", "cat": "a", "hue": None}, None),
-            ({"cat": "a", "val": "y", "hue": "a"}, None),
-            ({"val": "y", "cat": "a", "hue": "a"}, None),
-            ({"cat": "a", "val": "y", "hue": "b"}, None),
-            ({"val": "y", "cat": "a", "hue": "x"}, None),
-            ({"cat": "s", "val": "y", "hue": None}, None),
-            ({"val": "y", "cat": "s", "hue": None}, "h"),
-            ({"cat": "a", "val": "b", "hue": None}, None),
-            ({"val": "a", "cat": "b", "hue": None}, "h"),
-            ({"cat": "a", "val": "t", "hue": None}, None),
-            ({"val": "t", "cat": "a", "hue": None}, None),
-            ({"cat": "d", "val": "y", "hue": None}, None),
-            ({"val": "y", "cat": "d", "hue": None}, None),
-            ({"cat": "a_cat", "val": "y", "hue": None}, None),
-            ({"val": "y", "cat": "s_cat", "hue": None}, None),
-        ],
-    )
-    def test_positions(self, long_df, variables, orient):
-
-        cat_var = variables["cat"]
-        val_var = variables["val"]
-        hue_var = variables["hue"]
-        var_names = list(variables.values())
-        x_var, y_var, *_ = var_names
-
-        ax = self.func(
-            data=long_df, x=x_var, y=y_var, hue=hue_var, orient=orient,
-        )
-
-        _draw_figure(ax.figure)
-
-        cat_idx = var_names.index(cat_var)
-        val_idx = var_names.index(val_var)
-
-        axis_objs = ax.xaxis, ax.yaxis
-        cat_axis = axis_objs[cat_idx]
-        val_axis = axis_objs[val_idx]
-
-        cat_data = long_df[cat_var]
-        cat_levels = categorical_order(cat_data)
-
-        for i, label in enumerate(cat_levels):
-
-            vals = long_df.loc[cat_data == label, val_var]
-
-            points = ax.collections[i].get_offsets().T
-            cat_pos = points[var_names.index(cat_var)]
-            val_pos = points[var_names.index(val_var)]
-
-            assert_array_equal(val_pos, val_axis.convert_units(vals))
-            assert_array_equal(cat_pos.round(), i)
-            assert 0 <= np.ptp(cat_pos) <= .8
-
-            label = pd.Index([label]).astype(str)[0]
-            assert cat_axis.get_majorticklabels()[i].get_text() == label
-
-    @pytest.mark.parametrize(
-        "variables",
-        [
-            # Order matters for assigning to x/y
-            {"cat": "a", "val": "y", "hue": "b"},
-            {"val": "y", "cat": "a", "hue": "c"},
-            {"cat": "a", "val": "y", "hue": "f"},
-        ],
-    )
-    def test_positions_dodged(self, long_df, variables):
-
-        cat_var = variables["cat"]
-        val_var = variables["val"]
-        hue_var = variables["hue"]
-        var_names = list(variables.values())
-        x_var, y_var, *_ = var_names
-
-        ax = self.func(
-            data=long_df, x=x_var, y=y_var, hue=hue_var, dodge=True,
-        )
-
-        cat_vals = categorical_order(long_df[cat_var])
-        hue_vals = categorical_order(long_df[hue_var])
-
-        n_hue = len(hue_vals)
-        offsets = np.linspace(0, .8, n_hue + 1)[:-1]
-        offsets -= offsets.mean()
-        nest_width = .8 / n_hue
-
-        for i, cat_val in enumerate(cat_vals):
-            for j, hue_val in enumerate(hue_vals):
-                rows = (long_df[cat_var] == cat_val) & (long_df[hue_var] == hue_val)
-                vals = long_df.loc[rows, val_var]
-
-                points = ax.collections[n_hue * i + j].get_offsets().T
-                cat_pos = points[var_names.index(cat_var)]
-                val_pos = points[var_names.index(val_var)]
-
-                if pd.api.types.is_datetime64_any_dtype(vals):
-                    vals = mpl.dates.date2num(vals)
-
-                assert_array_equal(val_pos, vals)
-
-                assert_array_equal(cat_pos.round(), i)
-                assert_array_equal((cat_pos - (i + offsets[j])).round() / nest_width, 0)
-                assert 0 <= np.ptp(cat_pos) <= nest_width
-
-    @pytest.mark.parametrize("cat_var", ["a", "s", "d"])
-    def test_positions_unfixed(self, long_df, cat_var):
-
-        long_df = long_df.sort_values(cat_var)
-
-        kws = dict(size=.001)
-        if "stripplot" in str(self.func):  # can't use __name__ with partial
-            kws["jitter"] = False
-
-        ax = self.func(data=long_df, x=cat_var, y="y", native_scale=True, **kws)
-
-        for i, (cat_level, cat_data) in enumerate(long_df.groupby(cat_var)):
-
-            points = ax.collections[i].get_offsets().T
-            cat_pos = points[0]
-            val_pos = points[1]
-
-            assert_array_equal(val_pos, cat_data["y"])
-
-            comp_level = np.squeeze(ax.xaxis.convert_units(cat_level)).item()
-            assert_array_equal(cat_pos.round(), comp_level)
-
-    @pytest.mark.parametrize(
-        "x_type,order",
-        [
-            (str, None),
-            (str, ["a", "b", "c"]),
-            (str, ["c", "a"]),
-            (str, ["a", "b", "c", "d"]),
-            (int, None),
-            (int, [3, 1, 2]),
-            (int, [3, 1]),
-            (int, [1, 2, 3, 4]),
-            (int, ["3", "1", "2"]),
-        ]
-    )
-    def test_order(self, x_type, order):
-
-        if x_type is str:
-            x = ["b", "a", "c"]
-        else:
-            x = [2, 1, 3]
-        y = [1, 2, 3]
-
-        ax = self.func(x=x, y=y, order=order)
-        _draw_figure(ax.figure)
-
-        if order is None:
-            order = x
-            if x_type is int:
-                order = np.sort(order)
-
-        assert len(ax.collections) == len(order)
-        tick_labels = ax.xaxis.get_majorticklabels()
-
-        assert ax.get_xlim()[1] == (len(order) - .5)
-
-        for i, points in enumerate(ax.collections):
-            cat = order[i]
-            assert tick_labels[i].get_text() == str(cat)
-
-            positions = points.get_offsets()
-            if x_type(cat) in x:
-                val = y[x.index(x_type(cat))]
-                assert positions[0, 1] == val
-            else:
-                assert not positions.size
-
-    @pytest.mark.parametrize("hue_var", ["a", "b"])
-    def test_hue_categorical(self, long_df, hue_var):
-
-        cat_var = "b"
+        cat_var = "b"
 
         hue_levels = categorical_order(long_df[hue_var])
         cat_levels = categorical_order(long_df[cat_var])
@@ -2028,7 +1009,7 @@ class SharedScatterTests(SharedAxesLevelTests):
 
         ax = self.func(data=long_df, x="y", y="a", hue="z")
         vals = [float(t.get_text()) for t in ax.legend_.texts]
-        assert (vals[1] - vals[0]) == pytest.approx(vals[2] - vals[1])
+        assert (vals[1] - vals[0]) == approx(vals[2] - vals[1])
 
     def test_legend_disabled(self, long_df):
 
@@ -2077,7 +1058,7 @@ class SharedScatterTests(SharedAxesLevelTests):
         self.func(x=x, y=y, native_scale=True)
         for i, point in enumerate(ax.collections):
             val = point.get_offsets()[0, 0]
-            assert val == pytest.approx(x[i])
+            assert val == approx(x[i])
 
         x = y = np.ones(100)
 
@@ -2127,6 +1108,25 @@ class SharedScatterTests(SharedAxesLevelTests):
         self.func(x=[], y=[], hue=[], palette=[])
 
 
+class SharedAggTests(SharedAxesLevelTests):
+
+    def test_labels_flat(self):
+
+        ind = pd.Index(["a", "b", "c"], name="x")
+        ser = pd.Series([1, 2, 3], ind, name="y")
+
+        ax = self.func(ser)
+
+        # To populate texts; only needed on older matplotlibs
+        _draw_figure(ax.figure)
+
+        assert ax.get_xlabel() == ind.name
+        assert ax.get_ylabel() == ser.name
+        labels = [t.get_text() for t in ax.get_xticklabels()]
+        for label, level in zip(labels, ind):
+            assert label == level
+
+
 class TestStripPlot(SharedScatterTests):
 
     func = staticmethod(stripplot)
@@ -2142,643 +1142,1762 @@ class TestStripPlot(SharedScatterTests):
         np.random.seed(0)
         stripplot(**kws, y=long_df["s"] * 2, ax=ax2)
 
-        p1 = ax1.collections[0].get_offsets()[1]
-        p2 = ax2.collections[0].get_offsets()[1]
+        p1 = ax1.collections[0].get_offsets()[1]
+        p2 = ax2.collections[0].get_offsets()[1]
+
+        assert p2.std() > p1.std()
+
+    @pytest.mark.parametrize(
+        "orient,jitter",
+        itertools.product(["v", "h"], [True, .1]),
+    )
+    def test_jitter(self, long_df, orient, jitter):
+
+        cat_var, val_var = "a", "y"
+        if orient == "x":
+            x_var, y_var = cat_var, val_var
+            cat_idx, val_idx = 0, 1
+        else:
+            x_var, y_var = val_var, cat_var
+            cat_idx, val_idx = 1, 0
+
+        cat_vals = categorical_order(long_df[cat_var])
+
+        ax = stripplot(
+            data=long_df, x=x_var, y=y_var, jitter=jitter,
+        )
+
+        if jitter is True:
+            jitter_range = .4
+        else:
+            jitter_range = 2 * jitter
+
+        for i, level in enumerate(cat_vals):
+
+            vals = long_df.loc[long_df[cat_var] == level, val_var]
+            points = ax.collections[i].get_offsets().T
+            cat_points = points[cat_idx]
+            val_points = points[val_idx]
+
+            assert_array_equal(val_points, vals)
+            assert np.std(cat_points) > 0
+            assert np.ptp(cat_points) <= jitter_range
+
+
+class TestSwarmPlot(SharedScatterTests):
+
+    func = staticmethod(partial(swarmplot, warn_thresh=1))
+
+
+class TestBoxPlot(SharedAxesLevelTests):
+
+    func = staticmethod(boxplot)
+
+    @pytest.fixture
+    def common_kws(self):
+        return {"saturation": 1}
+
+    def get_last_color(self, ax):
+
+        colors = [b.get_facecolor() for b in ax.containers[-1].boxes]
+        unique_colors = np.unique(colors, axis=0)
+        assert len(unique_colors) == 1
+        return to_rgba(unique_colors.squeeze())
+
+    def get_box_verts(self, box):
+
+        path = box.get_path()
+        visible_codes = [mpl.path.Path.MOVETO, mpl.path.Path.LINETO]
+        visible = np.isin(path.codes, visible_codes)
+        return path.vertices[visible].T
+
+    def check_box(self, bxp, data, orient, pos, width=0.8):
+
+        pos_idx, val_idx = self.orient_indices(orient)
+
+        p25, p50, p75 = np.percentile(data, [25, 50, 75])
+
+        box = self.get_box_verts(bxp.box)
+        assert box[val_idx].min() == p25
+        assert box[val_idx].max() == p75
+        assert box[pos_idx].min() == approx(pos - width / 2)
+        assert box[pos_idx].max() == approx(pos + width / 2)
+
+        med = bxp.median.get_xydata().T
+        assert tuple(med[val_idx]) == (p50, p50)
+        assert np.allclose(med[pos_idx], (pos - width / 2, pos + width / 2))
+
+    def check_whiskers(self, bxp, data, orient, pos, capsize=0.4, whis=1.5):
+
+        pos_idx, val_idx = self.orient_indices(orient)
+
+        whis_lo = bxp.whiskers[0].get_xydata().T
+        whis_hi = bxp.whiskers[1].get_xydata().T
+        caps_lo = bxp.caps[0].get_xydata().T
+        caps_hi = bxp.caps[1].get_xydata().T
+        fliers = bxp.fliers.get_xydata().T
+
+        p25, p75 = np.percentile(data, [25, 75])
+        iqr = p75 - p25
+
+        adj_lo = data[data >= (p25 - iqr * whis)].min()
+        adj_hi = data[data <= (p75 + iqr * whis)].max()
+
+        assert whis_lo[val_idx].max() == p25
+        assert whis_lo[val_idx].min() == approx(adj_lo)
+        assert np.allclose(whis_lo[pos_idx], (pos, pos))
+        assert np.allclose(caps_lo[val_idx], (adj_lo, adj_lo))
+        assert np.allclose(caps_lo[pos_idx], (pos - capsize / 2, pos + capsize / 2))
+
+        assert whis_hi[val_idx].min() == p75
+        assert whis_hi[val_idx].max() == approx(adj_hi)
+        assert np.allclose(whis_hi[pos_idx], (pos, pos))
+        assert np.allclose(caps_hi[val_idx], (adj_hi, adj_hi))
+        assert np.allclose(caps_hi[pos_idx], (pos - capsize / 2, pos + capsize / 2))
+
+        flier_data = data[(data < adj_lo) | (data > adj_hi)]
+        assert sorted(fliers[val_idx]) == sorted(flier_data)
+        assert np.allclose(fliers[pos_idx], pos)
+
+    @pytest.mark.parametrize("orient,col", [("x", "y"), ("y", "z")])
+    def test_single_var(self, long_df, orient, col):
+
+        var = {"x": "y", "y": "x"}[orient]
+        ax = boxplot(long_df, **{var: col})
+        bxp = ax.containers[0][0]
+        self.check_box(bxp, long_df[col], orient, 0)
+        self.check_whiskers(bxp, long_df[col], orient, 0)
+
+    @pytest.mark.parametrize("orient,col", [(None, "x"), ("x", "y"), ("y", "z")])
+    def test_vector_data(self, long_df, orient, col):
+
+        ax = boxplot(long_df[col], orient=orient)
+        orient = "x" if orient is None else orient
+        bxp = ax.containers[0][0]
+        self.check_box(bxp, long_df[col], orient, 0)
+        self.check_whiskers(bxp, long_df[col], orient, 0)
+
+    @pytest.mark.parametrize("orient", ["h", "v"])
+    def test_wide_data(self, wide_df, orient):
+
+        orient = {"h": "y", "v": "x"}[orient]
+        ax = boxplot(wide_df, orient=orient)
+        for i, bxp in enumerate(ax.containers):
+            col = wide_df.columns[i]
+            self.check_box(bxp[i], wide_df[col], orient, i)
+            self.check_whiskers(bxp[i], wide_df[col], orient, i)
+
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_grouped(self, long_df, orient):
+
+        value = {"x": "y", "y": "x"}[orient]
+        ax = boxplot(long_df, **{orient: "a", value: "z"})
+        bxp, = ax.containers
+        levels = categorical_order(long_df["a"])
+        for i, level in enumerate(levels):
+            data = long_df.loc[long_df["a"] == level, "z"]
+            self.check_box(bxp[i], data, orient, i)
+            self.check_whiskers(bxp[i], data, orient, i)
+
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_hue_grouped(self, long_df, orient):
+
+        value = {"x": "y", "y": "x"}[orient]
+        ax = boxplot(long_df, hue="c", **{orient: "a", value: "z"})
+        for i, hue_level in enumerate(categorical_order(long_df["c"])):
+            bxp = ax.containers[i]
+            for j, level in enumerate(categorical_order(long_df["a"])):
+                rows = (long_df["a"] == level) & (long_df["c"] == hue_level)
+                data = long_df.loc[rows, "z"]
+                pos = j + [-.2, +.2][i]
+                width, capsize = 0.4, 0.2
+                self.check_box(bxp[j], data, orient, pos, width)
+                self.check_whiskers(bxp[j], data, orient, pos, capsize)
+
+    def test_hue_not_dodged(self, long_df):
+
+        levels = categorical_order(long_df["b"])
+        hue = long_df["b"].isin(levels[:2])
+        ax = boxplot(long_df, x="b", y="z", hue=hue)
+        bxps = ax.containers
+        for i, level in enumerate(levels):
+            idx = int(i < 2)
+            data = long_df.loc[long_df["b"] == level, "z"]
+            self.check_box(bxps[idx][i % 2], data, "x", i)
+            self.check_whiskers(bxps[idx][i % 2], data, "x", i)
+
+    def test_dodge_native_scale(self, long_df):
+
+        centers = categorical_order(long_df["s"])
+        hue_levels = categorical_order(long_df["c"])
+        spacing = min(np.diff(centers))
+        width = 0.8 * spacing / len(hue_levels)
+        offset = width / len(hue_levels)
+        ax = boxplot(long_df, x="s", y="z", hue="c", native_scale=True)
+        for i, hue_level in enumerate(hue_levels):
+            bxp = ax.containers[i]
+            for j, center in enumerate(centers):
+                rows = (long_df["s"] == center) & (long_df["c"] == hue_level)
+                data = long_df.loc[rows, "z"]
+                pos = center + [-offset, +offset][i]
+                self.check_box(bxp[j], data, "x", pos, width)
+                self.check_whiskers(bxp[j], data, "x", pos, width / 2)
+
+    def test_dodge_native_scale_log(self, long_df):
+
+        pos = 10 ** long_df["s"]
+        ax = mpl.figure.Figure().subplots()
+        ax.set_xscale("log")
+        boxplot(long_df, x=pos, y="z", hue="c", native_scale=True, ax=ax)
+        widths = []
+        for bxp in ax.containers:
+            for box in bxp.boxes:
+                coords = np.log10(box.get_path().vertices.T[0])
+                widths.append(np.ptp(coords))
+        assert np.std(widths) == approx(0)
+
+    def test_color(self, long_df):
+
+        color = "#123456"
+        ax = boxplot(long_df, x="a", y="y", color=color, saturation=1)
+        for box in ax.containers[0].boxes:
+            assert same_color(box.get_facecolor(), color)
+
+    def test_hue_colors(self, long_df):
+
+        ax = boxplot(long_df, x="a", y="y", hue="b", saturation=1)
+        for i, bxp in enumerate(ax.containers):
+            for box in bxp.boxes:
+                assert same_color(box.get_facecolor(), f"C{i}")
+
+    def test_linecolor(self, long_df):
+
+        color = "#778815"
+        ax = boxplot(long_df, x="a", y="y", linecolor=color)
+        bxp = ax.containers[0]
+        for line in [*bxp.medians, *bxp.whiskers, *bxp.caps]:
+            assert same_color(line.get_color(), color)
+        for box in bxp.boxes:
+            assert same_color(box.get_edgecolor(), color)
+        for flier in bxp.fliers:
+            assert same_color(flier.get_markeredgecolor(), color)
+
+    def test_saturation(self, long_df):
+
+        color = "#8912b0"
+        ax = boxplot(long_df["x"], color=color, saturation=.5)
+        box = ax.containers[0].boxes[0]
+        assert np.allclose(box.get_facecolor()[:3], desaturate(color, 0.5))
+
+    def test_linewidth(self, long_df):
+
+        width = 5
+        ax = boxplot(long_df, x="a", y="y", linewidth=width)
+        bxp = ax.containers[0]
+        for line in [*bxp.boxes, *bxp.medians, *bxp.whiskers, *bxp.caps]:
+            assert line.get_linewidth() == width
+
+    def test_fill(self, long_df):
+
+        color = "#459900"
+        ax = boxplot(x=long_df["z"], fill=False, color=color)
+        bxp = ax.containers[0]
+        assert isinstance(bxp.boxes[0], mpl.lines.Line2D)
+        for line in [*bxp.boxes, *bxp.medians, *bxp.whiskers, *bxp.caps]:
+            assert same_color(line.get_color(), color)
+
+    @pytest.mark.parametrize("notch_param", ["notch", "shownotches"])
+    def test_notch(self, long_df, notch_param):
+
+        ax = boxplot(x=long_df["z"], **{notch_param: True})
+        verts = ax.containers[0].boxes[0].get_path().vertices
+        assert len(verts) == 12
+
+    def test_whis(self, long_df):
+
+        data = long_df["z"]
+        ax = boxplot(x=data, whis=2)
+        bxp = ax.containers[0][0]
+        self.check_whiskers(bxp, data, "y", 0, whis=2)
+
+    def test_gap(self, long_df):
+
+        ax = boxplot(long_df, x="a", y="z", hue="c", gap=.1)
+        for i, hue_level in enumerate(categorical_order(long_df["c"])):
+            bxp = ax.containers[i]
+            for j, level in enumerate(categorical_order(long_df["a"])):
+                rows = (long_df["a"] == level) & (long_df["c"] == hue_level)
+                data = long_df.loc[rows, "z"]
+                pos = j + [-.2, +.2][i]
+                width = 0.9 * 0.4
+                self.check_box(bxp[j], data, "x", pos, width)
+
+    def test_prop_dicts(self, long_df):
+
+        prop_dicts = dict(
+            boxprops=dict(linewidth=3),
+            medianprops=dict(color=".1"),
+            whiskerprops=dict(linestyle="--"),
+            capprops=dict(solid_capstyle="butt"),
+            flierprops=dict(marker="s"),
+        )
+        attr_map = dict(box="boxes", flier="fliers")
+        ax = boxplot(long_df, x="a", y="z", hue="c", **prop_dicts)
+        for bxp in ax.containers:
+            for element in ["box", "median", "whisker", "cap", "flier"]:
+                attr = attr_map.get(element, f"{element}s")
+                for artist in getattr(bxp, attr):
+                    for k, v in prop_dicts[f"{element}props"].items():
+                        assert plt.getp(artist, k) == v
+
+    def test_showfliers(self, long_df):
+
+        ax = boxplot(long_df["x"], showfliers=False)
+        assert not ax.containers[0].fliers
+
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            dict(data="wide"),
+            dict(data="wide", orient="h"),
+            dict(data="flat"),
+            dict(data="long", x="a", y="y"),
+            dict(data=None, x="a", y="y"),
+            dict(data="long", x="a", y="y", hue="a"),
+            dict(data=None, x="a", y="y", hue="a"),
+            dict(data="long", x="a", y="y", hue="b"),
+            dict(data=None, x="s", y="y", hue="a"),
+            dict(data="long", x="a", y="y", hue="s"),
+            dict(data="null", x="a", y="y", hue="a"),
+            dict(data="long", x="s", y="y", hue="a", native_scale=True),
+            dict(data="long", x="d", y="y", hue="a", native_scale=True),
+            dict(data="null", x="a", y="y", hue="b", fill=False, gap=.2),
+            dict(data="null", x="a", y="y", whis=1, showfliers=False),
+            dict(data="null", x="a", y="y", linecolor="r", linewidth=5),
+            dict(data="null", x="a", y="y", shownotches=True, showcaps=False),
+        ]
+    )
+    def test_vs_catplot(self, long_df, wide_df, null_df, flat_series, kwargs):
+
+        if kwargs["data"] == "long":
+            kwargs["data"] = long_df
+        elif kwargs["data"] == "wide":
+            kwargs["data"] = wide_df
+        elif kwargs["data"] == "flat":
+            kwargs["data"] = flat_series
+        elif kwargs["data"] == "null":
+            kwargs["data"] = null_df
+        elif kwargs["data"] is None:
+            for var in ["x", "y", "hue"]:
+                if var in kwargs:
+                    kwargs[var] = long_df[kwargs[var]]
+
+        ax = boxplot(**kwargs)
+        g = catplot(**kwargs, kind="box")
+
+        assert_plots_equal(ax, g.ax)
+
+
+class TestViolinPlot(SharedAxesLevelTests):
+
+    func = staticmethod(violinplot)
+
+    @pytest.fixture
+    def common_kws(self):
+        return {"saturation": 1}
+
+    def get_last_color(self, ax):
+
+        color = ax.collections[-1].get_facecolor()
+        return to_rgba(color)
+
+    def violin_width(self, poly, orient="x"):
+
+        idx, _ = self.orient_indices(orient)
+        return np.ptp(poly.get_paths()[0].vertices[:, idx])
+
+    def check_violin(self, poly, data, orient, pos, width=0.8):
+
+        pos_idx, val_idx = self.orient_indices(orient)
+        verts = poly.get_paths()[0].vertices.T
+
+        assert verts[pos_idx].min() >= (pos - width / 2)
+        assert verts[pos_idx].max() <= (pos + width / 2)
+        # Assumes violin was computed with cut=0
+        assert verts[val_idx].min() == approx(data.min())
+        assert verts[val_idx].max() == approx(data.max())
+
+    @pytest.mark.parametrize("orient,col", [("x", "y"), ("y", "z")])
+    def test_single_var(self, long_df, orient, col):
+
+        var = {"x": "y", "y": "x"}[orient]
+        ax = violinplot(long_df, **{var: col}, cut=0)
+        poly = ax.collections[0]
+        self.check_violin(poly, long_df[col], orient, 0)
+
+    @pytest.mark.parametrize("orient,col", [(None, "x"), ("x", "y"), ("y", "z")])
+    def test_vector_data(self, long_df, orient, col):
+
+        orient = "x" if orient is None else orient
+        ax = violinplot(long_df[col], cut=0, orient=orient)
+        poly = ax.collections[0]
+        self.check_violin(poly, long_df[col], orient, 0)
+
+    @pytest.mark.parametrize("orient", ["h", "v"])
+    def test_wide_data(self, wide_df, orient):
+
+        orient = {"h": "y", "v": "x"}[orient]
+        ax = violinplot(wide_df, cut=0, orient=orient)
+        for i, poly in enumerate(ax.collections):
+            col = wide_df.columns[i]
+            self.check_violin(poly, wide_df[col], orient, i)
+
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_grouped(self, long_df, orient):
+
+        value = {"x": "y", "y": "x"}[orient]
+        ax = violinplot(long_df, **{orient: "a", value: "z"}, cut=0)
+        levels = categorical_order(long_df["a"])
+        for i, level in enumerate(levels):
+            data = long_df.loc[long_df["a"] == level, "z"]
+            self.check_violin(ax.collections[i], data, orient, i)
+
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_hue_grouped(self, long_df, orient):
+
+        value = {"x": "y", "y": "x"}[orient]
+        ax = violinplot(long_df, hue="c", **{orient: "a", value: "z"}, cut=0)
+        polys = iter(ax.collections)
+        for i, level in enumerate(categorical_order(long_df["a"])):
+            for j, hue_level in enumerate(categorical_order(long_df["c"])):
+                rows = (long_df["a"] == level) & (long_df["c"] == hue_level)
+                data = long_df.loc[rows, "z"]
+                pos = i + [-.2, +.2][j]
+                width = 0.4
+                self.check_violin(next(polys), data, orient, pos, width)
+
+    def test_hue_not_dodged(self, long_df):
+
+        levels = categorical_order(long_df["b"])
+        hue = long_df["b"].isin(levels[:2])
+        ax = violinplot(long_df, x="b", y="z", hue=hue, cut=0)
+        for i, level in enumerate(levels):
+            poly = ax.collections[i]
+            data = long_df.loc[long_df["b"] == level, "z"]
+            self.check_violin(poly, data, "x", i)
+
+    def test_dodge_native_scale(self, long_df):
+
+        centers = categorical_order(long_df["s"])
+        hue_levels = categorical_order(long_df["c"])
+        spacing = min(np.diff(centers))
+        width = 0.8 * spacing / len(hue_levels)
+        offset = width / len(hue_levels)
+        ax = violinplot(long_df, x="s", y="z", hue="c", native_scale=True, cut=0)
+        violins = iter(ax.collections)
+        for center in centers:
+            for i, hue_level in enumerate(hue_levels):
+                rows = (long_df["s"] == center) & (long_df["c"] == hue_level)
+                data = long_df.loc[rows, "z"]
+                pos = center + [-offset, +offset][i]
+                poly = next(violins)
+                self.check_violin(poly, data, "x", pos, width)
+
+    def test_dodge_native_scale_log(self, long_df):
+
+        pos = 10 ** long_df["s"]
+        ax = mpl.figure.Figure().subplots()
+        ax.set_xscale("log")
+        variables = dict(x=pos, y="z", hue="c")
+        violinplot(long_df, **variables, native_scale=True, density_norm="width", ax=ax)
+        widths = []
+        n_violins = long_df["s"].nunique() * long_df["c"].nunique()
+        for poly in ax.collections[:n_violins]:
+            verts = poly.get_paths()[0].vertices[:, 0]
+            coords = np.log10(verts)
+            widths.append(np.ptp(coords))
+        assert np.std(widths) == approx(0)
+
+    def test_color(self, long_df):
+
+        color = "#123456"
+        ax = violinplot(long_df, x="a", y="y", color=color, saturation=1)
+        for poly in ax.collections:
+            assert same_color(poly.get_facecolor(), color)
+
+    def test_hue_colors(self, long_df):
+
+        ax = violinplot(long_df, x="a", y="y", hue="b", saturation=1)
+        n_levels = long_df["b"].nunique()
+        for i, poly in enumerate(ax.collections):
+            assert same_color(poly.get_facecolor(), f"C{i % n_levels}")
+
+    @pytest.mark.parametrize("inner", ["box", "quart", "stick", "point"])
+    def test_linecolor(self, long_df, inner):
+
+        color = "#669913"
+        ax = violinplot(long_df, x="a", y="y", linecolor=color, inner=inner)
+        for poly in ax.findobj(mpl.collections.PolyCollection):
+            assert same_color(poly.get_edgecolor(), color)
+        for lines in ax.findobj(mpl.collections.LineCollection):
+            assert same_color(lines.get_color(), color)
+        for line in ax.lines:
+            assert same_color(line.get_color(), color)
+
+    def test_linewidth(self, long_df):
+
+        width = 5
+        ax = violinplot(long_df, x="a", y="y", linewidth=width)
+        poly = ax.collections[0]
+        assert poly.get_linewidth() == width
+
+    def test_saturation(self, long_df):
+
+        color = "#8912b0"
+        ax = violinplot(long_df["x"], color=color, saturation=.5)
+        poly = ax.collections[0]
+        assert np.allclose(poly.get_facecolors()[0, :3], desaturate(color, 0.5))
+
+    @pytest.mark.parametrize("inner", ["box", "quart", "stick", "point"])
+    def test_fill(self, long_df, inner):
+
+        color = "#459900"
+        ax = violinplot(x=long_df["z"], fill=False, color=color, inner=inner)
+        for poly in ax.findobj(mpl.collections.PolyCollection):
+            assert poly.get_facecolor().size == 0
+            assert same_color(poly.get_edgecolor(), color)
+        for lines in ax.findobj(mpl.collections.LineCollection):
+            assert same_color(lines.get_color(), color)
+        for line in ax.lines:
+            assert same_color(line.get_color(), color)
+
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_inner_box(self, long_df, orient):
+
+        pos_idx, val_idx = self.orient_indices(orient)
+        ax = violinplot(long_df["y"], orient=orient)
+        stats = mpl.cbook.boxplot_stats(long_df["y"])[0]
+
+        whiskers = ax.lines[0].get_xydata()
+        assert whiskers[0, val_idx] == stats["whislo"]
+        assert whiskers[1, val_idx] == stats["whishi"]
+        assert whiskers[:, pos_idx].tolist() == [0, 0]
+
+        box = ax.lines[1].get_xydata()
+        assert box[0, val_idx] == stats["q1"]
+        assert box[1, val_idx] == stats["q3"]
+        assert box[:, pos_idx].tolist() == [0, 0]
+
+        median = ax.lines[2].get_xydata()
+        assert median[0, val_idx] == stats["med"]
+        assert median[0, pos_idx] == 0
+
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_inner_quartiles(self, long_df, orient):
+
+        pos_idx, val_idx = self.orient_indices(orient)
+        ax = violinplot(long_df["y"], orient=orient, inner="quart")
+        quartiles = np.percentile(long_df["y"], [25, 50, 75])
+
+        for q, line in zip(quartiles, ax.lines):
+            pts = line.get_xydata()
+            for pt in pts:
+                assert pt[val_idx] == q
+            assert pts[0, pos_idx] == -pts[1, pos_idx]
+
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_inner_stick(self, long_df, orient):
+
+        pos_idx, val_idx = self.orient_indices(orient)
+        ax = violinplot(long_df["y"], orient=orient, inner="stick")
+        for i, pts in enumerate(ax.collections[1].get_segments()):
+            for pt in pts:
+                assert pt[val_idx] == long_df["y"].iloc[i]
+            assert pts[0, pos_idx] == -pts[1, pos_idx]
+
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_inner_points(self, long_df, orient):
+
+        pos_idx, val_idx = self.orient_indices(orient)
+        ax = violinplot(long_df["y"], orient=orient, inner="points")
+        points = ax.collections[1]
+        for i, pt in enumerate(points.get_offsets()):
+            assert pt[val_idx] == long_df["y"].iloc[i]
+            assert pt[pos_idx] == 0
+
+    def test_split_single(self, long_df):
+
+        ax = violinplot(long_df, x="a", y="z", split=True, cut=0)
+        levels = categorical_order(long_df["a"])
+        for i, level in enumerate(levels):
+            data = long_df.loc[long_df["a"] == level, "z"]
+            self.check_violin(ax.collections[i], data, "x", i)
+            verts = ax.collections[i].get_paths()[0].vertices
+            assert np.isclose(verts[:, 0], i + .4).sum() >= 100
+
+    def test_split_multi(self, long_df):
+
+        ax = violinplot(long_df, x="a", y="z", hue="c", split=True, cut=0)
+        polys = iter(ax.collections)
+        for i, level in enumerate(categorical_order(long_df["a"])):
+            for j, hue_level in enumerate(categorical_order(long_df["c"])):
+                rows = (long_df["a"] == level) & (long_df["c"] == hue_level)
+                data = long_df.loc[rows, "z"]
+                pos = i + [-.2, +.2][j]
+                poly = next(polys)
+                self.check_violin(poly, data, "x", pos, width=0.4)
+                verts = poly.get_paths()[0].vertices
+                assert np.isclose(verts[:, 0], i).sum() >= 100
+
+    def test_density_norm_area(self, long_df):
+
+        y = long_df["y"].to_numpy()
+        ax = violinplot([y, y * 5])
+        widths = []
+        for poly in ax.collections:
+            widths.append(self.violin_width(poly))
+        assert widths[0] / widths[1] == approx(5)
+
+    def test_density_norm_count(self, long_df):
+
+        y = long_df["y"].to_numpy()
+        ax = violinplot([np.repeat(y, 3), y], density_norm="count")
+        widths = []
+        for poly in ax.collections:
+            widths.append(self.violin_width(poly))
+        assert widths[0] / widths[1] == approx(3)
+
+    def test_density_norm_width(self, long_df):
+
+        ax = violinplot(long_df, x="a", y="y", density_norm="width")
+        for poly in ax.collections:
+            assert self.violin_width(poly) == approx(0.8)
+
+    def test_common_norm(self, long_df):
+
+        ax = violinplot(long_df, x="a", y="y", hue="c", common_norm=True, legend=False)
+        widths = []
+        for poly in ax.collections:
+            widths.append(self.violin_width(poly))
+        assert sum(w > 0.3999 for w in widths) == 1
+
+    def test_scale_deprecation(self, long_df):
+
+        with pytest.warns(FutureWarning, match=r".+Pass `density_norm='count'`"):
+            violinplot(long_df, x="a", y="y", hue="b", scale="count")
+
+    def test_scale_hue_deprecation(self, long_df):
+
+        with pytest.warns(FutureWarning, match=r".+Pass `common_norm=True`"):
+            violinplot(long_df, x="a", y="y", hue="b", scale_hue=False)
+
+    def test_bw_adjust(self, long_df):
+
+        ax = violinplot(long_df["y"], bw_adjust=.2)
+        violinplot(long_df["y"], bw_adjust=2)
+        kde1 = ax.collections[0].get_paths()[0].vertices[:100, 0]
+        kde2 = ax.collections[1].get_paths()[0].vertices[:100, 0]
+        assert np.std(np.diff(kde1)) > np.std(np.diff(kde2))
+
+    def test_bw_deprecation(self, long_df):
+
+        with pytest.warns(FutureWarning, match=r".*Setting `bw_method='silverman'`"):
+            violinplot(long_df["y"], bw="silverman")
+
+    def test_gap(self, long_df):
+
+        ax = violinplot(long_df, y="y", hue="c", gap=.2)
+        a = ax.collections[0].get_paths()[0].vertices[:, 0].max()
+        b = ax.collections[1].get_paths()[0].vertices[:, 0].min()
+        assert (b - a) == approx(0.2 * 0.8 / 2)
+
+    def test_inner_kws(self, long_df):
+
+        kws = {"linewidth": 3}
+        ax = violinplot(long_df, x="a", y="y", inner="stick", inner_kws=kws)
+        for line in ax.lines:
+            assert line.get_linewidth() == kws["linewidth"]
+
+    def test_box_inner_kws(self, long_df):
+
+        kws = {"box_width": 10, "whis_width": 2, "marker": "x"}
+        ax = violinplot(long_df, x="a", y="y", inner_kws=kws)
+        for line in ax.lines[::3]:
+            assert line.get_linewidth() == kws["whis_width"]
+        for line in ax.lines[1::3]:
+            assert line.get_linewidth() == kws["box_width"]
+        for line in ax.lines[2::3]:
+            assert line.get_marker() == kws["marker"]
+
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            dict(data="wide"),
+            dict(data="wide", orient="h"),
+            dict(data="flat"),
+            dict(data="long", x="a", y="y"),
+            dict(data=None, x="a", y="y", split=True),
+            dict(data="long", x="a", y="y", hue="a"),
+            dict(data=None, x="a", y="y", hue="a"),
+            dict(data="long", x="a", y="y", hue="b"),
+            dict(data=None, x="s", y="y", hue="a"),
+            dict(data="long", x="a", y="y", hue="s", split=True),
+            dict(data="null", x="a", y="y", hue="a"),
+            dict(data="long", x="s", y="y", hue="a", native_scale=True),
+            dict(data="long", x="d", y="y", hue="a", native_scale=True),
+            dict(data="null", x="a", y="y", hue="b", fill=False, gap=.2),
+            dict(data="null", x="a", y="y", linecolor="r", linewidth=5),
+            dict(data="long", x="a", y="y", inner="stick"),
+            dict(data="long", x="a", y="y", inner="points"),
+            dict(data="long", x="a", y="y", hue="b", inner="quartiles", split=True),
+            dict(data="long", x="a", y="y", density_norm="count", common_norm=True),
+            dict(data="long", x="a", y="y", bw=2),
+            dict(data="long", x="a", y="y", bw_adjust=2),
+        ]
+    )
+    def test_vs_catplot(self, long_df, wide_df, null_df, flat_series, kwargs):
+
+        if kwargs["data"] == "long":
+            kwargs["data"] = long_df
+        elif kwargs["data"] == "wide":
+            kwargs["data"] = wide_df
+        elif kwargs["data"] == "flat":
+            kwargs["data"] = flat_series
+        elif kwargs["data"] == "null":
+            kwargs["data"] = null_df
+        elif kwargs["data"] is None:
+            for var in ["x", "y", "hue"]:
+                if var in kwargs:
+                    kwargs[var] = long_df[kwargs[var]]
+
+        ax = violinplot(**kwargs)
+        g = catplot(**kwargs, kind="violin")
+
+        assert_plots_equal(ax, g.ax)
+
+
+class TestBarPlot(SharedAggTests):
+
+    func = staticmethod(barplot)
+
+    @pytest.fixture
+    def common_kws(self):
+        return {"saturation": 1}
+
+    def get_last_color(self, ax):
+
+        colors = [p.get_facecolor() for p in ax.containers[-1]]
+        unique_colors = np.unique(colors, axis=0)
+        assert len(unique_colors) == 1
+        return to_rgba(unique_colors.squeeze())
+
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_single_var(self, orient):
+
+        vals = pd.Series([1, 3, 10])
+        ax = barplot(**{orient: vals})
+        bar, = ax.patches
+        prop = {"x": "width", "y": "height"}[orient]
+        assert getattr(bar, f"get_{prop}")() == approx(vals.mean())
+
+    @pytest.mark.parametrize("orient", ["x", "y", "h", "v"])
+    def test_wide_df(self, wide_df, orient):
+
+        ax = barplot(wide_df, orient=orient)
+        orient = {"h": "y", "v": "x"}.get(orient, orient)
+        prop = {"x": "height", "y": "width"}[orient]
+        for i, bar in enumerate(ax.patches):
+            assert getattr(bar, f"get_{prop}")() == approx(wide_df.iloc[:, i].mean())
+
+    @pytest.mark.parametrize("orient", ["x", "y", "h", "v"])
+    def test_vector_orient(self, orient):
+
+        keys, vals = ["a", "b", "c"], [1, 2, 3]
+        data = dict(zip(keys, vals))
+        orient = {"h": "y", "v": "x"}.get(orient, orient)
+        prop = {"x": "height", "y": "width"}[orient]
+        ax = barplot(data, orient=orient)
+        for i, bar in enumerate(ax.patches):
+            assert getattr(bar, f"get_{orient}")() == approx(i - 0.4)
+            assert getattr(bar, f"get_{prop}")() == approx(vals[i])
+
+    def test_xy_vertical(self):
+
+        x, y = ["a", "b", "c"], [1, 3, 2.5]
+
+        ax = barplot(x=x, y=y)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(i)
+            assert bar.get_y() == approx(0)
+            assert bar.get_height() == approx(y[i])
+            assert bar.get_width() == approx(0.8)
+
+    def test_xy_horizontal(self):
+
+        x, y = [1, 3, 2.5], ["a", "b", "c"]
+
+        ax = barplot(x=x, y=y)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() == approx(0)
+            assert bar.get_y() + bar.get_height() / 2 == approx(i)
+            assert bar.get_height() == approx(0.8)
+            assert bar.get_width() == approx(x[i])
+
+    def test_xy_with_na_grouper(self):
+
+        x, y = ["a", None, "b"], [1, 2, 3]
+        ax = barplot(x=x, y=y)
+        _draw_figure(ax.figure)  # For matplotlib<3.5
+        assert ax.get_xticks() == [0, 1]
+        assert [t.get_text() for t in ax.get_xticklabels()] == ["a", "b"]
+        assert ax.patches[0].get_height() == 1
+        assert ax.patches[1].get_height() == 3
+
+    def test_xy_with_na_value(self):
+
+        x, y = ["a", "b", "c"], [1, None, 3]
+        ax = barplot(x=x, y=y)
+        _draw_figure(ax.figure)  # For matplotlib<3.5
+        assert ax.get_xticks() == [0, 1, 2]
+        assert [t.get_text() for t in ax.get_xticklabels()] == ["a", "b", "c"]
+        assert ax.patches[0].get_height() == 1
+        assert ax.patches[1].get_height() == 3
+
+    def test_hue_redundant(self):
+
+        x, y = ["a", "b", "c"], [1, 2, 3]
+
+        ax = barplot(x=x, y=y, hue=x, saturation=1)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(i)
+            assert bar.get_y() == 0
+            assert bar.get_height() == y[i]
+            assert bar.get_width() == approx(0.8)
+            assert same_color(bar.get_facecolor(), f"C{i}")
+
+    def test_hue_matched(self):
+
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        hue = ["x", "x", "y"]
+
+        ax = barplot(x=x, y=y, hue=hue, saturation=1)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(i)
+            assert bar.get_y() == 0
+            assert bar.get_height() == y[i]
+            assert bar.get_width() == approx(0.8)
+            assert same_color(bar.get_facecolor(), f"C{i // 2}")
+
+    def test_hue_matched_by_name(self):
+
+        data = {"x": ["a", "b", "c"], "y": [1, 2, 3]}
+        ax = barplot(data, x="x", y="y", hue="x", saturation=1)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(i)
+            assert bar.get_y() == 0
+            assert bar.get_height() == data["y"][i]
+            assert bar.get_width() == approx(0.8)
+            assert same_color(bar.get_facecolor(), f"C{i}")
+
+    def test_hue_dodged(self):
+
+        x = ["a", "b", "a", "b"]
+        y = [1, 2, 3, 4]
+        hue = ["x", "x", "y", "y"]
+
+        ax = barplot(x=x, y=y, hue=hue, saturation=1)
+        for i, bar in enumerate(ax.patches):
+            sign = 1 if i // 2 else -1
+            assert (
+                bar.get_x() + bar.get_width() / 2
+                == approx(i % 2 + sign * 0.8 / 4)
+            )
+            assert bar.get_y() == 0
+            assert bar.get_height() == y[i]
+            assert bar.get_width() == approx(0.8 / 2)
+            assert same_color(bar.get_facecolor(), f"C{i // 2}")
+
+    def test_gap(self):
+
+        x = ["a", "b", "a", "b"]
+        y = [1, 2, 3, 4]
+        hue = ["x", "x", "y", "y"]
 
-        assert p2.std() > p1.std()
+        ax = barplot(x=x, y=y, hue=hue, gap=.25)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_width() == approx(0.8 / 2 * .75)
 
-    @pytest.mark.parametrize(
-        "orient,jitter",
-        itertools.product(["v", "h"], [True, .1]),
-    )
-    def test_jitter(self, long_df, orient, jitter):
+    def test_hue_undodged(self):
 
-        cat_var, val_var = "a", "y"
-        if orient == "v":
-            x_var, y_var = cat_var, val_var
-            cat_idx, val_idx = 0, 1
-        else:
-            x_var, y_var = val_var, cat_var
-            cat_idx, val_idx = 1, 0
+        x = ["a", "b", "a", "b"]
+        y = [1, 2, 3, 4]
+        hue = ["x", "x", "y", "y"]
 
-        cat_vals = categorical_order(long_df[cat_var])
+        ax = barplot(x=x, y=y, hue=hue, saturation=1, dodge=False)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(i % 2)
+            assert bar.get_y() == 0
+            assert bar.get_height() == y[i]
+            assert bar.get_width() == approx(0.8)
+            assert same_color(bar.get_facecolor(), f"C{i // 2}")
 
-        ax = stripplot(
-            data=long_df, x=x_var, y=y_var, jitter=jitter,
-        )
+    def test_hue_order(self):
 
-        if jitter is True:
-            jitter_range = .4
-        else:
-            jitter_range = 2 * jitter
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        hue_order = ["c", "b", "a"]
 
-        for i, level in enumerate(cat_vals):
+        ax = barplot(x=x, y=y, hue=x, hue_order=hue_order, saturation=1)
+        for i, bar in enumerate(ax.patches):
+            assert same_color(bar.get_facecolor(), f"C{i}")
+            assert bar.get_x() + bar.get_width() / 2 == approx(2 - i)
 
-            vals = long_df.loc[long_df[cat_var] == level, val_var]
-            points = ax.collections[i].get_offsets().T
-            cat_points = points[cat_idx]
-            val_points = points[val_idx]
+    def test_hue_norm(self):
 
-            assert_array_equal(val_points, vals)
-            assert np.std(cat_points) > 0
-            assert np.ptp(cat_points) <= jitter_range
+        x, y = [1, 2, 3, 4], [1, 2, 3, 4]
 
+        ax = barplot(x=x, y=y, hue=x, hue_norm=(2, 3))
+        colors = [bar.get_facecolor() for bar in ax.patches]
+        assert colors[0] == colors[1]
+        assert colors[1] != colors[2]
+        assert colors[2] == colors[3]
 
-class TestSwarmPlot(SharedScatterTests):
+    def test_fill(self):
 
-    func = staticmethod(partial(swarmplot, warn_thresh=1))
+        x = ["a", "b", "a", "b"]
+        y = [1, 2, 3, 4]
+        hue = ["x", "x", "y", "y"]
 
+        ax = barplot(x=x, y=y, hue=hue, fill=False)
+        for i, bar in enumerate(ax.patches):
+            assert same_color(bar.get_edgecolor(), f"C{i // 2}")
+            assert same_color(bar.get_facecolor(), (0, 0, 0, 0))
 
-class TestBarPlotter(CategoricalFixture):
+    def test_xy_native_scale(self):
 
-    default_kws = dict(
-        data=None, x=None, y=None, hue=None, units=None,
-        estimator="mean", errorbar=("ci", 95), n_boot=100, seed=None,
-        order=None, hue_order=None,
-        orient=None, color=None, palette=None,
-        saturation=.75, width=0.8,
-        errcolor=".26", errwidth=None,
-        capsize=None, dodge=True
-    )
+        x, y = [2, 4, 8], [1, 2, 3]
 
-    def test_nested_width(self):
+        ax = barplot(x=x, y=y, native_scale=True)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(x[i])
+            assert bar.get_y() == 0
+            assert bar.get_height() == y[i]
+            assert bar.get_width() == approx(0.8 * 2)
 
-        ax = cat.barplot(data=self.df, x="g", y="y", hue="h")
-        for bar in ax.patches:
-            assert bar.get_width() == pytest.approx(.8 / 2)
-        ax.clear()
+    def test_xy_native_scale_log_transform(self):
 
-        ax = cat.barplot(data=self.df, x="g", y="y", hue="g", width=.5)
-        for bar in ax.patches:
-            assert bar.get_width() == pytest.approx(.5 / 3)
-        ax.clear()
+        x, y = [1, 10, 100], [1, 2, 3]
 
-        ax = cat.barplot(data=self.df, x="g", y="y", hue="g", dodge=False)
-        for bar in ax.patches:
-            assert bar.get_width() == pytest.approx(.8)
-        ax.clear()
+        ax = mpl.figure.Figure().subplots()
+        ax.set_xscale("log")
+        barplot(x=x, y=y, native_scale=True, ax=ax)
+        for i, bar in enumerate(ax.patches):
+            x0, x1 = np.log10([bar.get_x(), bar.get_x() + bar.get_width()])
+            center = 10 ** (x0 + (x1 - x0) / 2)
+            assert center == approx(x[i])
+            assert bar.get_y() == 0
+            assert bar.get_height() == y[i]
+        assert ax.patches[1].get_width() > ax.patches[0].get_width()
 
-    def test_draw_vertical_bars(self):
+    def test_datetime_native_scale_axis(self):
 
-        kws = self.default_kws.copy()
-        kws.update(x="g", y="y", data=self.df)
-        p = cat._BarPlotter(**kws)
+        x = pd.date_range("2010-01-01", periods=20, freq="m")
+        y = np.arange(20)
+        ax = barplot(x=x, y=y, native_scale=True)
+        assert "Date" in ax.xaxis.get_major_locator().__class__.__name__
+        day = "2003-02-28"
+        assert_array_equal(ax.xaxis.convert_units([day]), mpl.dates.date2num([day]))
 
-        f, ax = plt.subplots()
-        p.draw_bars(ax, {})
+    def test_native_scale_dodged(self):
 
-        assert len(ax.patches) == len(p.plot_data)
-        assert len(ax.lines) == len(p.plot_data)
+        x, y = [2, 4, 2, 4], [1, 2, 3, 4]
+        hue = ["x", "x", "y", "y"]
 
-        for bar, color in zip(ax.patches, p.colors):
-            assert bar.get_facecolor()[:-1] == color
+        ax = barplot(x=x, y=y, hue=hue, native_scale=True)
 
-        positions = np.arange(len(p.plot_data)) - p.width / 2
-        for bar, pos, stat in zip(ax.patches, positions, p.statistic):
-            assert bar.get_x() == pos
-            assert bar.get_width() == p.width
-            assert bar.get_y() == 0
-            assert bar.get_height() == stat
+        for x_i, bar in zip(x[:2], ax.patches[:2]):
+            assert bar.get_x() + bar.get_width() == approx(x_i)
+        for x_i, bar in zip(x[2:], ax.patches[2:]):
+            assert bar.get_x() == approx(x_i)
 
-    def test_draw_horizontal_bars(self):
+    def test_native_scale_log_transform_dodged(self):
 
-        kws = self.default_kws.copy()
-        kws.update(x="y", y="g", orient="h", data=self.df)
-        p = cat._BarPlotter(**kws)
+        x, y = [1, 100, 1, 100], [1, 2, 3, 4]
+        hue = ["x", "x", "y", "y"]
 
-        f, ax = plt.subplots()
-        p.draw_bars(ax, {})
+        ax = mpl.figure.Figure().subplots()
+        ax.set_xscale("log")
+        barplot(x=x, y=y, hue=hue, native_scale=True, ax=ax)
 
-        assert len(ax.patches) == len(p.plot_data)
-        assert len(ax.lines) == len(p.plot_data)
+        for x_i, bar in zip(x[:2], ax.patches[:2]):
+            assert bar.get_x() + bar.get_width() == approx(x_i)
+        for x_i, bar in zip(x[2:], ax.patches[2:]):
+            assert bar.get_x() == approx(x_i)
 
-        for bar, color in zip(ax.patches, p.colors):
-            assert bar.get_facecolor()[:-1] == color
+    def test_estimate_default(self, long_df):
 
-        positions = np.arange(len(p.plot_data)) - p.width / 2
-        for bar, pos, stat in zip(ax.patches, positions, p.statistic):
-            assert bar.get_y() == pos
-            assert bar.get_height() == p.width
-            assert bar.get_x() == 0
-            assert bar.get_width() == stat
+        agg_var, val_var = "a", "y"
+        agg_df = long_df.groupby(agg_var)[val_var].mean()
 
-    def test_draw_nested_vertical_bars(self):
+        ax = barplot(long_df, x=agg_var, y=val_var, errorbar=None)
+        order = categorical_order(long_df[agg_var])
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_height() == approx(agg_df[order[i]])
 
-        kws = self.default_kws.copy()
-        kws.update(x="g", y="y", hue="h", data=self.df)
-        p = cat._BarPlotter(**kws)
+    def test_estimate_string(self, long_df):
 
-        f, ax = plt.subplots()
-        p.draw_bars(ax, {})
+        agg_var, val_var = "a", "y"
+        agg_df = long_df.groupby(agg_var)[val_var].median()
 
-        n_groups, n_hues = len(p.plot_data), len(p.hue_names)
-        assert len(ax.patches) == n_groups * n_hues
-        assert len(ax.lines) == n_groups * n_hues
+        ax = barplot(long_df, x=agg_var, y=val_var, estimator="median", errorbar=None)
+        order = categorical_order(long_df[agg_var])
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_height() == approx(agg_df[order[i]])
 
-        for bar in ax.patches[:n_groups]:
-            assert bar.get_facecolor()[:-1] == p.colors[0]
-        for bar in ax.patches[n_groups:]:
-            assert bar.get_facecolor()[:-1] == p.colors[1]
+    def test_estimate_func(self, long_df):
 
-        positions = np.arange(len(p.plot_data))
-        for bar, pos in zip(ax.patches[:n_groups], positions):
-            assert bar.get_x() == approx(pos - p.width / 2)
-            assert bar.get_width() == approx(p.nested_width)
+        agg_var, val_var = "a", "y"
+        agg_df = long_df.groupby(agg_var)[val_var].median()
 
-        for bar, stat in zip(ax.patches, p.statistic.T.flat):
-            assert bar.get_y() == approx(0)
-            assert bar.get_height() == approx(stat)
+        ax = barplot(long_df, x=agg_var, y=val_var, estimator=np.median, errorbar=None)
+        order = categorical_order(long_df[agg_var])
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_height() == approx(agg_df[order[i]])
 
-    def test_draw_nested_horizontal_bars(self):
+    def test_estimate_log_transform(self, long_df):
 
-        kws = self.default_kws.copy()
-        kws.update(x="y", y="g", hue="h", orient="h", data=self.df)
-        p = cat._BarPlotter(**kws)
+        ax = mpl.figure.Figure().subplots()
+        ax.set_xscale("log")
+        barplot(x=long_df["z"], ax=ax)
+        bar, = ax.patches
+        assert bar.get_width() == 10 ** np.log10(long_df["z"]).mean()
 
-        f, ax = plt.subplots()
-        p.draw_bars(ax, {})
+    def test_errorbars(self, long_df):
 
-        n_groups, n_hues = len(p.plot_data), len(p.hue_names)
-        assert len(ax.patches) == n_groups * n_hues
-        assert len(ax.lines) == n_groups * n_hues
+        agg_var, val_var = "a", "y"
+        agg_df = long_df.groupby(agg_var)[val_var].agg(["mean", "std"])
 
-        for bar in ax.patches[:n_groups]:
-            assert bar.get_facecolor()[:-1] == p.colors[0]
-        for bar in ax.patches[n_groups:]:
-            assert bar.get_facecolor()[:-1] == p.colors[1]
+        ax = barplot(long_df, x=agg_var, y=val_var, errorbar="sd")
+        order = categorical_order(long_df[agg_var])
+        for i, line in enumerate(ax.lines):
+            row = agg_df.loc[order[i]]
+            lo, hi = line.get_ydata()
+            assert lo == approx(row["mean"] - row["std"])
+            assert hi == approx(row["mean"] + row["std"])
 
-        positions = np.arange(len(p.plot_data))
-        for bar, pos in zip(ax.patches[:n_groups], positions):
-            assert bar.get_y() == approx(pos - p.width / 2)
-            assert bar.get_height() == approx(p.nested_width)
+    def test_width(self):
 
-        for bar, stat in zip(ax.patches, p.statistic.T.flat):
-            assert bar.get_x() == approx(0)
-            assert bar.get_width() == approx(stat)
+        width = .5
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        ax = barplot(x=x, y=y, width=width)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(i)
+            assert bar.get_width() == width
 
-    def test_draw_missing_bars(self):
+    def test_width_native_scale(self):
 
-        kws = self.default_kws.copy()
+        width = .5
+        x, y = [4, 6, 10], [1, 2, 3]
+        ax = barplot(x=x, y=y, width=width, native_scale=True)
+        for bar in ax.patches:
+            assert bar.get_width() == (width * 2)
 
-        order = list("abcd")
-        kws.update(x="g", y="y", order=order, data=self.df)
-        p = cat._BarPlotter(**kws)
+    def test_width_spaced_categories(self):
 
-        f, ax = plt.subplots()
-        p.draw_bars(ax, {})
+        ax = barplot(x=["a", "b", "c"], y=[4, 5, 6])
+        barplot(x=["a", "c"], y=[1, 3], ax=ax)
+        for bar in ax.patches:
+            assert bar.get_width() == pytest.approx(0.8)
 
-        assert len(ax.patches) == len(order)
-        assert len(ax.lines) == len(order)
+    def test_saturation_color(self):
 
-        plt.close("all")
+        color = (.1, .9, .2)
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        ax = barplot(x=x, y=y)
+        for bar in ax.patches:
+            assert np.var(bar.get_facecolor()[:3]) < np.var(color)
 
-        hue_order = list("mno")
-        kws.update(x="g", y="y", hue="h", hue_order=hue_order, data=self.df)
-        p = cat._BarPlotter(**kws)
+    def test_saturation_palette(self):
 
-        f, ax = plt.subplots()
-        p.draw_bars(ax, {})
+        palette = color_palette("viridis", 3)
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        ax = barplot(x=x, y=y, hue=x, palette=palette)
+        for i, bar in enumerate(ax.patches):
+            assert np.var(bar.get_facecolor()[:3]) < np.var(palette[i])
 
-        assert len(ax.patches) == len(p.plot_data) * len(hue_order)
-        assert len(ax.lines) == len(p.plot_data) * len(hue_order)
+    def test_legend_numeric_auto(self, long_df):
 
-        plt.close("all")
+        ax = barplot(long_df, x="x", y="y", hue="x")
+        assert len(ax.get_legend().texts) <= 6
 
-    def test_unaligned_index(self):
+    def test_legend_numeric_full(self, long_df):
 
-        f, (ax1, ax2) = plt.subplots(2)
-        cat.barplot(x=self.g, y=self.y, errorbar="sd", ax=ax1)
-        cat.barplot(x=self.g, y=self.y_perm, errorbar="sd", ax=ax2)
-        for l1, l2 in zip(ax1.lines, ax2.lines):
-            assert approx(l1.get_xydata()) == l2.get_xydata()
-        for p1, p2 in zip(ax1.patches, ax2.patches):
-            assert approx(p1.get_xy()) == p2.get_xy()
-            assert approx(p1.get_height()) == p2.get_height()
-            assert approx(p1.get_width()) == p2.get_width()
+        ax = barplot(long_df, x="x", y="y", hue="x", legend="full")
+        labels = [t.get_text() for t in ax.get_legend().texts]
+        levels = [str(x) for x in sorted(long_df["x"].unique())]
+        assert labels == levels
 
-        f, (ax1, ax2) = plt.subplots(2)
-        hue_order = self.h.unique()
-        cat.barplot(x=self.g, y=self.y, hue=self.h,
-                    hue_order=hue_order, errorbar="sd", ax=ax1)
-        cat.barplot(x=self.g, y=self.y_perm, hue=self.h,
-                    hue_order=hue_order, errorbar="sd", ax=ax2)
-        for l1, l2 in zip(ax1.lines, ax2.lines):
-            assert approx(l1.get_xydata()) == l2.get_xydata()
-        for p1, p2 in zip(ax1.patches, ax2.patches):
-            assert approx(p1.get_xy()) == p2.get_xy()
-            assert approx(p1.get_height()) == p2.get_height()
-            assert approx(p1.get_width()) == p2.get_width()
+    def test_legend_disabled(self, long_df):
 
-    def test_barplot_colors(self):
+        ax = barplot(long_df, x="x", y="y", hue="b", legend=False)
+        assert ax.get_legend() is None
 
-        # Test unnested palette colors
-        kws = self.default_kws.copy()
-        kws.update(x="g", y="y", data=self.df,
-                   saturation=1, palette="muted")
-        p = cat._BarPlotter(**kws)
+    def test_error_caps(self):
 
-        f, ax = plt.subplots()
-        p.draw_bars(ax, {})
+        x, y = ["a", "b", "c"] * 2, [1, 2, 3, 4, 5, 6]
+        ax = barplot(x=x, y=y, capsize=.8, errorbar="pi")
 
-        palette = palettes.color_palette("muted", len(self.g.unique()))
-        for patch, pal_color in zip(ax.patches, palette):
-            assert patch.get_facecolor()[:-1] == pal_color
+        assert len(ax.patches) == len(ax.lines)
+        for bar, error in zip(ax.patches, ax.lines):
+            pos = error.get_xdata()
+            assert len(pos) == 8
+            assert np.nanmin(pos) == approx(bar.get_x())
+            assert np.nanmax(pos) == approx(bar.get_x() + bar.get_width())
 
-        plt.close("all")
+    def test_error_caps_native_scale(self):
 
-        # Test single color
-        color = (.2, .2, .3, 1)
-        kws = self.default_kws.copy()
-        kws.update(x="g", y="y", data=self.df,
-                   saturation=1, color=color)
-        p = cat._BarPlotter(**kws)
+        x, y = [2, 4, 20] * 2, [1, 2, 3, 4, 5, 6]
+        ax = barplot(x=x, y=y, capsize=.8, native_scale=True, errorbar="pi")
 
-        f, ax = plt.subplots()
-        p.draw_bars(ax, {})
+        assert len(ax.patches) == len(ax.lines)
+        for bar, error in zip(ax.patches, ax.lines):
+            pos = error.get_xdata()
+            assert len(pos) == 8
+            assert np.nanmin(pos) == approx(bar.get_x())
+            assert np.nanmax(pos) == approx(bar.get_x() + bar.get_width())
 
-        for patch in ax.patches:
-            assert patch.get_facecolor() == color
+    def test_error_caps_native_scale_log_transform(self):
 
-        plt.close("all")
+        x, y = [1, 10, 1000] * 2, [1, 2, 3, 4, 5, 6]
+        ax = mpl.figure.Figure().subplots()
+        ax.set_xscale("log")
+        barplot(x=x, y=y, capsize=.8, native_scale=True, errorbar="pi", ax=ax)
 
-        # Test nested palette colors
-        kws = self.default_kws.copy()
-        kws.update(x="g", y="y", hue="h", data=self.df,
-                   saturation=1, palette="Set2")
-        p = cat._BarPlotter(**kws)
+        assert len(ax.patches) == len(ax.lines)
+        for bar, error in zip(ax.patches, ax.lines):
+            pos = error.get_xdata()
+            assert len(pos) == 8
+            assert np.nanmin(pos) == approx(bar.get_x())
+            assert np.nanmax(pos) == approx(bar.get_x() + bar.get_width())
 
-        f, ax = plt.subplots()
-        p.draw_bars(ax, {})
+    def test_bar_kwargs(self):
 
-        palette = palettes.color_palette("Set2", len(self.h.unique()))
-        for patch in ax.patches[:len(self.g.unique())]:
-            assert patch.get_facecolor()[:-1] == palette[0]
-        for patch in ax.patches[len(self.g.unique()):]:
-            assert patch.get_facecolor()[:-1] == palette[1]
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        kwargs = dict(linewidth=3, facecolor=(.5, .4, .3, .2), rasterized=True)
+        ax = barplot(x=x, y=y, **kwargs)
+        for bar in ax.patches:
+            assert bar.get_linewidth() == kwargs["linewidth"]
+            assert bar.get_facecolor() == kwargs["facecolor"]
+            assert bar.get_rasterized() == kwargs["rasterized"]
 
-        plt.close("all")
+    @pytest.mark.parametrize("fill", [True, False])
+    def test_err_kws(self, fill):
 
-    def test_simple_barplots(self):
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        err_kws = dict(color=(1, 1, .5, .5), linewidth=5)
+        ax = barplot(x=x, y=y, fill=fill, err_kws=err_kws)
+        for line in ax.lines:
+            assert line.get_color() == err_kws["color"]
+            assert line.get_linewidth() == err_kws["linewidth"]
 
-        ax = cat.barplot(x="g", y="y", data=self.df)
-        assert len(ax.patches) == len(self.g.unique())
-        assert ax.get_xlabel() == "g"
-        assert ax.get_ylabel() == "y"
-        plt.close("all")
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            dict(data="wide"),
+            dict(data="wide", orient="h"),
+            dict(data="flat"),
+            dict(data="long", x="a", y="y"),
+            dict(data=None, x="a", y="y"),
+            dict(data="long", x="a", y="y", hue="a"),
+            dict(data=None, x="a", y="y", hue="a"),
+            dict(data="long", x="a", y="y", hue="b"),
+            dict(data=None, x="s", y="y", hue="a"),
+            dict(data="long", x="a", y="y", hue="s"),
+            dict(data="long", x="a", y="y", units="c"),
+            dict(data="null", x="a", y="y", hue="a", gap=.1, fill=False),
+            dict(data="long", x="s", y="y", hue="a", native_scale=True),
+            dict(data="long", x="d", y="y", hue="a", native_scale=True),
+            dict(data="long", x="a", y="y", errorbar=("pi", 50)),
+            dict(data="long", x="a", y="y", errorbar=None),
+            dict(data="long", x="a", y="y", capsize=.3, err_kws=dict(c="k")),
+            dict(data="long", x="a", y="y", color="blue", ec="green", alpha=.5),
+        ]
+    )
+    def test_vs_catplot(self, long_df, wide_df, null_df, flat_series, kwargs):
 
-        ax = cat.barplot(x="y", y="g", orient="h", data=self.df)
-        assert len(ax.patches) == len(self.g.unique())
-        assert ax.get_xlabel() == "y"
-        assert ax.get_ylabel() == "g"
-        plt.close("all")
+        kwargs = kwargs.copy()
+        kwargs["seed"] = 0
+        kwargs["n_boot"] = 10
 
-        ax = cat.barplot(x="g", y="y", hue="h", data=self.df)
-        assert len(ax.patches) == len(self.g.unique()) * len(self.h.unique())
-        assert ax.get_xlabel() == "g"
-        assert ax.get_ylabel() == "y"
-        plt.close("all")
+        if kwargs["data"] == "long":
+            kwargs["data"] = long_df
+        elif kwargs["data"] == "wide":
+            kwargs["data"] = wide_df
+        elif kwargs["data"] == "flat":
+            kwargs["data"] = flat_series
+        elif kwargs["data"] == "null":
+            kwargs["data"] = null_df
+        elif kwargs["data"] is None:
+            for var in ["x", "y", "hue"]:
+                if var in kwargs:
+                    kwargs[var] = long_df[kwargs[var]]
+
+        ax = barplot(**kwargs)
+        g = catplot(**kwargs, kind="bar")
 
-        ax = cat.barplot(x="y", y="g", hue="h", orient="h", data=self.df)
-        assert len(ax.patches) == len(self.g.unique()) * len(self.h.unique())
-        assert ax.get_xlabel() == "y"
-        assert ax.get_ylabel() == "g"
-        plt.close("all")
+        assert_plots_equal(ax, g.ax)
 
-    def test_errorbar(self, long_df):
+    def test_errwidth_deprecation(self):
 
-        ax = cat.barplot(data=long_df, x="a", y="y", errorbar=("sd", 2))
-        order = categorical_order(long_df["a"])
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        val = 5
+        with pytest.warns(FutureWarning, match="\n\nThe `errwidth` parameter"):
+            ax = barplot(x=x, y=y, errwidth=val)
+        for line in ax.lines:
+            assert line.get_linewidth() == val
 
-        for i, line in enumerate(ax.lines):
-            sub_df = long_df.loc[long_df["a"] == order[i], "y"]
-            mean = sub_df.mean()
-            sd = sub_df.std()
-            expected = mean - 2 * sd, mean + 2 * sd
-            assert_array_equal(line.get_ydata(), expected)
+    def test_errcolor_deprecation(self):
 
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        val = (1, .7, .4, .8)
+        with pytest.warns(FutureWarning, match="\n\nThe `errcolor` parameter"):
+            ax = barplot(x=x, y=y, errcolor=val)
+        for line in ax.lines:
+            assert line.get_color() == val
 
-class TestPointPlotter(CategoricalFixture):
+    def test_capsize_as_none_deprecation(self):
 
-    default_kws = dict(
-        x=None, y=None, hue=None, data=None,
-        estimator="mean", errorbar=("ci", 95),
-        n_boot=100, units=None, seed=None,
-        order=None, hue_order=None,
-        markers="o", linestyles="-", dodge=0,
-        join=True, scale=1, orient=None,
-        color=None, palette=None,
-        errwidth=None, capsize=None, label=None,
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        with pytest.warns(FutureWarning, match="\n\nPassing `capsize=None`"):
+            ax = barplot(x=x, y=y, capsize=None)
+        for line in ax.lines:
+            assert len(line.get_xdata()) == 2
 
-    )
+    def test_hue_implied_by_palette_deprecation(self):
 
-    def test_different_defualt_colors(self):
+        x = ["a", "b", "c"]
+        y = [1, 2, 3]
+        palette = "Set1"
+        colors = color_palette(palette, len(x))
+        msg = "Passing `palette` without assigning `hue` is deprecated."
+        with pytest.warns(FutureWarning, match=msg):
+            ax = barplot(x=x, y=y, saturation=1, palette=palette)
+        for i, bar in enumerate(ax.patches):
+            assert same_color(bar.get_facecolor(), colors[i])
 
-        kws = self.default_kws.copy()
-        kws.update(dict(x="g", y="y", data=self.df))
-        p = cat._PointPlotter(**kws)
-        color = palettes.color_palette()[0]
-        npt.assert_array_equal(p.colors, [color, color, color])
 
-    def test_hue_offsets(self):
+class TestPointPlot(SharedAggTests):
 
-        kws = self.default_kws.copy()
-        kws.update(dict(x="g", y="y", hue="h", data=self.df))
+    func = staticmethod(pointplot)
 
-        p = cat._PointPlotter(**kws)
-        npt.assert_array_equal(p.hue_offsets, [0, 0])
+    def get_last_color(self, ax):
+
+        color = ax.lines[-1].get_color()
+        return to_rgba(color)
 
-        kws.update(dict(dodge=.5))
+    @pytest.mark.parametrize("orient", ["x", "y"])
+    def test_single_var(self, orient):
 
-        p = cat._PointPlotter(**kws)
-        npt.assert_array_equal(p.hue_offsets, [-.25, .25])
+        vals = pd.Series([1, 3, 10])
+        ax = pointplot(**{orient: vals})
+        line = ax.lines[0]
+        assert getattr(line, f"get_{orient}data")() == approx(vals.mean())
 
-        kws.update(dict(x="h", hue="g", dodge=0))
+    @pytest.mark.parametrize("orient", ["x", "y", "h", "v"])
+    def test_wide_df(self, wide_df, orient):
 
-        p = cat._PointPlotter(**kws)
-        npt.assert_array_equal(p.hue_offsets, [0, 0, 0])
+        ax = pointplot(wide_df, orient=orient)
+        orient = {"h": "y", "v": "x"}.get(orient, orient)
+        depend = {"x": "y", "y": "x"}[orient]
+        line = ax.lines[0]
+        assert_array_equal(
+            getattr(line, f"get_{orient}data")(),
+            np.arange(len(wide_df.columns)),
+        )
+        assert_array_almost_equal(
+            getattr(line, f"get_{depend}data")(),
+            wide_df.mean(axis=0),
+        )
 
-        kws.update(dict(dodge=.3))
+    @pytest.mark.parametrize("orient", ["x", "y", "h", "v"])
+    def test_vector_orient(self, orient):
 
-        p = cat._PointPlotter(**kws)
-        npt.assert_array_equal(p.hue_offsets, [-.15, 0, .15])
+        keys, vals = ["a", "b", "c"], [1, 2, 3]
+        data = dict(zip(keys, vals))
+        orient = {"h": "y", "v": "x"}.get(orient, orient)
+        depend = {"x": "y", "y": "x"}[orient]
+        ax = pointplot(data, orient=orient)
+        line = ax.lines[0]
+        assert_array_equal(
+            getattr(line, f"get_{orient}data")(),
+            np.arange(len(keys)),
+        )
+        assert_array_equal(getattr(line, f"get_{depend}data")(), vals)
 
-    def test_draw_vertical_points(self):
+    def test_xy_vertical(self):
 
-        kws = self.default_kws.copy()
-        kws.update(x="g", y="y", data=self.df)
-        p = cat._PointPlotter(**kws)
+        x, y = ["a", "b", "c"], [1, 3, 2.5]
+        ax = pointplot(x=x, y=y)
+        for i, xy in enumerate(ax.lines[0].get_xydata()):
+            assert tuple(xy) == (i, y[i])
 
-        f, ax = plt.subplots()
-        p.draw_points(ax)
+    def test_xy_horizontal(self):
 
-        assert len(ax.collections) == 1
-        assert len(ax.lines) == len(p.plot_data) + 1
-        points = ax.collections[0]
-        assert len(points.get_offsets()) == len(p.plot_data)
+        x, y = [1, 3, 2.5], ["a", "b", "c"]
+        ax = pointplot(x=x, y=y)
+        for i, xy in enumerate(ax.lines[0].get_xydata()):
+            assert tuple(xy) == (x[i], i)
 
-        x, y = points.get_offsets().T
-        npt.assert_array_equal(x, np.arange(len(p.plot_data)))
-        npt.assert_array_equal(y, p.statistic)
+    def test_xy_with_na_grouper(self):
 
-        for got_color, want_color in zip(points.get_facecolors(),
-                                         p.colors):
-            npt.assert_array_equal(got_color[:-1], want_color)
+        x, y = ["a", None, "b"], [1, 2, 3]
+        ax = pointplot(x=x, y=y)
+        _draw_figure(ax.figure)  # For matplotlib<3.5
+        assert ax.get_xticks() == [0, 1]
+        assert [t.get_text() for t in ax.get_xticklabels()] == ["a", "b"]
+        assert_array_equal(ax.lines[0].get_xdata(), [0, 1])
+        assert_array_equal(ax.lines[0].get_ydata(), [1, 3])
 
-    def test_draw_horizontal_points(self):
+    def test_xy_with_na_value(self):
 
-        kws = self.default_kws.copy()
-        kws.update(x="y", y="g", orient="h", data=self.df)
-        p = cat._PointPlotter(**kws)
+        x, y = ["a", "b", "c"], [1, np.nan, 3]
+        ax = pointplot(x=x, y=y)
+        _draw_figure(ax.figure)  # For matplotlib<3.5
+        assert ax.get_xticks() == [0, 1, 2]
+        assert [t.get_text() for t in ax.get_xticklabels()] == x
+        assert_array_equal(ax.lines[0].get_xdata(), [0, 1, 2])
+        assert_array_equal(ax.lines[0].get_ydata(), y)
 
-        f, ax = plt.subplots()
-        p.draw_points(ax)
+    def test_hue(self):
 
-        assert len(ax.collections) == 1
-        assert len(ax.lines) == len(p.plot_data) + 1
-        points = ax.collections[0]
-        assert len(points.get_offsets()) == len(p.plot_data)
+        x, y = ["a", "a", "b", "b"], [1, 2, 3, 4]
+        hue = ["x", "y", "x", "y"]
+        ax = pointplot(x=x, y=y, hue=hue, errorbar=None)
+        for i, line in enumerate(ax.lines[:2]):
+            assert_array_equal(line.get_ydata(), y[i::2])
+            assert same_color(line.get_color(), f"C{i}")
 
-        x, y = points.get_offsets().T
-        npt.assert_array_equal(x, p.statistic)
-        npt.assert_array_equal(y, np.arange(len(p.plot_data)))
+    def test_xy_native_scale(self):
 
-        for got_color, want_color in zip(points.get_facecolors(),
-                                         p.colors):
-            npt.assert_array_equal(got_color[:-1], want_color)
+        x, y = [2, 4, 8], [1, 2, 3]
 
-    def test_draw_vertical_nested_points(self):
+        ax = pointplot(x=x, y=y, native_scale=True)
+        line = ax.lines[0]
+        assert_array_equal(line.get_xdata(), x)
+        assert_array_equal(line.get_ydata(), y)
 
-        kws = self.default_kws.copy()
-        kws.update(x="g", y="y", hue="h", data=self.df)
-        p = cat._PointPlotter(**kws)
+    @pytest.mark.parametrize("estimator", ["mean", np.mean])
+    def test_estimate(self, long_df, estimator):
 
-        f, ax = plt.subplots()
-        p.draw_points(ax)
+        agg_var, val_var = "a", "y"
+        agg_df = long_df.groupby(agg_var)[val_var].agg(estimator)
 
-        assert len(ax.collections) == 2
-        assert len(ax.lines) == len(p.plot_data) * len(p.hue_names) + len(p.hue_names)
+        ax = pointplot(long_df, x=agg_var, y=val_var, errorbar=None)
+        order = categorical_order(long_df[agg_var])
+        for i, xy in enumerate(ax.lines[0].get_xydata()):
+            assert tuple(xy) == approx((i, agg_df[order[i]]))
 
-        for points, numbers, color in zip(ax.collections,
-                                          p.statistic.T,
-                                          p.colors):
+    def test_estimate_log_transform(self, long_df):
 
-            assert len(points.get_offsets()) == len(p.plot_data)
+        ax = mpl.figure.Figure().subplots()
+        ax.set_xscale("log")
+        pointplot(x=long_df["z"], ax=ax)
+        val, = ax.lines[0].get_xdata()
+        assert val == 10 ** np.log10(long_df["z"]).mean()
 
-            x, y = points.get_offsets().T
-            npt.assert_array_equal(x, np.arange(len(p.plot_data)))
-            npt.assert_array_equal(y, numbers)
+    def test_errorbars(self, long_df):
 
-            for got_color in points.get_facecolors():
-                npt.assert_array_equal(got_color[:-1], color)
+        agg_var, val_var = "a", "y"
+        agg_df = long_df.groupby(agg_var)[val_var].agg(["mean", "std"])
 
-    def test_draw_horizontal_nested_points(self):
+        ax = pointplot(long_df, x=agg_var, y=val_var, errorbar="sd")
+        order = categorical_order(long_df[agg_var])
+        for i, line in enumerate(ax.lines[1:]):
+            row = agg_df.loc[order[i]]
+            lo, hi = line.get_ydata()
+            assert lo == approx(row["mean"] - row["std"])
+            assert hi == approx(row["mean"] + row["std"])
 
-        kws = self.default_kws.copy()
-        kws.update(x="y", y="g", hue="h", orient="h", data=self.df)
-        p = cat._PointPlotter(**kws)
+    def test_marker_linestyle(self):
 
-        f, ax = plt.subplots()
-        p.draw_points(ax)
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        ax = pointplot(x=x, y=y, marker="s", linestyle="--")
+        line = ax.lines[0]
+        assert line.get_marker() == "s"
+        assert line.get_linestyle() == "--"
 
-        assert len(ax.collections) == 2
-        assert len(ax.lines) == len(p.plot_data) * len(p.hue_names) + len(p.hue_names)
+    def test_markers_linestyles_single(self):
 
-        for points, numbers, color in zip(ax.collections,
-                                          p.statistic.T,
-                                          p.colors):
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        ax = pointplot(x=x, y=y, markers="s", linestyles="--")
+        line = ax.lines[0]
+        assert line.get_marker() == "s"
+        assert line.get_linestyle() == "--"
+
+    def test_markers_linestyles_mapped(self):
+
+        x, y = ["a", "a", "b", "b"], [1, 2, 3, 4]
+        hue = ["x", "y", "x", "y"]
+        markers = ["d", "s"]
+        linestyles = ["--", ":"]
+        ax = pointplot(
+            x=x, y=y, hue=hue,
+            markers=markers, linestyles=linestyles,
+            errorbar=None,
+        )
+        for i, line in enumerate(ax.lines[:2]):
+            assert line.get_marker() == markers[i]
+            assert line.get_linestyle() == linestyles[i]
+
+    def test_dodge_boolean(self):
+
+        x, y = ["a", "b", "a", "b"], [1, 2, 3, 4]
+        hue = ["x", "x", "y", "y"]
+        ax = pointplot(x=x, y=y, hue=hue, dodge=True, errorbar=None)
+        for i, xy in enumerate(ax.lines[0].get_xydata()):
+            assert tuple(xy) == (i - .025, y[i])
+        for i, xy in enumerate(ax.lines[1].get_xydata()):
+            assert tuple(xy) == (i + .025, y[2 + i])
+
+    def test_dodge_float(self):
+
+        x, y = ["a", "b", "a", "b"], [1, 2, 3, 4]
+        hue = ["x", "x", "y", "y"]
+        ax = pointplot(x=x, y=y, hue=hue, dodge=.2, errorbar=None)
+        for i, xy in enumerate(ax.lines[0].get_xydata()):
+            assert tuple(xy) == (i - .1, y[i])
+        for i, xy in enumerate(ax.lines[1].get_xydata()):
+            assert tuple(xy) == (i + .1, y[2 + i])
+
+    def test_dodge_log_scale(self):
+
+        x, y = [10, 1000, 10, 1000], [1, 2, 3, 4]
+        hue = ["x", "x", "y", "y"]
+        ax = mpl.figure.Figure().subplots()
+        ax.set_xscale("log")
+        pointplot(x=x, y=y, hue=hue, dodge=.2, native_scale=True, errorbar=None, ax=ax)
+        for i, xy in enumerate(ax.lines[0].get_xydata()):
+            assert tuple(xy) == approx((10 ** (np.log10(x[i]) - .2), y[i]))
+        for i, xy in enumerate(ax.lines[1].get_xydata()):
+            assert tuple(xy) == approx((10 ** (np.log10(x[2 + i]) + .2), y[2 + i]))
+
+    def test_err_kws(self):
+
+        x, y = ["a", "a", "b", "b"], [1, 2, 3, 4]
+        err_kws = dict(color=(.2, .5, .3), linewidth=10)
+        ax = pointplot(x=x, y=y, errorbar=("pi", 100), err_kws=err_kws)
+        for line in ax.lines[1:]:
+            assert same_color(line.get_color(), err_kws["color"])
+            assert line.get_linewidth() == err_kws["linewidth"]
+
+    def test_err_kws_inherited(self):
+
+        x, y = ["a", "a", "b", "b"], [1, 2, 3, 4]
+        kws = dict(color=(.2, .5, .3), linewidth=10)
+        ax = pointplot(x=x, y=y, errorbar=("pi", 100), **kws)
+        for line in ax.lines[1:]:
+            assert same_color(line.get_color(), kws["color"])
+            assert line.get_linewidth() == kws["linewidth"]
 
-            assert len(points.get_offsets()) == len(p.plot_data)
+    @pytest.mark.skipif(
+        _version_predates(mpl, "3.6"),
+        reason="Legend handle missing marker property"
+    )
+    def test_legend_contents(self):
 
-            x, y = points.get_offsets().T
-            npt.assert_array_equal(x, numbers)
-            npt.assert_array_equal(y, np.arange(len(p.plot_data)))
+        x, y = ["a", "a", "b", "b"], [1, 2, 3, 4]
+        hue = ["x", "y", "x", "y"]
+        ax = pointplot(x=x, y=y, hue=hue)
+        _draw_figure(ax.figure)
+        legend = ax.get_legend()
+        assert [t.get_text() for t in legend.texts] == ["x", "y"]
+        for i, handle in enumerate(get_legend_handles(legend)):
+            assert handle.get_marker() == "o"
+            assert handle.get_linestyle() == "-"
+            assert same_color(handle.get_color(), f"C{i}")
 
-            for got_color in points.get_facecolors():
-                npt.assert_array_equal(got_color[:-1], color)
+    @pytest.mark.skipif(
+        _version_predates(mpl, "3.6"),
+        reason="Legend handle missing marker property"
+    )
+    def test_legend_set_props(self):
 
-    def test_draw_missing_points(self):
+        x, y = ["a", "a", "b", "b"], [1, 2, 3, 4]
+        hue = ["x", "y", "x", "y"]
+        kws = dict(marker="s", linewidth=1)
+        ax = pointplot(x=x, y=y, hue=hue, **kws)
+        legend = ax.get_legend()
+        for i, handle in enumerate(get_legend_handles(legend)):
+            assert handle.get_marker() == kws["marker"]
+            assert handle.get_linewidth() == kws["linewidth"]
 
-        kws = self.default_kws.copy()
-        df = self.df.copy()
+    @pytest.mark.skipif(
+        _version_predates(mpl, "3.6"),
+        reason="Legend handle missing marker property"
+    )
+    def test_legend_synced_props(self):
 
-        kws.update(x="g", y="y", hue="h", hue_order=["x", "y"], data=df)
-        p = cat._PointPlotter(**kws)
-        f, ax = plt.subplots()
-        p.draw_points(ax)
+        x, y = ["a", "a", "b", "b"], [1, 2, 3, 4]
+        hue = ["x", "y", "x", "y"]
+        kws = dict(markers=["s", "d"], linestyles=["--", ":"])
+        ax = pointplot(x=x, y=y, hue=hue, **kws)
+        legend = ax.get_legend()
+        for i, handle in enumerate(get_legend_handles(legend)):
+            assert handle.get_marker() == kws["markers"][i]
+            assert handle.get_linestyle() == kws["linestyles"][i]
 
-        df.loc[df["h"] == "m", "y"] = np.nan
-        kws.update(x="g", y="y", hue="h", data=df)
-        p = cat._PointPlotter(**kws)
-        f, ax = plt.subplots()
-        p.draw_points(ax)
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            dict(data="wide"),
+            dict(data="wide", orient="h"),
+            dict(data="flat"),
+            dict(data="long", x="a", y="y"),
+            dict(data=None, x="a", y="y"),
+            dict(data="long", x="a", y="y", hue="a"),
+            dict(data=None, x="a", y="y", hue="a"),
+            dict(data="long", x="a", y="y", hue="b"),
+            dict(data=None, x="s", y="y", hue="a"),
+            dict(data="long", x="a", y="y", hue="s"),
+            dict(data="long", x="a", y="y", units="c"),
+            dict(data="null", x="a", y="y", hue="a"),
+            dict(data="long", x="s", y="y", hue="a", native_scale=True),
+            dict(data="long", x="d", y="y", hue="a", native_scale=True),
+            dict(data="long", x="a", y="y", errorbar=("pi", 50)),
+            dict(data="long", x="a", y="y", errorbar=None),
+            dict(data="null", x="a", y="y", hue="a", dodge=True),
+            dict(data="null", x="a", y="y", hue="a", dodge=.2),
+            dict(data="long", x="a", y="y", capsize=.3, err_kws=dict(c="k")),
+            dict(data="long", x="a", y="y", color="blue", marker="s"),
+            dict(data="long", x="a", y="y", hue="a", markers=["s", "d", "p"]),
+        ]
+    )
+    def test_vs_catplot(self, long_df, wide_df, null_df, flat_series, kwargs):
 
-    def test_unaligned_index(self):
+        kwargs = kwargs.copy()
+        kwargs["seed"] = 0
+        kwargs["n_boot"] = 10
 
-        f, (ax1, ax2) = plt.subplots(2)
-        cat.pointplot(x=self.g, y=self.y, errorbar="sd", ax=ax1)
-        cat.pointplot(x=self.g, y=self.y_perm, errorbar="sd", ax=ax2)
-        for l1, l2 in zip(ax1.lines, ax2.lines):
-            assert approx(l1.get_xydata()) == l2.get_xydata()
-        for p1, p2 in zip(ax1.collections, ax2.collections):
-            assert approx(p1.get_offsets()) == p2.get_offsets()
+        if kwargs["data"] == "long":
+            kwargs["data"] = long_df
+        elif kwargs["data"] == "wide":
+            kwargs["data"] = wide_df
+        elif kwargs["data"] == "flat":
+            kwargs["data"] = flat_series
+        elif kwargs["data"] == "null":
+            kwargs["data"] = null_df
+        elif kwargs["data"] is None:
+            for var in ["x", "y", "hue"]:
+                if var in kwargs:
+                    kwargs[var] = long_df[kwargs[var]]
+
+        ax = pointplot(**kwargs)
+        g = catplot(**kwargs, kind="point")
 
-        f, (ax1, ax2) = plt.subplots(2)
-        hue_order = self.h.unique()
-        cat.pointplot(x=self.g, y=self.y, hue=self.h,
-                      hue_order=hue_order, errorbar="sd", ax=ax1)
-        cat.pointplot(x=self.g, y=self.y_perm, hue=self.h,
-                      hue_order=hue_order, errorbar="sd", ax=ax2)
-        for l1, l2 in zip(ax1.lines, ax2.lines):
-            assert approx(l1.get_xydata()) == l2.get_xydata()
-        for p1, p2 in zip(ax1.collections, ax2.collections):
-            assert approx(p1.get_offsets()) == p2.get_offsets()
+        assert_plots_equal(ax, g.ax)
 
-    def test_pointplot_colors(self):
+    def test_legend_disabled(self, long_df):
 
-        # Test a single-color unnested plot
-        color = (.2, .2, .3, 1)
-        kws = self.default_kws.copy()
-        kws.update(x="g", y="y", data=self.df, color=color)
-        p = cat._PointPlotter(**kws)
+        ax = pointplot(long_df, x="x", y="y", hue="b", legend=False)
+        assert ax.get_legend() is None
 
-        f, ax = plt.subplots()
-        p.draw_points(ax)
+    def test_join_deprecation(self):
 
-        for line in ax.lines:
-            assert line.get_color() == color[:-1]
+        with pytest.warns(UserWarning, match="The `join` parameter"):
+            ax = pointplot(x=["a", "b", "c"], y=[1, 2, 3], join=False)
+        assert ax.lines[0].get_linestyle().lower() == "none"
 
-        for got_color in ax.collections[0].get_facecolors():
-            npt.assert_array_equal(rgb2hex(got_color), rgb2hex(color))
+    def test_scale_deprecation(self):
 
-        plt.close("all")
+        x, y = ["a", "b", "c"], [1, 2, 3]
+        ax = pointplot(x=x, y=y, errorbar=None)
+        with pytest.warns(UserWarning, match="The `scale` parameter"):
+            pointplot(x=x, y=y, errorbar=None, scale=2)
+        l1, l2 = ax.lines
+        assert l2.get_linewidth() == 2 * l1.get_linewidth()
+        assert l2.get_markersize() > l1.get_markersize()
 
-        # Test a multi-color unnested plot
-        palette = palettes.color_palette("Set1", 3)
-        kws.update(x="g", y="y", data=self.df, palette="Set1")
-        p = cat._PointPlotter(**kws)
+    def test_layered_plot_clipping(self):
 
-        assert not p.join
+        x, y = ['a'], [4]
+        pointplot(x=x, y=y)
+        x, y = ['b'], [5]
+        ax = pointplot(x=x, y=y)
+        y_range = ax.viewLim.intervaly
+        assert y_range[0] < 4 and y_range[1] > 5
 
-        f, ax = plt.subplots()
-        p.draw_points(ax)
 
-        for line, pal_color in zip(ax.lines, palette):
-            npt.assert_array_equal(line.get_color(), pal_color)
+class TestCountPlot:
 
-        for point_color, pal_color in zip(ax.collections[0].get_facecolors(),
-                                          palette):
-            npt.assert_array_equal(rgb2hex(point_color), rgb2hex(pal_color))
+    def test_empty(self):
 
-        plt.close("all")
+        ax = countplot()
+        assert not ax.patches
 
-        # Test a multi-colored nested plot
-        palette = palettes.color_palette("dark", 2)
-        kws.update(x="g", y="y", hue="h", data=self.df, palette="dark")
-        p = cat._PointPlotter(**kws)
+        ax = countplot(x=[])
+        assert not ax.patches
 
-        f, ax = plt.subplots()
-        p.draw_points(ax)
+    def test_labels_long(self, long_df):
 
-        for line in ax.lines[:(len(p.plot_data) + 1)]:
-            assert line.get_color() == palette[0]
-        for line in ax.lines[(len(p.plot_data) + 1):]:
-            assert line.get_color() == palette[1]
+        fig = mpl.figure.Figure()
+        axs = fig.subplots(2)
+        countplot(long_df, x="a", ax=axs[0])
+        countplot(long_df, x="b", stat="percent", ax=axs[1])
 
-        for i, pal_color in enumerate(palette):
-            for point_color in ax.collections[i].get_facecolors():
-                npt.assert_array_equal(point_color[:-1], pal_color)
+        # To populate texts; only needed on older matplotlibs
+        _draw_figure(fig)
 
-        plt.close("all")
+        assert axs[0].get_xlabel() == "a"
+        assert axs[1].get_xlabel() == "b"
+        assert axs[0].get_ylabel() == "count"
+        assert axs[1].get_ylabel() == "percent"
 
-    def test_simple_pointplots(self):
+    def test_wide_data(self, wide_df):
 
-        ax = cat.pointplot(x="g", y="y", data=self.df)
-        assert len(ax.collections) == 1
-        assert len(ax.lines) == len(self.g.unique()) + 1
-        assert ax.get_xlabel() == "g"
-        assert ax.get_ylabel() == "y"
-        plt.close("all")
+        ax = countplot(wide_df)
+        assert len(ax.patches) == len(wide_df.columns)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(i)
+            assert bar.get_y() == 0
+            assert bar.get_height() == len(wide_df)
+            assert bar.get_width() == approx(0.8)
 
-        ax = cat.pointplot(x="y", y="g", orient="h", data=self.df)
-        assert len(ax.collections) == 1
-        assert len(ax.lines) == len(self.g.unique()) + 1
-        assert ax.get_xlabel() == "y"
-        assert ax.get_ylabel() == "g"
-        plt.close("all")
+    def test_flat_series(self):
 
-        ax = cat.pointplot(x="g", y="y", hue="h", data=self.df)
-        assert len(ax.collections) == len(self.h.unique())
-        assert len(ax.lines) == (
-            len(self.g.unique()) * len(self.h.unique()) + len(self.h.unique())
-        )
-        assert ax.get_xlabel() == "g"
-        assert ax.get_ylabel() == "y"
-        plt.close("all")
+        vals = ["a", "b", "c"]
+        counts = [2, 1, 4]
+        vals = pd.Series([x for x, n in zip(vals, counts) for _ in range(n)])
+        ax = countplot(vals)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() == 0
+            assert bar.get_y() + bar.get_height() / 2 == approx(i)
+            assert bar.get_height() == approx(0.8)
+            assert bar.get_width() == counts[i]
+
+    def test_x_series(self):
+
+        vals = ["a", "b", "c"]
+        counts = [2, 1, 4]
+        vals = pd.Series([x for x, n in zip(vals, counts) for _ in range(n)])
+        ax = countplot(x=vals)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(i)
+            assert bar.get_y() == 0
+            assert bar.get_height() == counts[i]
+            assert bar.get_width() == approx(0.8)
 
-        ax = cat.pointplot(x="y", y="g", hue="h", orient="h", data=self.df)
-        assert len(ax.collections) == len(self.h.unique())
-        assert len(ax.lines) == (
-            len(self.g.unique()) * len(self.h.unique()) + len(self.h.unique())
-        )
-        assert ax.get_xlabel() == "y"
-        assert ax.get_ylabel() == "g"
-        plt.close("all")
+    def test_y_series(self):
 
-    def test_errorbar(self, long_df):
+        vals = ["a", "b", "c"]
+        counts = [2, 1, 4]
+        vals = pd.Series([x for x, n in zip(vals, counts) for _ in range(n)])
+        ax = countplot(y=vals)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() == 0
+            assert bar.get_y() + bar.get_height() / 2 == approx(i)
+            assert bar.get_height() == approx(0.8)
+            assert bar.get_width() == counts[i]
 
-        ax = cat.pointplot(
-            data=long_df, x="a", y="y", errorbar=("sd", 2), join=False
-        )
-        order = categorical_order(long_df["a"])
+    def test_hue_redundant(self):
 
-        for i, line in enumerate(ax.lines):
-            sub_df = long_df.loc[long_df["a"] == order[i], "y"]
-            mean = sub_df.mean()
-            sd = sub_df.std()
-            expected = mean - 2 * sd, mean + 2 * sd
-            assert_array_equal(line.get_ydata(), expected)
+        vals = ["a", "b", "c"]
+        counts = [2, 1, 4]
+        vals = pd.Series([x for x, n in zip(vals, counts) for _ in range(n)])
 
-    def test_on_facetgrid(self, long_df):
+        ax = countplot(x=vals, hue=vals, saturation=1)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_x() + bar.get_width() / 2 == approx(i)
+            assert bar.get_y() == 0
+            assert bar.get_height() == counts[i]
+            assert bar.get_width() == approx(0.8)
+            assert same_color(bar.get_facecolor(), f"C{i}")
+
+    def test_hue_dodged(self):
+
+        vals = ["a", "a", "a", "b", "b", "b"]
+        hue = ["x", "y", "y", "x", "x", "x"]
+        counts = [1, 3, 2, 0]
+
+        ax = countplot(x=vals, hue=hue, saturation=1)
+        for i, bar in enumerate(ax.patches):
+            sign = 1 if i // 2 else -1
+            assert (
+                bar.get_x() + bar.get_width() / 2
+                == approx(i % 2 + sign * 0.8 / 4)
+            )
+            assert bar.get_y() == 0
+            assert bar.get_height() == counts[i]
+            assert bar.get_width() == approx(0.8 / 2)
+            assert same_color(bar.get_facecolor(), f"C{i // 2}")
 
-        g = FacetGrid(long_df, hue="a")
-        g.map(pointplot, "a", "y")
-        g.add_legend()
+    @pytest.mark.parametrize("stat", ["percent", "probability", "proportion"])
+    def test_stat(self, long_df, stat):
 
-        order = categorical_order(long_df["a"])
-        legend_texts = [t.get_text() for t in g.legend.texts]
-        assert legend_texts == order
+        col = "a"
+        order = categorical_order(long_df[col])
+        expected = long_df[col].value_counts(normalize=True)
+        if stat == "percent":
+            expected *= 100
+        ax = countplot(long_df, x=col, stat=stat)
+        for i, bar in enumerate(ax.patches):
+            assert bar.get_height() == approx(expected[order[i]])
 
+    def test_xy_error(self, long_df):
 
-class TestCountPlot(CategoricalFixture):
+        with pytest.raises(TypeError, match="Cannot pass values for both"):
+            countplot(long_df, x="a", y="b")
 
-    def test_plot_elements(self):
+    def test_legend_numeric_auto(self, long_df):
 
-        ax = cat.countplot(x="g", data=self.df)
-        assert len(ax.patches) == self.g.unique().size
-        for p in ax.patches:
-            assert p.get_y() == 0
-            assert p.get_height() == self.g.size / self.g.unique().size
-        plt.close("all")
+        ax = countplot(long_df, x="x", hue="x")
+        assert len(ax.get_legend().texts) <= 6
 
-        ax = cat.countplot(y="g", data=self.df)
-        assert len(ax.patches) == self.g.unique().size
-        for p in ax.patches:
-            assert p.get_x() == 0
-            assert p.get_width() == self.g.size / self.g.unique().size
-        plt.close("all")
+    def test_legend_disabled(self, long_df):
 
-        ax = cat.countplot(x="g", hue="h", data=self.df)
-        assert len(ax.patches) == self.g.unique().size * self.h.unique().size
-        plt.close("all")
+        ax = countplot(long_df, x="x", hue="b", legend=False)
+        assert ax.get_legend() is None
 
-        ax = cat.countplot(y="g", hue="h", data=self.df)
-        assert len(ax.patches) == self.g.unique().size * self.h.unique().size
-        plt.close("all")
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            dict(data="wide"),
+            dict(data="wide", orient="h"),
+            dict(data="flat"),
+            dict(data="long", x="a"),
+            dict(data=None, x="a"),
+            dict(data="long", y="b"),
+            dict(data="long", x="a", hue="a"),
+            dict(data=None, x="a", hue="a"),
+            dict(data="long", x="a", hue="b"),
+            dict(data=None, x="s", hue="a"),
+            dict(data="long", x="a", hue="s"),
+            dict(data="null", x="a", hue="a"),
+            dict(data="long", x="s", hue="a", native_scale=True),
+            dict(data="long", x="d", hue="a", native_scale=True),
+            dict(data="long", x="a", stat="percent"),
+            dict(data="long", x="a", hue="b", stat="proportion"),
+            dict(data="long", x="a", color="blue", ec="green", alpha=.5),
+        ]
+    )
+    def test_vs_catplot(self, long_df, wide_df, null_df, flat_series, kwargs):
 
-    def test_input_error(self):
+        kwargs = kwargs.copy()
+        if kwargs["data"] == "long":
+            kwargs["data"] = long_df
+        elif kwargs["data"] == "wide":
+            kwargs["data"] = wide_df
+        elif kwargs["data"] == "flat":
+            kwargs["data"] = flat_series
+        elif kwargs["data"] == "null":
+            kwargs["data"] = null_df
+        elif kwargs["data"] is None:
+            for var in ["x", "y", "hue"]:
+                if var in kwargs:
+                    kwargs[var] = long_df[kwargs[var]]
+
+        ax = countplot(**kwargs)
+        g = catplot(**kwargs, kind="count")
 
-        with pytest.raises(ValueError):
-            cat.countplot(x="g", y="h", data=self.df)
+        assert_plots_equal(ax, g.ax)
 
 
 class TestCatPlot(CategoricalFixture):
@@ -2800,14 +2919,13 @@ class TestCatPlot(CategoricalFixture):
     def test_plot_elements(self):
 
         g = cat.catplot(x="g", y="y", data=self.df, kind="point")
-        assert len(g.ax.collections) == 1
-        want_lines = self.g.unique().size + 1
+        want_lines = 1 + self.g.unique().size
         assert len(g.ax.lines) == want_lines
 
         g = cat.catplot(x="g", y="y", hue="h", data=self.df, kind="point")
-        want_collections = self.h.unique().size
-        assert len(g.ax.collections) == want_collections
-        want_lines = (self.g.unique().size + 1) * self.h.unique().size
+        want_lines = (
+            len(self.g.unique()) * len(self.h.unique()) + 2 * len(self.h.unique())
+        )
         assert len(g.ax.lines) == want_lines
 
         g = cat.catplot(x="g", y="y", data=self.df, kind="bar")
@@ -2850,7 +2968,7 @@ class TestCatPlot(CategoricalFixture):
         g = cat.catplot(x="g", y="y", hue="h", data=self.df,
                         kind="violin", inner=None)
         want_elements = self.g.unique().size * self.h.unique().size
-        assert len(g.ax.collections) == want_elements
+        assert len(g.ax.collections) == want_elements + self.h.unique().size
 
         g = cat.catplot(x="g", y="y", data=self.df, kind="strip")
         want_elements = self.g.unique().size
@@ -2931,20 +3049,18 @@ class TestCatPlot(CategoricalFixture):
         for ax in g.axes.flat:
             assert len(ax.collections) == len(self.df.g.unique())
 
-        # Test unsharing workscol
-        with pytest.warns(UserWarning):
-            g = cat.catplot(
-                x="g", y="y", col="g", data=self.df, sharex=False, kind="bar",
-            )
-            for ax in g.axes.flat:
-                assert len(ax.patches) == 1
+        # Test unsharing works
+        g = cat.catplot(
+            x="g", y="y", col="g", data=self.df, sharex=False, kind="bar",
+        )
+        for ax in g.axes.flat:
+            assert len(ax.patches) == 1
 
-        with pytest.warns(UserWarning):
-            g = cat.catplot(
-                x="y", y="g", col="g", data=self.df, sharey=False, kind="bar",
-            )
-            for ax in g.axes.flat:
-                assert len(ax.patches) == 1
+        g = cat.catplot(
+            x="y", y="g", col="g", data=self.df, sharey=False, kind="bar",
+        )
+        for ax in g.axes.flat:
+            assert len(ax.patches) == 1
 
         # Make sure no warning is raised if color is provided on unshared plot
         with warnings.catch_warnings():
@@ -3483,3 +3599,35 @@ class TestBeeswarm:
         with pytest.warns(UserWarning, match=msg):
             new_points = p.add_gutters(points, 0)
         assert_array_equal(new_points, np.array([0, -.5, .4, .5]))
+
+
+class TestBoxPlotContainer:
+
+    @pytest.fixture
+    def container(self, wide_array):
+
+        ax = mpl.figure.Figure().subplots()
+        artist_dict = ax.boxplot(wide_array)
+        return BoxPlotContainer(artist_dict)
+
+    def test_repr(self, container, wide_array):
+
+        n = wide_array.shape[1]
+        assert str(container) == f"<BoxPlotContainer object with {n} boxes>"
+
+    def test_iteration(self, container):
+        for artist_tuple in container:
+            for attr in ["box", "median", "whiskers", "caps", "fliers", "mean"]:
+                assert hasattr(artist_tuple, attr)
+
+    def test_label(self, container):
+
+        label = "a box plot"
+        container.set_label(label)
+        assert container.get_label() == label
+
+    def test_children(self, container):
+
+        children = container.get_children()
+        for child in children:
+            assert isinstance(child, mpl.artist.Artist)
diff --git a/tests/test_core.py b/tests/test_core.py
index 798a8d61..1e528688 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -22,6 +22,7 @@ from seaborn._oldcore import (
     unique_markers,
     categorical_order,
 )
+from seaborn.utils import desaturate
 
 from seaborn.palettes import color_palette
 
@@ -325,6 +326,17 @@ class TestHueMapping:
         with pytest.warns(UserWarning, match="Ignoring `palette`"):
             HueMapping(p, palette="viridis")
 
+    def test_saturation(self, long_df):
+
+        p = VectorPlotter(data=long_df, variables=dict(x="x", y="y", hue="a"))
+        levels = categorical_order(long_df["a"])
+        palette = color_palette("viridis", len(levels))
+        saturation = 0.8
+
+        m = HueMapping(p, palette=palette, saturation=saturation)
+        for i, color in enumerate(m(levels)):
+            assert mpl.colors.same_color(color, desaturate(palette[i], saturation))
+
 
 class TestSizeMapping:
 
@@ -980,10 +992,10 @@ class TestVectorPlotter:
         for i, (sub_vars, _) in enumerate(iterator):
             assert sub_vars["hue"] == reversed_order[i]
 
-    def test_iter_data_dropna(self, missing_df):
+    def test_iter_data_dropna(self, null_df):
 
         p = VectorPlotter(
-            data=missing_df,
+            data=null_df,
             variables=dict(x="x", y="y", hue="a")
         )
         for _, sub_df in p.iter_data("hue"):
@@ -1471,39 +1483,39 @@ class TestCoreFunc:
         cats = pd.Series(["a", "b"] * 3)
         dates = pd.date_range("1999-09-22", "2006-05-14", 6)
 
-        assert infer_orient(cats, nums) == "v"
-        assert infer_orient(nums, cats) == "h"
+        assert infer_orient(cats, nums) == "x"
+        assert infer_orient(nums, cats) == "y"
 
-        assert infer_orient(cats, dates, require_numeric=False) == "v"
-        assert infer_orient(dates, cats, require_numeric=False) == "h"
+        assert infer_orient(cats, dates, require_numeric=False) == "x"
+        assert infer_orient(dates, cats, require_numeric=False) == "y"
 
-        assert infer_orient(nums, None) == "h"
+        assert infer_orient(nums, None) == "y"
         with pytest.warns(UserWarning, match="Vertical .+ `x`"):
-            assert infer_orient(nums, None, "v") == "h"
+            assert infer_orient(nums, None, "v") == "y"
 
-        assert infer_orient(None, nums) == "v"
+        assert infer_orient(None, nums) == "x"
         with pytest.warns(UserWarning, match="Horizontal .+ `y`"):
-            assert infer_orient(None, nums, "h") == "v"
+            assert infer_orient(None, nums, "h") == "x"
 
-        infer_orient(cats, None, require_numeric=False) == "h"
+        infer_orient(cats, None, require_numeric=False) == "y"
         with pytest.raises(TypeError, match="Horizontal .+ `x`"):
             infer_orient(cats, None)
 
-        infer_orient(cats, None, require_numeric=False) == "v"
+        infer_orient(cats, None, require_numeric=False) == "x"
         with pytest.raises(TypeError, match="Vertical .+ `y`"):
             infer_orient(None, cats)
 
-        assert infer_orient(nums, nums, "vert") == "v"
-        assert infer_orient(nums, nums, "hori") == "h"
+        assert infer_orient(nums, nums, "vert") == "x"
+        assert infer_orient(nums, nums, "hori") == "y"
 
-        assert infer_orient(cats, cats, "h", require_numeric=False) == "h"
-        assert infer_orient(cats, cats, "v", require_numeric=False) == "v"
-        assert infer_orient(cats, cats, require_numeric=False) == "v"
+        assert infer_orient(cats, cats, "h", require_numeric=False) == "y"
+        assert infer_orient(cats, cats, "v", require_numeric=False) == "x"
+        assert infer_orient(cats, cats, require_numeric=False) == "x"
 
         with pytest.raises(TypeError, match="Vertical .+ `y`"):
-            infer_orient(cats, cats, "v")
+            infer_orient(cats, cats, "x")
         with pytest.raises(TypeError, match="Horizontal .+ `x`"):
-            infer_orient(cats, cats, "h")
+            infer_orient(cats, cats, "y")
         with pytest.raises(TypeError, match="Neither"):
             infer_orient(cats, cats)
 
diff --git a/tests/test_distributions.py b/tests/test_distributions.py
index 4e5f16a2..4cada7d5 100644
--- a/tests/test_distributions.py
+++ b/tests/test_distributions.py
@@ -1433,12 +1433,12 @@ class TestHistPlotUnivariate(SharedAxesLevelTests):
             assert_array_almost_equal(start, wide_df[col].min())
             assert_array_almost_equal(stop, wide_df[col].max())
 
-    def test_weights_with_missing(self, missing_df):
+    def test_weights_with_missing(self, null_df):
 
-        ax = histplot(missing_df, x="x", weights="s", bins=5)
+        ax = histplot(null_df, x="x", weights="s", bins=5)
 
         bar_heights = [bar.get_height() for bar in ax.patches]
-        total_weight = missing_df[["x", "s"]].dropna()["s"].sum()
+        total_weight = null_df[["x", "s"]].dropna()["s"].sum()
         assert sum(bar_heights) == pytest.approx(total_weight)
 
     def test_weight_norm(self, rng):
@@ -1827,6 +1827,11 @@ class TestHistPlotUnivariate(SharedAxesLevelTests):
         assert len(handles) == 1
         assert labels == ["a label"]
 
+    def test_default_color_scout_cleanup(self, flat_series):
+
+        ax = histplot(flat_series)
+        assert len(ax.containers) == 1
+
 
 class TestHistPlotBivariate:
 
diff --git a/tests/test_matrix.py b/tests/test_matrix.py
index 9c71ab28..110f4f5c 100644
--- a/tests/test_matrix.py
+++ b/tests/test_matrix.py
@@ -21,10 +21,7 @@ except ImportError:
     _no_fastcluster = True
 
 import numpy.testing as npt
-try:
-    import pandas.testing as pdt
-except ImportError:
-    import pandas.util.testing as pdt
+import pandas.testing as pdt
 import pytest
 
 from seaborn import matrix as mat
diff --git a/tests/test_regression.py b/tests/test_regression.py
index b0675568..ac020968 100644
--- a/tests/test_regression.py
+++ b/tests/test_regression.py
@@ -7,10 +7,7 @@ import pandas as pd
 
 import pytest
 import numpy.testing as npt
-try:
-    import pandas.testing as pdt
-except ImportError:
-    import pandas.util.testing as pdt
+import pandas.testing as pdt
 
 try:
     import statsmodels.regression.linear_model as smlm
diff --git a/tests/test_relational.py b/tests/test_relational.py
index 53fcd9f9..ca7970d4 100644
--- a/tests/test_relational.py
+++ b/tests/test_relational.py
@@ -22,7 +22,7 @@ from seaborn.relational import (
 )
 
 from seaborn.utils import _draw_figure
-from seaborn._compat import get_colormap
+from seaborn._compat import get_colormap, get_legend_handles
 from seaborn._testing import assert_plots_equal
 
 
@@ -95,7 +95,7 @@ class TestRelationalPlotter(Helpers):
         p.assign_variables(data=wide_df)
         assert p.input_format == "wide"
         assert list(p.variables) == ["x", "y", "hue", "style"]
-        assert len(p.plot_data) == np.product(wide_df.shape)
+        assert len(p.plot_data) == np.prod(wide_df.shape)
 
         x = p.plot_data["x"]
         expected_x = np.tile(wide_df.index, wide_df.shape[1])
@@ -127,7 +127,7 @@ class TestRelationalPlotter(Helpers):
 
         numeric_df = long_df.select_dtypes("number")
 
-        assert len(p.plot_data) == np.product(numeric_df.shape)
+        assert len(p.plot_data) == np.prod(numeric_df.shape)
 
         x = p.plot_data["x"]
         expected_x = np.tile(numeric_df.index, numeric_df.shape[1])
@@ -158,7 +158,7 @@ class TestRelationalPlotter(Helpers):
         p.assign_variables(data=wide_array)
         assert p.input_format == "wide"
         assert list(p.variables) == ["x", "y", "hue", "style"]
-        assert len(p.plot_data) == np.product(wide_array.shape)
+        assert len(p.plot_data) == np.prod(wide_array.shape)
 
         nrow, ncol = wide_array.shape
 
@@ -189,7 +189,7 @@ class TestRelationalPlotter(Helpers):
         p.assign_variables(data=flat_array)
         assert p.input_format == "wide"
         assert list(p.variables) == ["x", "y"]
-        assert len(p.plot_data) == np.product(flat_array.shape)
+        assert len(p.plot_data) == np.prod(flat_array.shape)
 
         x = p.plot_data["x"]
         expected_x = np.arange(flat_array.shape[0])
@@ -680,6 +680,11 @@ class TestRelationalPlotter(Helpers):
         for text in g.legend.texts:
             assert float(text.get_text()) > 1e7
 
+    def test_lineplot_2d_dashes(self, long_df):
+        ax = lineplot(data=long_df[["x", "y"]], dashes=[(5, 5), (10, 10)])
+        for line in ax.get_lines():
+            assert line.is_dashed()
+
 
 class TestLinePlotter(SharedAxesLevelTests, Helpers):
 
@@ -1225,7 +1230,7 @@ class TestLinePlotter(SharedAxesLevelTests, Helpers):
         wide_df, wide_array,
         wide_list_of_series, wide_list_of_arrays, wide_list_of_lists,
         flat_array, flat_series, flat_list,
-        long_df, missing_df, object_df
+        long_df, null_df, object_df
     ):
 
         f, ax = plt.subplots()
@@ -1281,10 +1286,10 @@ class TestLinePlotter(SharedAxesLevelTests, Helpers):
         lineplot(x="x", y="y", hue="a", style="b", data=long_df)
         ax.clear()
 
-        lineplot(x="x", y="y", hue="a", style="a", data=missing_df)
+        lineplot(x="x", y="y", hue="a", style="a", data=null_df)
         ax.clear()
 
-        lineplot(x="x", y="y", hue="a", style="b", data=missing_df)
+        lineplot(x="x", y="y", hue="a", style="b", data=null_df)
         ax.clear()
 
         lineplot(x="x", y="y", hue="a", size="a", data=long_df)
@@ -1293,10 +1298,10 @@ class TestLinePlotter(SharedAxesLevelTests, Helpers):
         lineplot(x="x", y="y", hue="a", size="s", data=long_df)
         ax.clear()
 
-        lineplot(x="x", y="y", hue="a", size="a", data=missing_df)
+        lineplot(x="x", y="y", hue="a", size="a", data=null_df)
         ax.clear()
 
-        lineplot(x="x", y="y", hue="a", size="s", data=missing_df)
+        lineplot(x="x", y="y", hue="a", size="s", data=null_df)
         ax.clear()
 
         lineplot(x="x", y="y", hue="f", data=object_df)
@@ -1739,7 +1744,7 @@ class TestScatterPlotter(SharedAxesLevelTests, Helpers):
         legend_data = [
             {
                 label.get_text(): handle.get_sizes().item()
-                for label, handle in zip(legend.get_texts(), legend.legendHandles)
+                for label, handle in zip(legend.get_texts(), get_legend_handles(legend))
             } for legend in legends
         ]
 
@@ -1782,7 +1787,7 @@ class TestScatterPlotter(SharedAxesLevelTests, Helpers):
         wide_df, wide_array,
         flat_series, flat_array, flat_list,
         wide_list_of_series, wide_list_of_arrays, wide_list_of_lists,
-        long_df, missing_df, object_df
+        long_df, null_df, object_df
     ):
 
         f, ax = plt.subplots()
@@ -1835,10 +1840,10 @@ class TestScatterPlotter(SharedAxesLevelTests, Helpers):
         scatterplot(x="x", y="y", hue="a", style="b", data=long_df)
         ax.clear()
 
-        scatterplot(x="x", y="y", hue="a", style="a", data=missing_df)
+        scatterplot(x="x", y="y", hue="a", style="a", data=null_df)
         ax.clear()
 
-        scatterplot(x="x", y="y", hue="a", style="b", data=missing_df)
+        scatterplot(x="x", y="y", hue="a", style="b", data=null_df)
         ax.clear()
 
         scatterplot(x="x", y="y", hue="a", size="a", data=long_df)
@@ -1847,10 +1852,10 @@ class TestScatterPlotter(SharedAxesLevelTests, Helpers):
         scatterplot(x="x", y="y", hue="a", size="s", data=long_df)
         ax.clear()
 
-        scatterplot(x="x", y="y", hue="a", size="a", data=missing_df)
+        scatterplot(x="x", y="y", hue="a", size="a", data=null_df)
         ax.clear()
 
-        scatterplot(x="x", y="y", hue="a", size="s", data=missing_df)
+        scatterplot(x="x", y="y", hue="a", size="s", data=null_df)
         ax.clear()
 
         scatterplot(x="x", y="y", hue="f", data=object_df)
diff --git a/tests/test_statistics.py b/tests/test_statistics.py
index e3912788..f8fa444f 100644
--- a/tests/test_statistics.py
+++ b/tests/test_statistics.py
@@ -25,6 +25,10 @@ class DistributionFixtures:
     def x(self, rng):
         return rng.normal(0, 1, 100)
 
+    @pytest.fixture
+    def x2(self, rng):
+        return rng.normal(0, 1, 742)  # random value to avoid edge cases
+
     @pytest.fixture
     def y(self, rng):
         return rng.normal(0, 5, 100)
@@ -440,6 +444,15 @@ class TestECDF(DistributionFixtures):
         assert_array_almost_equal(stat[1:], np.arange(len(x)) + 1)
         assert stat[0] == 0
 
+    def test_univariate_percent(self, x2):
+
+        ecdf = ECDF(stat="percent")
+        stat, vals = ecdf(x2)
+
+        assert_array_equal(vals[1:], np.sort(x2))
+        assert_array_almost_equal(stat[1:], (np.arange(len(x2)) + 1) / len(x2) * 100)
+        assert stat[0] == 0
+
     def test_univariate_proportion_weights(self, x, weights):
 
         ecdf = ECDF()
diff --git a/tests/test_utils.py b/tests/test_utils.py
index ab0e0239..28c836e9 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -27,10 +27,12 @@ from seaborn.utils import (
     remove_na,
     load_dataset,
     _assign_default_kwargs,
+    _check_argument,
     _draw_figure,
     _deprecate_ci,
-    _version_predates,
+    _version_predates, DATASET_NAMES_URL,
 )
+from seaborn._compat import get_legend_handles
 
 
 a_norm = np.random.randn(100)
@@ -89,6 +91,9 @@ def test_desaturate():
     out4 = utils.desaturate("red", .5)
     assert out4 == (.75, .25, .25)
 
+    out5 = utils.desaturate("lightblue", 1)
+    assert out5 == mpl.colors.to_rgb("lightblue")
+
 
 def test_desaturation_prop():
     """Test that pct outside of [0, 1] raises exception."""
@@ -407,8 +412,8 @@ def test_move_legend_grid_object(long_df):
     assert g.legend.get_title().get_text() == hue_var
     assert g.legend.get_title().get_size() == fontsize
 
-    assert g.legend.legendHandles
-    for i, h in enumerate(g.legend.legendHandles):
+    assert get_legend_handles(g.legend)
+    for i, h in enumerate(get_legend_handles(g.legend)):
         assert mpl.colors.to_rgb(h.get_color()) == mpl.colors.to_rgb(f"C{i}")
 
 
@@ -441,14 +446,14 @@ def check_load_cached_dataset(name):
         assert_frame_equal(ds, ds2)
 
 
-@_network(url="https://github.com/mwaskom/seaborn-data")
+@_network(url=DATASET_NAMES_URL)
 def test_get_dataset_names():
     names = get_dataset_names()
     assert names
     assert "tips" in names
 
 
-@_network(url="https://github.com/mwaskom/seaborn-data")
+@_network(url=DATASET_NAMES_URL)
 def test_load_datasets():
 
     # Heavy test to verify that we can load all available datasets
@@ -459,7 +464,7 @@ def test_load_datasets():
         check_load_dataset(name)
 
 
-@_network(url="https://github.com/mwaskom/seaborn-data")
+@_network(url=DATASET_NAMES_URL)
 def test_load_dataset_string_error():
 
     name = "bad_name"
@@ -548,6 +553,23 @@ def test_assign_default_kwargs():
     assert kws == {"c": 3, "d": 2}
 
 
+def test_check_argument():
+
+    opts = ["a", "b", None]
+    assert _check_argument("arg", opts, "a") == "a"
+    assert _check_argument("arg", opts, None) is None
+    assert _check_argument("arg", opts, "aa", prefix=True) == "aa"
+    assert _check_argument("arg", opts, None, prefix=True) is None
+    with pytest.raises(ValueError, match="The value for `arg`"):
+        _check_argument("arg", opts, "c")
+    with pytest.raises(ValueError, match="The value for `arg`"):
+        _check_argument("arg", opts, "c", prefix=True)
+    with pytest.raises(ValueError, match="The value for `arg`"):
+        _check_argument("arg", opts[:-1], None)
+    with pytest.raises(ValueError, match="The value for `arg`"):
+        _check_argument("arg", opts[:-1], None, prefix=True)
+
+
 def test_draw_figure():
 
     f, ax = plt.subplots()