Repository: mwaskom/seaborn

Issue:
Legends don't represent additional visual properties
This is a meta issue replacing the following (exhaustive) list of reports:

- #2852
- #2005
- #1763
- #940

The basic issue is that artists in seaborn legends typically do not look exactly like artists in the plot in cases where additional matplotlib keyword arguments have been passed. This is semi-intentional (more like, it was an intentional decision to avoid the complexity of making this work) but is a source of confusion.

This does work as expected in the new objects interface, so I would expect it to eventually be resolved in the function interface once they are refactored to use that behind the scenes. Although the legend code in the objects interface is complex and probably a little buggy at the moment. I don't know whether there will be any effort to improve this aspect of legends in the plotting functions without larger changes to the codebase; legends are hard (#2231).

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) Report
> Merging [#3467](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (4d9e619) into [master](https://app.codecov.io/gh/mwaskom/seaborn/commit/e71d95b661a069e85473ad347773d35317611486?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (e71d95b) will **decrease** coverage by `0.01%`.
> The diff coverage is `98.79%`.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467/graphs/tree.svg?width=650&height=150&src=pr&token=SLuzHnA9Bl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)

```diff
@@            Coverage Diff             @@
##           master    #3467      +/-   ##
==========================================
- Coverage   98.33%   98.32%   -0.01%     
==========================================
  Files          75       75              
  Lines       24460    24562     +102     
==========================================
+ Hits        24052    24151      +99     
- Misses        408      411       +3     
```


| [Files Changed](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) | Coverage Δ | |
|---|---|---|
| [seaborn/\_base.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9fYmFzZS5weQ==) | `97.25% <94.93%> (-0.27%)` | :arrow_down: |
| [seaborn/utils.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi91dGlscy5weQ==) | `94.17% <95.00%> (+0.04%)` | :arrow_up: |
| [seaborn/categorical.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9jYXRlZ29yaWNhbC5weQ==) | `98.89% <100.00%> (-0.01%)` | :arrow_down: |
| [seaborn/relational.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9yZWxhdGlvbmFsLnB5) | `99.63% <100.00%> (-0.07%)` | :arrow_down: |
| [tests/test\_axisgrid.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvdGVzdF9heGlzZ3JpZC5weQ==) | `99.54% <100.00%> (+<0.01%)` | :arrow_up: |
| [tests/test\_categorical.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvdGVzdF9jYXRlZ29yaWNhbC5weQ==) | `99.24% <100.00%> (+0.10%)` | :arrow_up: |
| [tests/test\_relational.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvdGVzdF9yZWxhdGlvbmFsLnB5) | `99.32% <100.00%> (+0.01%)` | :arrow_up: |
| [tests/test\_utils.py](https://app.codecov.io/gh/mwaskom/seaborn/pull/3467?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvdGVzdF91dGlscy5weQ==) | `98.95% <100.00%> (ø)` | |

</details>

Patch:
diff --git a/examples/heat_scatter.py b/examples/heat_scatter.py
index a7df620c..a8e5ee47 100644
--- a/examples/heat_scatter.py
+++ b/examples/heat_scatter.py
@@ -6,7 +6,6 @@ _thumb: .5, .5
 
 """
 import seaborn as sns
-from seaborn._compat import get_legend_handles
 sns.set_theme(style="whitegrid")
 
 # Load the brain networks dataset, select subset, and collapse the multi-index
@@ -38,5 +37,3 @@ g.despine(left=True, bottom=True)
 g.ax.margins(.02)
 for label in g.ax.get_xticklabels():
     label.set_rotation(90)
-for artist in get_legend_handles(g.legend):
-    artist.set_edgecolor(".7")
diff --git a/seaborn/_base.py b/seaborn/_base.py
index f27f544d..7b70fdc7 100644
--- a/seaborn/_base.py
+++ b/seaborn/_base.py
@@ -18,7 +18,9 @@ from seaborn.palettes import (
 )
 from seaborn.utils import (
     _check_argument,
+    _version_predates,
     desaturate,
+    locator_to_legend_entries,
     get_color_cycle,
     remove_na,
 )
@@ -1200,6 +1202,137 @@ class VectorPlotter:
             y_visible = any(t.get_visible() for t in ax.get_yticklabels())
             ax.set_ylabel(self.variables.get("y", default_y), visible=y_visible)
 
+    def add_legend_data(
+        self, ax, func, common_kws=None, attrs=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"]:
+            err = "`legend` must be 'auto', 'brief', 'full', or a boolean."
+            raise ValueError(err)
+        elif verbosity is True:
+            verbosity = "auto"
+
+        keys = []
+        legend_kws = {}
+        common_kws = {} if common_kws is None else common_kws.copy()
+        semantic_kws = {} if semantic_kws is None else semantic_kws.copy()
+
+        # 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
+        # invisible handle (which is a hack)
+        titles = {
+            title for title in
+            (self.variables.get(v, None) for v in ["hue", "size", "style"])
+            if title is not None
+        }
+        title = "" if len(titles) != 1 else titles.pop()
+        title_kws = dict(
+            visible=False, color="w", s=0, linewidth=0, marker="", dashes=""
+        )
+
+        def update(var_name, val_name, **kws):
+
+            key = var_name, val_name
+            if key in legend_kws:
+                legend_kws[key].update(**kws)
+            else:
+                keys.append(key)
+                legend_kws[key] = dict(**kws)
+
+        if attrs is None:
+            attrs = {"hue": "color", "size": ["linewidth", "s"], "style": None}
+        for var, names in attrs.items():
+            self._update_legend_data(
+                update, var, verbosity, title, title_kws, names, semantic_kws.get(var),
+            )
+
+        legend_data = {}
+        legend_order = []
+
+        # Don't allow color=None so we can set a neutral color for size/style legends
+        if common_kws.get("color", False) is None:
+            common_kws.pop("color")
+
+        for key in keys:
+
+            _, label = key
+            kws = legend_kws[key]
+            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:
+                    level_kws[attr] = kws[attr]
+            artist = func(label=label, **{"color": ".2", **common_kws, **level_kws})
+            if _version_predates(mpl, "3.5.0"):
+                if isinstance(artist, mpl.lines.Line2D):
+                    ax.add_line(artist)
+                elif isinstance(artist, mpl.patches.Patch):
+                    ax.add_patch(artist)
+                elif isinstance(artist, mpl.collections.Collection):
+                    ax.add_collection(artist)
+            else:
+                ax.add_artist(artist)
+            legend_data[key] = artist
+            legend_order.append(key)
+
+        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,
+    ):
+        """Generate legend tick values and formatted labels."""
+        brief_ticks = 6
+        mapper = getattr(self, f"_{var}_map", None)
+        if mapper is None:
+            return
+
+        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)
+
     # XXX If the scale_* methods are going to modify the plot_data structure, they
     # can't be called twice. That means that if they are called twice, they should
     # raise. Alternatively, we could store an original version of plot_data and each
diff --git a/seaborn/categorical.py b/seaborn/categorical.py
index 7ab91934..e61637e3 100644
--- a/seaborn/categorical.py
+++ b/seaborn/categorical.py
@@ -13,17 +13,18 @@ from matplotlib.patches import Rectangle
 import matplotlib.pyplot as plt
 
 from seaborn._core.typing import default, deprecated
-from seaborn._base import infer_orient, categorical_order
+from seaborn._base import VectorPlotter, infer_orient, categorical_order
 from seaborn._stats.density import KDE
-from seaborn.relational import _RelationalPlotter
 from seaborn import utils
 from seaborn.utils import (
     desaturate,
     _check_argument,
     _draw_figure,
     _default_color,
+    _get_patch_legend_artist,
     _get_transform_functions,
     _normalize_kwargs,
+    _scatter_legend_artist,
     _version_predates,
 )
 from seaborn._statistics import EstimateAggregator, LetterValues
@@ -39,14 +40,11 @@ __all__ = [
 ]
 
 
-# Subclassing _RelationalPlotter for the legend machinery,
-# but probably should move that more centrally
-class _CategoricalPlotter(_RelationalPlotter):
+class _CategoricalPlotter(VectorPlotter):
 
     wide_structure = {"x": "@columns", "y": "@values"}
     flat_structure = {"y": "@values"}
 
-    _legend_func = "scatter"
     _legend_attributes = ["color"]
 
     def __init__(
@@ -63,13 +61,13 @@ class _CategoricalPlotter(_RelationalPlotter):
 
         # This method takes care of some bookkeeping that is necessary because the
         # original categorical plots (prior to the 2021 refactor) had some rules that
-        # don't fit exactly into the logic of _core. It may be wise to have a second
+        # don't fit exactly into VectorPlotter logic. It may be wise to have a second
         # round of refactoring that moves the logic deeper, but this will keep things
         # relatively sensible for now.
 
         # 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.
+        # default VectorPlotter rules. If we do decide to make orient part of the
+        # _base variable assignment, we'll want to figure out how to express that.
         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)
@@ -85,7 +83,7 @@ class _CategoricalPlotter(_RelationalPlotter):
                 self.var_types["x"] = orig_y_type
 
         # The concept of an "orientation" is important to the original categorical
-        # plots, but there's no provision for it in _core, so we need to do it here.
+        # plots, but there's no provision for it in VectorPlotter, so we need it here.
         # Note that it could be useful for the other functions in at least two ways
         # (orienting a univariate distribution plot from long-form data and selecting
         # the aggregation axis in lineplot), so we may want to eventually refactor it.
@@ -403,7 +401,7 @@ class _CategoricalPlotter(_RelationalPlotter):
             show_legend = bool(self.legend)
 
         if show_legend:
-            self.add_legend_data(ax, func, common_kws, semantic_kws)
+            self.add_legend_data(ax, func, common_kws, semantic_kws=semantic_kws)
             handles, _ = ax.get_legend_handles_labels()
             if handles:
                 ax.legend(title=self.legend_title)
@@ -488,7 +486,7 @@ class _CategoricalPlotter(_RelationalPlotter):
             if "hue" in self.variables:
                 points.set_facecolors(self._hue_map(sub_data["hue"]))
 
-        self._configure_legend(ax, ax.scatter)
+        self._configure_legend(ax, _scatter_legend_artist, common_kws=plot_kws)
 
     def plot_swarms(
         self,
@@ -558,7 +556,7 @@ class _CategoricalPlotter(_RelationalPlotter):
                 points.draw = draw.__get__(points)
 
         _draw_figure(ax.figure)
-        self._configure_legend(ax, ax.scatter)
+        self._configure_legend(ax, _scatter_legend_artist, plot_kws)
 
     def plot_boxes(
         self,
@@ -712,12 +710,8 @@ class _CategoricalPlotter(_RelationalPlotter):
 
             ax.add_container(BoxPlotContainer(artists))
 
-        patch_kws = props["box"].copy()
-        if not fill:
-            patch_kws["facecolor"] = (1, 1, 1, 0)
-        else:
-            patch_kws["edgecolor"] = linecolor
-        self._configure_legend(ax, ax.fill_between, patch_kws)
+        legend_artist = _get_patch_legend_artist(fill)
+        self._configure_legend(ax, legend_artist, boxprops)
 
     def plot_boxens(
         self,
@@ -856,12 +850,9 @@ class _CategoricalPlotter(_RelationalPlotter):
 
         ax.autoscale_view(scalex=self.orient == "y", scaley=self.orient == "x")
 
-        patch_kws = box_kws.copy()
-        if not fill:
-            patch_kws["facecolor"] = (1, 1, 1, 0)
-        else:
-            patch_kws["edgecolor"] = linecolor
-        self._configure_legend(ax, ax.fill_between, patch_kws)
+        legend_artist = _get_patch_legend_artist(fill)
+        common_kws = {**box_kws, "linewidth": linewidth, "edgecolor": linecolor}
+        self._configure_legend(ax, legend_artist, common_kws)
 
     def plot_violins(
         self,
@@ -1135,7 +1126,9 @@ class _CategoricalPlotter(_RelationalPlotter):
                 }
                 ax.plot(invx(x2), invy(y2), **dot_kws)
 
-        self._configure_legend(ax, ax.fill_between)  # TODO, patch_kws)
+        legend_artist = _get_patch_legend_artist(fill)
+        common_kws = {**plot_kws, "linewidth": linewidth, "edgecolor": linecolor}
+        self._configure_legend(ax, legend_artist, common_kws)
 
     def plot_points(
         self,
@@ -1212,8 +1205,9 @@ class _CategoricalPlotter(_RelationalPlotter):
             if aggregator.error_method is not None:
                 self.plot_errorbars(ax, agg_data, capsize, sub_err_kws)
 
+        legend_artist = partial(mpl.lines.Line2D, [], [])
         semantic_kws = {"hue": {"marker": markers, "linestyle": linestyles}}
-        self._configure_legend(ax, ax.plot, sub_kws, semantic_kws)
+        self._configure_legend(ax, legend_artist, sub_kws, semantic_kws)
 
     def plot_bars(
         self,
@@ -1294,7 +1288,8 @@ class _CategoricalPlotter(_RelationalPlotter):
                     {"color": ".26" if fill else main_color, **err_kws}
                 )
 
-        self._configure_legend(ax, ax.fill_between)
+        legend_artist = _get_patch_legend_artist(fill)
+        self._configure_legend(ax, legend_artist, plot_kws)
 
     def plot_errorbars(self, ax, data, capsize, err_kws):
 
@@ -2041,7 +2036,7 @@ boxenplot.__doc__ = dedent("""\
 def stripplot(
     data=None, *, x=None, y=None, hue=None, order=None, hue_order=None,
     jitter=True, dodge=False, orient=None, color=None, palette=None,
-    size=5, edgecolor="face", linewidth=0,
+    size=5, edgecolor=default, linewidth=0,
     hue_norm=None, log_scale=None, native_scale=False, formatter=None, legend="auto",
     ax=None, **kwargs
 ):
@@ -2810,8 +2805,7 @@ def catplot(
         if saturation < 1:
             color = desaturate(color, saturation)
 
-    edgecolor = kwargs.pop("edgecolor", "face" if kind == "strip" else "auto")
-    edgecolor = p._complement_color(edgecolor, color, p._hue_map)
+    edgecolor = p._complement_color(kwargs.pop("edgecolor", default), color, p._hue_map)
 
     width = kwargs.pop("width", 0.8)
     dodge = kwargs.pop("dodge", False if kind in undodged_kinds else "auto")
diff --git a/seaborn/relational.py b/seaborn/relational.py
index 67a5d4d9..c04188a7 100644
--- a/seaborn/relational.py
+++ b/seaborn/relational.py
@@ -1,3 +1,4 @@
+from functools import partial
 import warnings
 
 import numpy as np
@@ -9,11 +10,11 @@ from ._base import (
     VectorPlotter,
 )
 from .utils import (
-    locator_to_legend_entries,
     adjust_legend_subtitles,
     _default_color,
     _deprecate_ci,
     _get_transform_functions,
+    _scatter_legend_artist,
 )
 from ._statistics import EstimateAggregator
 from .axisgrid import FacetGrid, _facet_docs
@@ -191,132 +192,10 @@ class _RelationalPlotter(VectorPlotter):
     # TODO where best to define default parameters?
     sort = True
 
-    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"]:
-            err = "`legend` must be 'auto', 'brief', 'full', or a boolean."
-            raise ValueError(err)
-        elif verbosity is True:
-            verbosity = "auto"
-
-        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
-        # invisible handle (which is a hack)
-        titles = {
-            title for title in
-            (self.variables.get(v, None) for v in ["hue", "size", "style"])
-            if title is not None
-        }
-        title = "" if len(titles) != 1 else titles.pop()
-        title_kws = dict(
-            visible=False, color="w", s=0, linewidth=0, marker="", dashes=""
-        )
-
-        def update(var_name, val_name, **kws):
-
-            key = var_name, val_name
-            if key in legend_kws:
-                legend_kws[key].update(**kws)
-            else:
-                keys.append(key)
-                legend_kws[key] = dict(**kws)
-
-        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),
-            )
-
-        if func is None:
-            func = getattr(ax, self._legend_func)
-
-        legend_data = {}
-        legend_order = []
-
-        for key in keys:
-
-            _, label = key
-            kws = legend_kws[key]
-            kws.setdefault("color", ".2")
-            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:
-                    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 = 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", None)
-        if mapper is None:
-            return
-
-        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):
 
     _legend_attributes = ["color", "linewidth", "marker", "dashes"]
-    _legend_func = "plot"
 
     def __init__(
         self, *,
@@ -484,7 +363,9 @@ class _LinePlotter(_RelationalPlotter):
         # Finalize the axes details
         self._add_axis_labels(ax)
         if self.legend:
-            self.add_legend_data(ax)
+            legend_artist = partial(mpl.lines.Line2D, xdata=[], ydata=[])
+            attrs = {"hue": "color", "size": "linewidth", "style": None}
+            self.add_legend_data(ax, legend_artist, kws, attrs)
             handles, _ = ax.get_legend_handles_labels()
             if handles:
                 legend = ax.legend(title=self.legend_title)
@@ -494,7 +375,6 @@ class _LinePlotter(_RelationalPlotter):
 class _ScatterPlotter(_RelationalPlotter):
 
     _legend_attributes = ["color", "s", "marker"]
-    _legend_func = "scatter"
 
     def __init__(self, *, data=None, variables={}, legend=None):
 
@@ -563,12 +443,15 @@ class _ScatterPlotter(_RelationalPlotter):
 
         if "linewidth" not in kws:
             sizes = points.get_sizes()
-            points.set_linewidths(.08 * np.sqrt(np.percentile(sizes, 10)))
+            linewidth = .08 * np.sqrt(np.percentile(sizes, 10))
+            points.set_linewidths(linewidth)
+            kws["linewidth"] = linewidth
 
         # Finalize the axes details
         self._add_axis_labels(ax)
         if self.legend:
-            self.add_legend_data(ax)
+            attrs = {"hue": "color", "size": "s", "style": None}
+            self.add_legend_data(ax, _scatter_legend_artist, kws, attrs)
             handles, _ = ax.get_legend_handles_labels()
             if handles:
                 legend = ax.legend(title=self.legend_title)
@@ -731,8 +614,6 @@ def scatterplot(
 
     p._attach(ax)
 
-    # Other functions have color as an explicit param,
-    # and we should probably do that here too
     color = kwargs.pop("color", None)
     kwargs["color"] = _default_color(ax.scatter, hue, color, kwargs)
 
@@ -938,12 +819,37 @@ def relplot(
     # Pass "" when the variable name is None to overwrite internal variables
     g.set_axis_labels(variables.get("x") or "", variables.get("y") or "")
 
-    # Show the legend
     if legend:
-        # Replace the original plot data so the legend uses
-        # numeric data with the correct type
+        # Replace the original plot data so the legend uses numeric data with
+        # the correct type, since we force a categorical mapping above.
         p.plot_data = plot_data
-        p.add_legend_data(g.axes.flat[0])
+
+        # Handle the additional non-semantic keyword arguments out here.
+        # We're selective because some kwargs may be seaborn function specific
+        # and not relevant to the matplotlib artists going into the legend.
+        # Ideally, we will have a better solution where we don't need to re-make
+        # the legend out here and will have parity with the axes-level functions.
+        keys = ["c", "color", "alpha", "m", "marker"]
+        if kind == "scatter":
+            legend_artist = _scatter_legend_artist
+            keys += ["s", "facecolor", "fc", "edgecolor", "ec", "linewidth", "lw"]
+        else:
+            legend_artist = partial(mpl.lines.Line2D, xdata=[], ydata=[])
+            keys += [
+                "markersize", "ms",
+                "markeredgewidth", "mew",
+                "markeredgecolor", "mec",
+                "linestyle", "ls",
+                "linewidth", "lw",
+            ]
+
+        common_kws = {k: v for k, v in kwargs.items() if k in keys}
+        attrs = {"hue": "color", "style": None}
+        if kind == "scatter":
+            attrs["size"] = "s"
+        elif kind == "line":
+            attrs["size"] = "linewidth"
+        p.add_legend_data(g.axes.flat[0], legend_artist, common_kws, attrs)
         if p.legend_data:
             g.add_legend(legend_data=p.legend_data,
                          label_order=p.legend_order,
diff --git a/seaborn/utils.py b/seaborn/utils.py
index 7cc804a0..83527ba4 100644
--- a/seaborn/utils.py
+++ b/seaborn/utils.py
@@ -901,3 +901,44 @@ def _disable_autolayout():
 def _version_predates(lib: ModuleType, version: str) -> bool:
     """Helper function for checking version compatibility."""
     return Version(lib.__version__) < Version(version)
+
+
+def _scatter_legend_artist(**kws):
+
+    kws = _normalize_kwargs(kws, mpl.collections.PathCollection)
+
+    edgecolor = kws.pop("edgecolor", None)
+    rc = mpl.rcParams
+    line_kws = {
+        "linestyle": "",
+        "marker": kws.pop("marker", "o"),
+        "markersize": np.sqrt(kws.pop("s", rc["lines.markersize"] ** 2)),
+        "markerfacecolor": kws.pop("facecolor", kws.get("color")),
+        "markeredgewidth": kws.pop("linewidth", 0),
+        **kws,
+    }
+
+    if edgecolor is not None:
+        if edgecolor == "face":
+            line_kws["markeredgecolor"] = line_kws["markerfacecolor"]
+        else:
+            line_kws["markeredgecolor"] = edgecolor
+
+    return mpl.lines.Line2D([], [], **line_kws)
+
+
+def _get_patch_legend_artist(fill):
+
+    def legend_artist(**kws):
+
+        color = kws.pop("color", None)
+        if color is not None:
+            if fill:
+                kws["facecolor"] = color
+            else:
+                kws["edgecolor"] = color
+                kws["facecolor"] = "none"
+
+        return mpl.patches.Rectangle((0, 0), 0, 0, **kws)
+
+    return legend_artist


Test patch:
diff --git a/tests/test_axisgrid.py b/tests/test_axisgrid.py
index 30f930ae..6470edfa 100644
--- a/tests/test_axisgrid.py
+++ b/tests/test_axisgrid.py
@@ -14,6 +14,7 @@ from seaborn.palettes import color_palette
 from seaborn.relational import scatterplot
 from seaborn.distributions import histplot, kdeplot, distplot
 from seaborn.categorical import pointplot
+from seaborn.utils import _version_predates
 from seaborn import axisgrid as ag
 from seaborn._testing import (
     assert_plots_equal,
@@ -1423,13 +1424,14 @@ class TestPairGrid:
 
         assert_plots_equal(ax1, ax2, labels=False)
 
+    @pytest.mark.skipif(_version_predates(mpl, "3.7.0"), reason="Matplotlib bug")
     def test_pairplot_markers(self):
 
         vars = ["x", "y", "z"]
         markers = ["o", "X", "s"]
         g = ag.pairplot(self.df, hue="a", vars=vars, markers=markers)
-        m1 = get_legend_handles(g._legend)[0].get_paths()[0]
-        m2 = get_legend_handles(g._legend)[1].get_paths()[0]
+        m1 = get_legend_handles(g._legend)[0].get_marker()
+        m2 = get_legend_handles(g._legend)[1].get_marker()
         assert m1 != m2
 
         with pytest.warns(UserWarning):
@@ -1485,7 +1487,8 @@ class TestPairGrid:
         g.map(scatterplot)
         assert g.axes.shape == (3, 3)
         for ax in g.axes.flat:
-            assert len(ax.collections) == long_df["a"].nunique() + 1
+            pts = ax.collections[0].get_offsets()
+            assert len(pts) == len(long_df)
 
 
 class TestJointGrid:
diff --git a/tests/test_categorical.py b/tests/test_categorical.py
index 3949d6e8..c6f23a8f 100644
--- a/tests/test_categorical.py
+++ b/tests/test_categorical.py
@@ -608,6 +608,14 @@ class SharedScatterTests(SharedAxesLevelTests):
         vals = [float(t.get_text()) for t in ax.legend_.texts]
         assert (vals[1] - vals[0]) == approx(vals[2] - vals[1])
 
+    def test_legend_attributes(self, long_df):
+
+        kws = {"edgecolor": "r", "linewidth": 1}
+        ax = self.func(data=long_df, x="x", y="y", hue="a", **kws)
+        for pt in get_legend_handles(ax.get_legend()):
+            assert same_color(pt.get_markeredgecolor(), kws["edgecolor"])
+            assert pt.get_markeredgewidth() == kws["linewidth"]
+
     def test_legend_disabled(self, long_df):
 
         ax = self.func(data=long_df, x="y", y="a", hue="b", legend=False)
@@ -725,6 +733,33 @@ class SharedAggTests(SharedAxesLevelTests):
             assert label == level
 
 
+class SharedPatchArtistTests:
+
+    @pytest.mark.parametrize("fill", [True, False])
+    def test_legend_fill(self, long_df, fill):
+
+        palette = color_palette()
+        ax = self.func(
+            long_df, x="x", y="y", hue="a",
+            saturation=1, linecolor="k", fill=fill,
+        )
+        for i, patch in enumerate(get_legend_handles(ax.get_legend())):
+            fc = patch.get_facecolor()
+            ec = patch.get_edgecolor()
+            if fill:
+                assert same_color(fc, palette[i])
+                assert same_color(ec, "k")
+            else:
+                assert fc == (0, 0, 0, 0)
+                assert same_color(ec, palette[i])
+
+    def test_legend_attributes(self, long_df):
+
+        ax = self.func(long_df, x="x", y="y", hue="a", linewidth=3)
+        for patch in get_legend_handles(ax.get_legend()):
+            assert patch.get_linewidth() == 3
+
+
 class TestStripPlot(SharedScatterTests):
 
     func = staticmethod(stripplot)
@@ -787,7 +822,7 @@ class TestSwarmPlot(SharedScatterTests):
     func = staticmethod(partial(swarmplot, warn_thresh=1))
 
 
-class TestBoxPlot(SharedAxesLevelTests):
+class TestBoxPlot(SharedAxesLevelTests, SharedPatchArtistTests):
 
     func = staticmethod(boxplot)
 
@@ -1113,7 +1148,7 @@ class TestBoxPlot(SharedAxesLevelTests):
         assert_plots_equal(ax, g.ax)
 
 
-class TestBoxenPlot(SharedAxesLevelTests):
+class TestBoxenPlot(SharedAxesLevelTests, SharedPatchArtistTests):
 
     func = staticmethod(boxenplot)
 
@@ -1410,7 +1445,7 @@ class TestBoxenPlot(SharedAxesLevelTests):
         assert_plots_equal(ax, g.ax)
 
 
-class TestViolinPlot(SharedAxesLevelTests):
+class TestViolinPlot(SharedAxesLevelTests, SharedPatchArtistTests):
 
     func = staticmethod(violinplot)
 
@@ -1687,7 +1722,7 @@ class TestViolinPlot(SharedAxesLevelTests):
 
     def test_common_norm(self, long_df):
 
-        ax = violinplot(long_df, x="a", y="y", hue="c", common_norm=True, legend=False)
+        ax = violinplot(long_df, x="a", y="y", hue="c", common_norm=True)
         widths = []
         for poly in ax.collections:
             widths.append(self.violin_width(poly))
@@ -1891,7 +1926,7 @@ class TestBarPlot(SharedAggTests):
         x, y = ["a", "b", "c"], [1, 2, 3]
         hue = ["x", "x", "y"]
 
-        ax = barplot(x=x, y=y, hue=hue, saturation=1)
+        ax = barplot(x=x, y=y, hue=hue, saturation=1, legend=False)
         for i, bar in enumerate(ax.patches):
             assert bar.get_x() + bar.get_width() / 2 == approx(i)
             assert bar.get_y() == 0
@@ -1916,7 +1951,7 @@ class TestBarPlot(SharedAggTests):
         y = [1, 2, 3, 4]
         hue = ["x", "x", "y", "y"]
 
-        ax = barplot(x=x, y=y, hue=hue, saturation=1)
+        ax = barplot(x=x, y=y, hue=hue, saturation=1, legend=False)
         for i, bar in enumerate(ax.patches):
             sign = 1 if i // 2 else -1
             assert (
@@ -1934,7 +1969,7 @@ class TestBarPlot(SharedAggTests):
         y = [1, 2, 3, 4]
         hue = ["x", "x", "y", "y"]
 
-        ax = barplot(x=x, y=y, hue=hue, gap=.25)
+        ax = barplot(x=x, y=y, hue=hue, gap=.25, legend=False)
         for i, bar in enumerate(ax.patches):
             assert bar.get_width() == approx(0.8 / 2 * .75)
 
@@ -1944,7 +1979,7 @@ class TestBarPlot(SharedAggTests):
         y = [1, 2, 3, 4]
         hue = ["x", "x", "y", "y"]
 
-        ax = barplot(x=x, y=y, hue=hue, saturation=1, dodge=False)
+        ax = barplot(x=x, y=y, hue=hue, saturation=1, dodge=False, legend=False)
         for i, bar in enumerate(ax.patches):
             assert bar.get_x() + bar.get_width() / 2 == approx(i % 2)
             assert bar.get_y() == 0
@@ -1978,7 +2013,7 @@ class TestBarPlot(SharedAggTests):
         y = [1, 2, 3, 4]
         hue = ["x", "x", "y", "y"]
 
-        ax = barplot(x=x, y=y, hue=hue, fill=False)
+        ax = barplot(x=x, y=y, hue=hue, fill=False, legend=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))
@@ -2200,6 +2235,26 @@ class TestBarPlot(SharedAggTests):
             assert bar.get_facecolor() == kwargs["facecolor"]
             assert bar.get_rasterized() == kwargs["rasterized"]
 
+    def test_legend_attributes(self, long_df):
+
+        palette = color_palette()
+        ax = barplot(
+            long_df, x="a", y="y", hue="c", saturation=1, edgecolor="k", linewidth=3
+        )
+        for i, patch in enumerate(get_legend_handles(ax.get_legend())):
+            assert same_color(patch.get_facecolor(), palette[i])
+            assert same_color(patch.get_edgecolor(), "k")
+            assert patch.get_linewidth() == 3
+
+    def test_legend_unfilled(self, long_df):
+
+        palette = color_palette()
+        ax = barplot(long_df, x="a", y="y", hue="c", fill=False, linewidth=3)
+        for i, patch in enumerate(get_legend_handles(ax.get_legend())):
+            assert patch.get_facecolor() == (0, 0, 0, 0)
+            assert same_color(patch.get_edgecolor(), palette[i])
+            assert patch.get_linewidth() == 3
+
     @pytest.mark.parametrize("fill", [True, False])
     def test_err_kws(self, fill):
 
@@ -2729,7 +2784,7 @@ class TestCountPlot:
         hue = ["x", "y", "y", "x", "x", "x"]
         counts = [1, 3, 2, 0]
 
-        ax = countplot(x=vals, hue=hue, saturation=1)
+        ax = countplot(x=vals, hue=hue, saturation=1, legend=False)
         for i, bar in enumerate(ax.patches):
             sign = 1 if i // 2 else -1
             assert (
@@ -2869,8 +2924,8 @@ class TestCatPlot(CategoricalFixture):
         assert len(g.ax.lines) == want_elements
 
         g = cat.catplot(x="g", y="y", hue="h", data=self.df, kind="bar")
-        want_elements = self.g.unique().size * self.h.unique().size
-        assert len(g.ax.patches) == want_elements
+        want_elements = self.g.nunique() * self.h.nunique()
+        assert len(g.ax.patches) == (want_elements + self.h.nunique())
         assert len(g.ax.lines) == want_elements
 
         g = cat.catplot(x="g", data=self.df, kind="count")
@@ -2879,7 +2934,7 @@ class TestCatPlot(CategoricalFixture):
         assert len(g.ax.lines) == 0
 
         g = cat.catplot(x="g", hue="h", data=self.df, kind="count")
-        want_elements = self.g.unique().size * self.h.unique().size
+        want_elements = self.g.nunique() * self.h.nunique() + self.h.nunique()
         assert len(g.ax.patches) == want_elements
         assert len(g.ax.lines) == 0
 
@@ -2892,7 +2947,7 @@ class TestCatPlot(CategoricalFixture):
         assert len(self.get_box_artists(g.ax)) == want_artists
 
         g = cat.catplot(x="g", y="y", hue="h", data=self.df, kind="box")
-        want_artists = self.g.unique().size * self.h.unique().size
+        want_artists = self.g.nunique() * self.h.nunique()
         assert len(self.get_box_artists(g.ax)) == want_artists
 
         g = cat.catplot(x="g", y="y", data=self.df,
@@ -2902,8 +2957,8 @@ 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 + self.h.unique().size
+        want_elements = self.g.nunique() * self.h.nunique()
+        assert len(g.ax.collections) == want_elements
 
         g = cat.catplot(x="g", y="y", data=self.df, kind="strip")
         want_elements = self.g.unique().size
@@ -2912,7 +2967,7 @@ class TestCatPlot(CategoricalFixture):
             assert same_color(strip.get_facecolors(), "C0")
 
         g = cat.catplot(x="g", y="y", hue="h", data=self.df, kind="strip")
-        want_elements = self.g.unique().size + self.h.unique().size
+        want_elements = self.g.nunique()
         assert len(g.ax.collections) == want_elements
 
     def test_bad_plot_kind_error(self):
@@ -2954,13 +3009,15 @@ class TestCatPlot(CategoricalFixture):
         plt.close("all")
 
         ax = cat.pointplot(x="g", y="y", data=self.df, color="purple")
-        g = cat.catplot(x="g", y="y", data=self.df, color="purple")
+        g = cat.catplot(x="g", y="y", data=self.df, color="purple", kind="point")
         for l1, l2 in zip(ax.lines, g.ax.lines):
             assert l1.get_color() == l2.get_color()
         plt.close("all")
 
         ax = cat.pointplot(x="g", y="y", data=self.df, palette="Set2", hue="h")
-        g = cat.catplot(x="g", y="y", data=self.df, palette="Set2", hue="h")
+        g = cat.catplot(
+            x="g", y="y", data=self.df, palette="Set2", hue="h", kind="point"
+        )
         for l1, l2 in zip(ax.lines, g.ax.lines):
             assert l1.get_color() == l2.get_color()
         plt.close("all")
diff --git a/tests/test_relational.py b/tests/test_relational.py
index a7204999..569798a7 100644
--- a/tests/test_relational.py
+++ b/tests/test_relational.py
@@ -10,7 +10,7 @@ import pytest
 from numpy.testing import assert_array_equal, assert_array_almost_equal
 
 from seaborn.palettes import color_palette
-from seaborn._base import categorical_order
+from seaborn._base import categorical_order, unique_markers
 
 from seaborn.relational import (
     _RelationalPlotter,
@@ -21,7 +21,7 @@ from seaborn.relational import (
     scatterplot
 )
 
-from seaborn.utils import _draw_figure
+from seaborn.utils import _draw_figure, _version_predates
 from seaborn._compat import get_colormap, get_legend_handles
 from seaborn._testing import assert_plots_equal
 
@@ -46,7 +46,9 @@ def long_semantics(request):
 
 class Helpers:
 
-    # TODO Better place for these?
+    @pytest.fixture
+    def levels(self, long_df):
+        return {var: categorical_order(long_df[var]) for var in ["a", "b"]}
 
     def scatter_rgbs(self, collections):
         rgbs = []
@@ -691,6 +693,36 @@ class TestRelationalPlotter(Helpers):
         for line in ax.get_lines():
             assert line.is_dashed()
 
+    def test_legend_attributes_hue(self, long_df):
+
+        kws = {"s": 50, "linewidth": 1, "marker": "X"}
+        g = relplot(long_df, x="x", y="y", hue="a", **kws)
+        palette = color_palette()
+        for i, pt in enumerate(get_legend_handles(g.legend)):
+            assert same_color(pt.get_color(), palette[i])
+            assert pt.get_markersize() == np.sqrt(kws["s"])
+            assert pt.get_markeredgewidth() == kws["linewidth"]
+            if not _version_predates(mpl, "3.7.0"):
+                assert pt.get_marker() == kws["marker"]
+
+    def test_legend_attributes_style(self, long_df):
+
+        kws = {"s": 50, "linewidth": 1, "color": "r"}
+        g = relplot(long_df, x="x", y="y", style="a", **kws)
+        for pt in get_legend_handles(g.legend):
+            assert pt.get_markersize() == np.sqrt(kws["s"])
+            assert pt.get_markeredgewidth() == kws["linewidth"]
+            assert same_color(pt.get_color(), "r")
+
+    def test_legend_attributes_hue_and_style(self, long_df):
+
+        kws = {"s": 50, "linewidth": 1}
+        g = relplot(long_df, x="x", y="y", hue="a", style="b", **kws)
+        for pt in get_legend_handles(g.legend):
+            if pt.get_label() not in ["a", "b"]:
+                assert pt.get_markersize() == np.sqrt(kws["s"])
+                assert pt.get_markeredgewidth() == kws["linewidth"]
+
 
 class TestLinePlotter(SharedAxesLevelTests, Helpers):
 
@@ -700,200 +732,120 @@ class TestLinePlotter(SharedAxesLevelTests, Helpers):
 
         return to_rgba(ax.lines[-1].get_color())
 
-    def test_legend_data(self, long_df):
+    def test_legend_no_semantics(self, long_df):
 
-        f, ax = plt.subplots()
-
-        p = _LinePlotter(
-            data=long_df,
-            variables=dict(x="x", y="y"),
-            legend="full"
-        )
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
+        ax = lineplot(long_df, x="x", y="y")
+        handles, _ = ax.get_legend_handles_labels()
         assert handles == []
 
-        # --
+    def test_legend_hue_categorical(self, long_df, levels):
 
-        ax.clear()
-        p = _LinePlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", hue="a"),
-            legend="full",
-        )
-        p.add_legend_data(ax)
+        ax = lineplot(long_df, x="x", y="y", hue="a")
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_color() for h in handles]
-        assert labels == p._hue_map.levels
-        assert colors == p._hue_map(p._hue_map.levels)
+        assert labels == levels["a"]
+        assert colors == color_palette(n_colors=len(labels))
 
-        # --
+    def test_legend_hue_and_style_same(self, long_df, levels):
 
-        ax.clear()
-        p = _LinePlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", hue="a", style="a"),
-            legend="full",
-        )
-        p.map_style(markers=True)
-        p.add_legend_data(ax)
+        ax = lineplot(long_df, x="x", y="y", hue="a", style="a", markers=True)
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_color() for h in handles]
         markers = [h.get_marker() for h in handles]
-        assert labels == p._hue_map.levels
-        assert labels == p._style_map.levels
-        assert colors == p._hue_map(p._hue_map.levels)
-        assert markers == p._style_map(p._style_map.levels, "marker")
+        assert labels == levels["a"]
+        assert colors == color_palette(n_colors=len(labels))
+        assert markers == unique_markers(len(labels))
 
-        # --
+    def test_legend_hue_and_style_diff(self, long_df, levels):
 
-        ax.clear()
-        p = _LinePlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", hue="a", style="b"),
-            legend="full",
-        )
-        p.map_style(markers=True)
-        p.add_legend_data(ax)
+        ax = lineplot(long_df, x="x", y="y", hue="a", style="b", markers=True)
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_color() for h in handles]
         markers = [h.get_marker() for h in handles]
-        expected_labels = (
-            ["a"]
-            + p._hue_map.levels
-            + ["b"] + p._style_map.levels
-        )
-        expected_colors = (
-            ["w"] + p._hue_map(p._hue_map.levels)
-            + ["w"] + [".2" for _ in p._style_map.levels]
-        )
-        expected_markers = (
-            [""] + ["None" for _ in p._hue_map.levels]
-            + [""] + p._style_map(p._style_map.levels, "marker")
-        )
+        expected_labels = ["a", *levels["a"], "b", *levels["b"]]
+        expected_colors = [
+            "w", *color_palette(n_colors=len(levels["a"])),
+            "w", *[".2" for _ in levels["b"]],
+        ]
+        expected_markers = [
+            "", *["None" for _ in levels["a"]]
+            + [""] + unique_markers(len(levels["b"]))
+        ]
         assert labels == expected_labels
         assert colors == expected_colors
         assert markers == expected_markers
 
-        # --
+    def test_legend_hue_and_size_same(self, long_df, levels):
 
-        ax.clear()
-        p = _LinePlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", hue="a", size="a"),
-            legend="full"
-        )
-        p.add_legend_data(ax)
+        ax = lineplot(long_df, x="x", y="y", hue="a", size="a")
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_color() for h in handles]
         widths = [h.get_linewidth() for h in handles]
-        assert labels == p._hue_map.levels
-        assert labels == p._size_map.levels
-        assert colors == p._hue_map(p._hue_map.levels)
-        assert widths == p._size_map(p._size_map.levels)
+        assert labels == levels["a"]
+        assert colors == color_palette(n_colors=len(levels["a"]))
+        expected_widths = [
+            w * mpl.rcParams["lines.linewidth"]
+            for w in np.linspace(2, 0.5, len(levels["a"]))
+        ]
+        assert widths == expected_widths
 
-        # --
+    @pytest.mark.parametrize("var", ["hue", "size", "style"])
+    def test_legend_numerical_full(self, long_df, var):
 
         x, y = np.random.randn(2, 40)
         z = np.tile(np.arange(20), 2)
 
-        p = _LinePlotter(variables=dict(x=x, y=y, hue=z))
-
-        ax.clear()
-        p.legend = "full"
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert labels == [str(l) for l in p._hue_map.levels]
+        ax = lineplot(x=x, y=y, **{var: z}, legend="full")
+        _, labels = ax.get_legend_handles_labels()
+        assert labels == [str(z_i) for z_i in sorted(set(z))]
 
-        ax.clear()
-        p.legend = "brief"
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert len(labels) < len(p._hue_map.levels)
+    @pytest.mark.parametrize("var", ["hue", "size", "style"])
+    def test_legend_numerical_brief(self, var):
 
-        p = _LinePlotter(variables=dict(x=x, y=y, size=z))
+        x, y = np.random.randn(2, 40)
+        z = np.tile(np.arange(20), 2)
 
-        ax.clear()
-        p.legend = "full"
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert labels == [str(l) for l in p._size_map.levels]
+        ax = lineplot(x=x, y=y, **{var: z}, legend="brief")
+        _, labels = ax.get_legend_handles_labels()
+        if var == "style":
+            assert labels == [str(z_i) for z_i in sorted(set(z))]
+        else:
+            assert labels == ["0", "4", "8", "12", "16"]
 
-        ax.clear()
-        p.legend = "brief"
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert len(labels) < len(p._size_map.levels)
+    def test_legend_value_error(self, long_df):
 
-        ax.clear()
-        p.legend = "auto"
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert len(labels) < len(p._size_map.levels)
+        with pytest.raises(ValueError, match=r"`legend` must be"):
+            lineplot(long_df, x="x", y="y", hue="a", legend="bad_value")
 
-        ax.clear()
-        p.legend = True
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert len(labels) < len(p._size_map.levels)
+    @pytest.mark.parametrize("var", ["hue", "size"])
+    def test_legend_log_norm(self, var):
 
-        ax.clear()
-        p.legend = "bad_value"
-        with pytest.raises(ValueError):
-            p.add_legend_data(ax)
+        x, y = np.random.randn(2, 40)
+        z = np.tile(np.arange(20), 2)
 
-        ax.clear()
-        p = _LinePlotter(
-            variables=dict(x=x, y=y, hue=z + 1),
-            legend="brief"
-        )
-        p.map_hue(norm=mpl.colors.LogNorm()),
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
+        norm = mpl.colors.LogNorm()
+        ax = lineplot(x=x, y=y, **{var: z + 1, f"{var}_norm": norm})
+        _, labels = ax.get_legend_handles_labels()
         assert float(labels[1]) / float(labels[0]) == 10
 
-        ax.clear()
-        p = _LinePlotter(
-            variables=dict(x=x, y=y, hue=z % 2),
-            legend="auto"
-        )
-        p.map_hue(norm=mpl.colors.LogNorm()),
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
+    @pytest.mark.parametrize("var", ["hue", "size"])
+    def test_legend_binary_var(self, var):
+
+        x, y = np.random.randn(2, 40)
+        z = np.tile(np.arange(20), 2)
+
+        ax = lineplot(x=x, y=y, hue=z % 2)
+        _, labels = ax.get_legend_handles_labels()
         assert labels == ["0", "1"]
 
-        ax.clear()
-        p = _LinePlotter(
-            variables=dict(x=x, y=y, size=z + 1),
-            legend="brief"
-        )
-        p.map_size(norm=mpl.colors.LogNorm())
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert float(labels[1]) / float(labels[0]) == 10
+    @pytest.mark.parametrize("var", ["hue", "size"])
+    def test_legend_binary_numberic_brief(self, long_df, var):
 
-        ax.clear()
-        p = _LinePlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", hue="f"),
-            legend="brief",
-        )
-        p.add_legend_data(ax)
+        ax = lineplot(long_df, x="x", y="y", **{var: "f"}, legend="brief")
+        _, labels = ax.get_legend_handles_labels()
         expected_labels = ['0.20', '0.22', '0.24', '0.26', '0.28']
-        handles, labels = ax.get_legend_handles_labels()
         assert labels == expected_labels
 
-        ax.clear()
-        p = _LinePlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", size="f"),
-            legend="brief",
-        )
-        p.add_legend_data(ax)
-        expected_levels = ['0.20', '0.22', '0.24', '0.26', '0.28']
-        handles, labels = ax.get_legend_handles_labels()
-        assert labels == expected_levels
-
     def test_plot(self, long_df, repeated_df):
 
         f, ax = plt.subplots()
@@ -1217,10 +1169,41 @@ class TestLinePlotter(SharedAxesLevelTests, Helpers):
         ax = lineplot(data=wide_df, ax=ax1)
         assert ax is ax1
 
+    def test_legend_attributes_with_hue(self, long_df):
+
+        kws = {"marker": "o", "linewidth": 3}
+        ax = lineplot(long_df, x="x", y="y", hue="a", **kws)
+        palette = color_palette()
+        for i, line in enumerate(get_legend_handles(ax.get_legend())):
+            assert same_color(line.get_color(), palette[i])
+            assert line.get_linewidth() == kws["linewidth"]
+            if not _version_predates(mpl, "3.7.0"):
+                assert line.get_marker() == kws["marker"]
+
+    def test_legend_attributes_with_style(self, long_df):
+
+        kws = {"color": "r", "marker": "o", "linewidth": 3}
+        ax = lineplot(long_df, x="x", y="y", style="a", **kws)
+        for line in get_legend_handles(ax.get_legend()):
+            assert same_color(line.get_color(), kws["color"])
+            if not _version_predates(mpl, "3.7.0"):
+                assert line.get_marker() == kws["marker"]
+            assert line.get_linewidth() == kws["linewidth"]
+
+    def test_legend_attributes_with_hue_and_style(self, long_df):
+
+        kws = {"marker": "o", "linewidth": 3}
+        ax = lineplot(long_df, x="x", y="y", hue="a", style="b", **kws)
+        for line in get_legend_handles(ax.get_legend()):
+            if line.get_label() not in ["a", "b"]:
+                if not _version_predates(mpl, "3.7.0"):
+                    assert line.get_marker() == kws["marker"]
+                assert line.get_linewidth() == kws["linewidth"]
+
     def test_lineplot_vs_relplot(self, long_df, long_semantics):
 
-        ax = lineplot(data=long_df, **long_semantics)
-        g = relplot(data=long_df, kind="line", **long_semantics)
+        ax = lineplot(data=long_df, legend=False, **long_semantics)
+        g = relplot(data=long_df, kind="line", legend=False, **long_semantics)
 
         lin_lines = ax.lines
         rel_lines = g.ax.lines
@@ -1361,181 +1344,162 @@ class TestScatterPlotter(SharedAxesLevelTests, Helpers):
         self.func(data=long_df, x="x", y="y", fc="C4", ax=ax)
         assert self.get_last_color(ax) == to_rgba("C4")
 
-    def test_legend_data(self, long_df):
-
-        m = mpl.markers.MarkerStyle("o")
-        default_mark = m.get_path().transformed(m.get_transform())
+    def test_legend_no_semantics(self, long_df):
 
-        m = mpl.markers.MarkerStyle("")
-        null = m.get_path().transformed(m.get_transform())
+        ax = scatterplot(long_df, x="x", y="y")
+        handles, _ = ax.get_legend_handles_labels()
+        assert not handles
 
-        f, ax = plt.subplots()
+    def test_legend_hue(self, long_df):
 
-        p = _ScatterPlotter(
-            data=long_df,
-            variables=dict(x="x", y="y"),
-            legend="full",
-        )
-        p.add_legend_data(ax)
+        ax = scatterplot(long_df, x="x", y="y", hue="a")
         handles, labels = ax.get_legend_handles_labels()
-        assert handles == []
-
-        # --
-
-        ax.clear()
-        p = _ScatterPlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", hue="a"),
-            legend="full",
-        )
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        colors = [h.get_facecolors()[0] for h in handles]
-        expected_colors = p._hue_map(p._hue_map.levels)
-        assert labels == p._hue_map.levels
+        colors = [h.get_color() for h in handles]
+        expected_colors = color_palette(n_colors=len(handles))
         assert same_color(colors, expected_colors)
+        assert labels == categorical_order(long_df["a"])
 
-        # --
+    def test_legend_hue_style_same(self, long_df):
 
-        ax.clear()
-        p = _ScatterPlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", hue="a", style="a"),
-            legend="full",
-        )
-        p.map_style(markers=True)
-        p.add_legend_data(ax)
+        ax = scatterplot(long_df, x="x", y="y", hue="a", style="a")
         handles, labels = ax.get_legend_handles_labels()
-        colors = [h.get_facecolors()[0] for h in handles]
-        expected_colors = p._hue_map(p._hue_map.levels)
-        paths = [h.get_paths()[0] for h in handles]
-        expected_paths = p._style_map(p._style_map.levels, "path")
-        assert labels == p._hue_map.levels
-        assert labels == p._style_map.levels
+        colors = [h.get_color() for h in handles]
+        expected_colors = color_palette(n_colors=len(labels))
+        markers = [h.get_marker() for h in handles]
+        expected_markers = unique_markers(len(handles))
         assert same_color(colors, expected_colors)
-        assert self.paths_equal(paths, expected_paths)
+        assert markers == expected_markers
+        assert labels == categorical_order(long_df["a"])
 
-        # --
+    def test_legend_hue_style_different(self, long_df):
 
-        ax.clear()
-        p = _ScatterPlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", hue="a", style="b"),
-            legend="full",
-        )
-        p.map_style(markers=True)
-        p.add_legend_data(ax)
+        ax = scatterplot(long_df, x="x", y="y", hue="a", style="b")
         handles, labels = ax.get_legend_handles_labels()
-        colors = [h.get_facecolors()[0] for h in handles]
-        paths = [h.get_paths()[0] for h in handles]
-        expected_colors = (
-            ["w"] + p._hue_map(p._hue_map.levels)
-            + ["w"] + [".2" for _ in p._style_map.levels]
-        )
-        expected_paths = (
-            [null] + [default_mark for _ in p._hue_map.levels]
-            + [null] + p._style_map(p._style_map.levels, "path")
-        )
-        assert labels == (
-            ["a"] + p._hue_map.levels + ["b"] + p._style_map.levels
-        )
+        colors = [h.get_color() for h in handles]
+        expected_colors = [
+            "w", *color_palette(n_colors=long_df["a"].nunique()),
+            "w", *[".2" for _ in long_df["b"].unique()],
+        ]
+        markers = [h.get_marker() for h in handles]
+        expected_markers = [
+            "", *["o" for _ in long_df["a"].unique()],
+            "", *unique_markers(long_df["b"].nunique()),
+        ]
         assert same_color(colors, expected_colors)
-        assert self.paths_equal(paths, expected_paths)
+        assert markers == expected_markers
+        assert labels == [
+            "a", *categorical_order(long_df["a"]),
+            "b", *categorical_order(long_df["b"]),
+        ]
 
-        # --
+    def test_legend_data_hue_size_same(self, long_df):
 
-        ax.clear()
-        p = _ScatterPlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", hue="a", size="a"),
-            legend="full"
-        )
-        p.add_legend_data(ax)
+        ax = scatterplot(long_df, x="x", y="y", hue="a", size="a")
         handles, labels = ax.get_legend_handles_labels()
-        colors = [h.get_facecolors()[0] for h in handles]
-        expected_colors = p._hue_map(p._hue_map.levels)
-        sizes = [h.get_sizes()[0] for h in handles]
-        expected_sizes = p._size_map(p._size_map.levels)
-        assert labels == p._hue_map.levels
-        assert labels == p._size_map.levels
+        colors = [h.get_color() for h in handles]
+        expected_colors = color_palette(n_colors=len(labels))
+        sizes = [h.get_markersize() for h in handles]
+        ms = mpl.rcParams["lines.markersize"] ** 2
+        expected_sizes = np.sqrt(
+            [ms * scl for scl in np.linspace(2, 0.5, len(handles))]
+        ).tolist()
         assert same_color(colors, expected_colors)
         assert sizes == expected_sizes
+        assert labels == categorical_order(long_df["a"])
+        assert ax.get_legend().get_title().get_text() == "a"
 
-        # --
+    def test_legend_size_numeric_list(self, long_df):
 
-        ax.clear()
-        sizes_list = [10, 100, 200]
-        p = _ScatterPlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", size="s"),
-            legend="full",
-        )
-        p.map_size(sizes=sizes_list)
-        p.add_legend_data(ax)
+        size_list = [10, 100, 200]
+        ax = scatterplot(long_df, x="x", y="y", size="s", sizes=size_list)
         handles, labels = ax.get_legend_handles_labels()
-        sizes = [h.get_sizes()[0] for h in handles]
-        expected_sizes = p._size_map(p._size_map.levels)
-        assert labels == [str(l) for l in p._size_map.levels]
+        sizes = [h.get_markersize() for h in handles]
+        expected_sizes = list(np.sqrt(size_list))
         assert sizes == expected_sizes
+        assert labels == list(map(str, categorical_order(long_df["s"])))
+        assert ax.get_legend().get_title().get_text() == "s"
 
-        # --
+    def test_legend_size_numeric_dict(self, long_df):
 
-        ax.clear()
-        sizes_dict = {2: 10, 4: 100, 8: 200}
-        p = _ScatterPlotter(
-            data=long_df,
-            variables=dict(x="x", y="y", size="s"),
-            legend="full"
-        )
-        p.map_size(sizes=sizes_dict)
-        p.add_legend_data(ax)
+        size_dict = {2: 10, 4: 100, 8: 200}
+        ax = scatterplot(long_df, x="x", y="y", size="s", sizes=size_dict)
         handles, labels = ax.get_legend_handles_labels()
-        sizes = [h.get_sizes()[0] for h in handles]
-        expected_sizes = p._size_map(p._size_map.levels)
-        assert labels == [str(l) for l in p._size_map.levels]
+        sizes = [h.get_markersize() for h in handles]
+        order = categorical_order(long_df["s"])
+        expected_sizes = [np.sqrt(size_dict[k]) for k in order]
         assert sizes == expected_sizes
+        assert labels == list(map(str, order))
+        assert ax.get_legend().get_title().get_text() == "s"
 
-        # --
+    def test_legend_numeric_hue_full(self):
 
         x, y = np.random.randn(2, 40)
         z = np.tile(np.arange(20), 2)
+        ax = scatterplot(x=x, y=y, hue=z, legend="full")
+        _, labels = ax.get_legend_handles_labels()
+        assert labels == [str(z_i) for z_i in sorted(set(z))]
+        assert ax.get_legend().get_title().get_text() == ""
 
-        p = _ScatterPlotter(
-            variables=dict(x=x, y=y, hue=z),
-        )
-
-        ax.clear()
-        p.legend = "full"
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert labels == [str(l) for l in p._hue_map.levels]
+    def test_legend_numeric_hue_brief(self):
 
-        ax.clear()
-        p.legend = "brief"
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert len(labels) < len(p._hue_map.levels)
+        x, y = np.random.randn(2, 40)
+        z = np.tile(np.arange(20), 2)
+        ax = scatterplot(x=x, y=y, hue=z, legend="brief")
+        _, labels = ax.get_legend_handles_labels()
+        assert len(labels) < len(set(z))
 
-        p = _ScatterPlotter(
-            variables=dict(x=x, y=y, size=z),
-        )
+    def test_legend_numeric_size_full(self):
 
-        ax.clear()
-        p.legend = "full"
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert labels == [str(l) for l in p._size_map.levels]
+        x, y = np.random.randn(2, 40)
+        z = np.tile(np.arange(20), 2)
+        ax = scatterplot(x=x, y=y, size=z, legend="full")
+        _, labels = ax.get_legend_handles_labels()
+        assert labels == [str(z_i) for z_i in sorted(set(z))]
 
-        ax.clear()
-        p.legend = "brief"
-        p.add_legend_data(ax)
-        handles, labels = ax.get_legend_handles_labels()
-        assert len(labels) < len(p._size_map.levels)
+    def test_legend_numeric_size_brief(self):
 
-        ax.clear()
-        p.legend = "bad_value"
-        with pytest.raises(ValueError):
-            p.add_legend_data(ax)
+        x, y = np.random.randn(2, 40)
+        z = np.tile(np.arange(20), 2)
+        ax = scatterplot(x=x, y=y, size=z, legend="brief")
+        _, labels = ax.get_legend_handles_labels()
+        assert len(labels) < len(set(z))
+
+    def test_legend_attributes_hue(self, long_df):
+
+        kws = {"s": 50, "linewidth": 1, "marker": "X"}
+        ax = scatterplot(long_df, x="x", y="y", hue="a", **kws)
+        palette = color_palette()
+        for i, pt in enumerate(get_legend_handles(ax.get_legend())):
+            assert same_color(pt.get_color(), palette[i])
+            assert pt.get_markersize() == np.sqrt(kws["s"])
+            assert pt.get_markeredgewidth() == kws["linewidth"]
+            if not _version_predates(mpl, "3.7.0"):
+                # This attribute is empty on older matplotlibs
+                # but the legend looks correct so I assume it is a bug
+                assert pt.get_marker() == kws["marker"]
+
+    def test_legend_attributes_style(self, long_df):
+
+        kws = {"s": 50, "linewidth": 1, "color": "r"}
+        ax = scatterplot(long_df, x="x", y="y", style="a", **kws)
+        for pt in get_legend_handles(ax.get_legend()):
+            assert pt.get_markersize() == np.sqrt(kws["s"])
+            assert pt.get_markeredgewidth() == kws["linewidth"]
+            assert same_color(pt.get_color(), "r")
+
+    def test_legend_attributes_hue_and_style(self, long_df):
+
+        kws = {"s": 50, "linewidth": 1}
+        ax = scatterplot(long_df, x="x", y="y", hue="a", style="b", **kws)
+        for pt in get_legend_handles(ax.get_legend()):
+            if pt.get_label() not in ["a", "b"]:
+                assert pt.get_markersize() == np.sqrt(kws["s"])
+                assert pt.get_markeredgewidth() == kws["linewidth"]
+
+    def test_legend_value_error(self, long_df):
+
+        with pytest.raises(ValueError, match=r"`legend` must be"):
+            scatterplot(long_df, x="x", y="y", hue="a", legend="bad_value")
 
     def test_plot(self, long_df, repeated_df):
 
@@ -1716,14 +1680,6 @@ class TestScatterPlotter(SharedAxesLevelTests, Helpers):
             points1.get_linewidths().item() < points2.get_linewidths().item()
         )
 
-        ax.clear()
-        scatterplot(data=long_df, x="x", y="y", size=long_df["x"])
-        scatterplot(data=long_df, x="x", y="y", size=long_df["x"] * 2)
-        points1, points2, *_ = ax.collections
-        assert (
-            points1.get_linewidths().item() < points2.get_linewidths().item()
-        )
-
         ax.clear()
         lw = 2
         scatterplot(data=long_df, x="x", y="y", linewidth=lw)
@@ -1749,7 +1705,7 @@ class TestScatterPlotter(SharedAxesLevelTests, Helpers):
         legends = [ax.legend_ for ax in axs]
         legend_data = [
             {
-                label.get_text(): handle.get_sizes().item()
+                label.get_text(): handle.get_markersize()
                 for label, handle in zip(legend.get_texts(), get_legend_handles(legend))
             } for legend in legends
         ]
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 98c6f48d..be3744ce 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -437,7 +437,7 @@ def test_move_legend_with_labels(long_df):
     ax = scatterplot(long_df, x="x", y="y", hue="a", hue_order=order)
 
     handles_before = get_legend_handles(ax.get_legend())
-    colors_before = [tuple(h.get_facecolor().squeeze()) for h in handles_before]
+    colors_before = [h.get_markerfacecolor() for h in handles_before]
     utils.move_legend(ax, "best", labels=labels)
     _draw_figure(ax.figure)
 
@@ -445,7 +445,7 @@ def test_move_legend_with_labels(long_df):
     assert texts == labels
 
     handles_after = get_legend_handles(ax.get_legend())
-    colors_after = [tuple(h.get_facecolor().squeeze()) for h in handles_after]
+    colors_after = [h.get_markerfacecolor() for h in handles_after]
     assert colors_before == colors_after
 
     with pytest.raises(ValueError, match="Length of new labels"):