Repository: invent-framework/invent (language: python)

Issue:
Container refactoring
In preparation for adding flexbox containers, this PR makes the following changes to the existing grid-based containers:

* ~~Move grid-specific code from the Container base class into grid.py.~~ 
* Remove the "child wrapper", and put layout styles directly on the child. This significantly simplifies both the code and the generated HTML, and means `position="FILL"` is no longer a special case.

Unit tests have not yet been updated, but the calculator and farmyard examples both work fine. In fact, the Code page of the farmyard example is improved because long lines now scroll horizontally rather than overflowing the page.

Other changes:
* Update examples/calculator to PyScript 2024.5.1 (closes #107)
* Add an option to set the port number of the development server (e.g. `python utils/serve.py 9000`). This allows running two servers at once to compare old and new versions of the code.
Position property (e.g. "TOP-LEFT") should NOT BE A TEXT BOX!
The position of a component can be described with `VERTICAL-HORIZONTAL` text (e.g. `TOP-LEFT`, `MIDDLE` or `FILL`). This should NOT be a free text field, but generated from pre-defined selection of some sort.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index d032f5e..7462fe6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -115,5 +115,5 @@ venv.bak/
 *.swp
 
 # Zipped up assets
-/invent.zip
-/test_suite.zip
+invent.zip
+test_suite.zip
diff --git a/examples/calculator/index.html b/examples/calculator/index.html
index 729806c..5a8084d 100644
--- a/examples/calculator/index.html
+++ b/examples/calculator/index.html
@@ -8,8 +8,8 @@
     <meta name="viewport" content="width=device-width,initial-scale=1.0">
 
     <!-- PyScript -->
-    <link rel="stylesheet" href="https://pyscript.net/releases/2024.4.1/core.css">
-    <script type="module" src="https://pyscript.net/releases/2024.4.1/core.js"></script>
+    <link rel="stylesheet" href="https://pyscript.net/releases/2024.5.1/core.css">
+    <script type="module" src="https://pyscript.net/releases/2024.5.1/core.js"></script>
 
     <!-- App CSS Styles -->
     <link rel="stylesheet" href="https://unpkg.com/papercss@1.9.2/dist/paper.min.css">
diff --git a/examples/catfacts/main.py b/examples/catfacts/main.py
index 21c42cf..ae77ce3 100644
--- a/examples/catfacts/main.py
+++ b/examples/catfacts/main.py
@@ -61,12 +61,12 @@ def ready(value):
                         Image(
                             image=invent.media.images.puff.svg,
                             visible=from_datastore("working"),
+                            align_self="center",
                         ),
                         Button(
                             name="cat_fact_button",
                             label="Get Facts",
                             channel="get_cat_facts",
-                            position="FILL",
                             purpose="SUCCESS",
                             enabled=from_datastore(
                                 "working", with_function=ready
@@ -77,7 +77,7 @@ def ready(value):
                             text=from_datastore(
                                 "cat_fact", with_function=handle_cat_fact
                             ),
-                            position="MIDDLE-CENTER",
+                            align_self="center",
                         ),
                     ]
                 ),
diff --git a/examples/farmyard/main.py b/examples/farmyard/main.py
index 43913bc..964088f 100644
--- a/examples/farmyard/main.py
+++ b/examples/farmyard/main.py
@@ -64,44 +64,46 @@ def make_pigs(number_of_oinks):
         Page(
             name="Lucy",
             content=[
-                Slider(
-                    value=from_datastore("number_of_honks"),
-                    name="Honk Slider",
-                    position="FILL",
-                    step=1,
-                ),
                 Column(
                     content=[
                         Image(
                             image=invent.media.images.goose.png,
                             channel="honk",
-                            position="MIDDLE-CENTER",
+                            align_self="center",
                         ),
                         Row(
-                            position="CENTER",
+                            align_self="center",
                             content=[
                                 Button(
                                     name="button honk",
                                     label="HONK!",
                                     channel="honk",
-                                    position="FILL",
                                 ),
                                 Button(
                                     name="to_percy",
                                     label="Visit Percy",
                                     channel="navigate",
-                                    position="FILL",
                                 ),
+                            ],
+                        ),
+                        Row(
+                            content=[
                                 TextBox(
                                     name="number_of_honks",
                                     text=from_datastore("number_of_honks"),
-                                    position="MIDDLE-CENTER",
+                                    align_self="center",
                                 ),
-                            ],
+                                Slider(
+                                    value=from_datastore("number_of_honks"),
+                                    name="Honk Slider",
+                                    step=1,
+                                    flex=1,
+                                ),
+                            ]
                         ),
                         Row(
                             id="geese",
-                            position="CENTER",
+                            justify_content="center",
                             content=from_datastore(
                                 "number_of_honks", with_function=make_geese
                             ),
@@ -110,7 +112,6 @@ def make_pigs(number_of_oinks):
                             name="to_code",
                             label="Show Code",
                             channel="navigate",
-                            position="FILL",
                         ),
                     ]
                 ),
@@ -124,10 +125,10 @@ def make_pigs(number_of_oinks):
                         Image(
                             image=invent.media.images.pig.png,
                             channel="oink",
-                            position="MIDDLE-CENTER",
+                            align_self="center",
                         ),
                         Row(
-                            position="CENTER",
+                            align_self="center",
                             content=[
                                 Button(
                                     name="button oink",
@@ -138,18 +139,17 @@ def make_pigs(number_of_oinks):
                                     name="to_lucy",
                                     label="Visit Lucy",
                                     channel="navigate",
-                                    position="FILL",
                                 ),
                                 TextBox(
                                     name="number_of_oinks",
                                     text=from_datastore("number_of_oinks"),
-                                    position="MIDDLE-CENTER",
+                                    align_self="center",
                                 ),
                             ],
                         ),
                         Row(
                             id="pigs",
-                            position="CENTER",
+                            justify_content="center",
                             content=from_datastore(
                                 "number_of_oinks", with_function=make_pigs
                             ),
@@ -158,7 +158,6 @@ def make_pigs(number_of_oinks):
                             name="to_code",
                             label="Show Code",
                             channel="navigate",
-                            position="FILL",
                         ),
                     ],
                 ),
@@ -181,13 +180,13 @@ def make_pigs(number_of_oinks):
                         name="to_lucy",
                         label="Visit Lucy",
                         channel="navigate",
-                        position="FILL",
+                        flex=1,
                     ),
                     Button(
                         name="to_percy",
                         label="Visit Percy",
                         channel="navigate",
-                        position="FILL",
+                        flex=1,
                     ),
                 ]
             ),
diff --git a/src/invent/ui/containers/box.py b/src/invent/ui/containers/box.py
new file mode 100644
index 0000000..9b0e482
--- /dev/null
+++ b/src/invent/ui/containers/box.py
@@ -0,0 +1,33 @@
+from ..core import Container, ChoiceProperty
+from ..core.component import _TSHIRT_SIZES, ALIGNMENTS
+
+justify_content_kwargs = dict(
+    choices=ALIGNMENTS,
+    default_value="start",
+    map_to_style="justify-content"
+)
+
+
+class Box(Container):
+    """
+    Common base class for Row and Column.
+    """
+
+    gap = ChoiceProperty(
+        "The gap between items in the container",
+        choices=_TSHIRT_SIZES,
+        default_value="M",
+    )
+
+    def render(self):
+        element = super().render()
+        element.style.display = "flex"
+        element.style.flexDirection = self.flex_direction
+        return element
+
+    def on_gap_changed(self):
+        """
+        Set the gap between elements in the container (translating from t-shirt
+        sizes).
+        """
+        self._set_gap(self.gap, "gap")
diff --git a/src/invent/ui/containers/column.py b/src/invent/ui/containers/column.py
index 7d2362b..26a25ec 100644
--- a/src/invent/ui/containers/column.py
+++ b/src/invent/ui/containers/column.py
@@ -1,8 +1,8 @@
-from pyscript import document
-from ..core import Container
+from ..core import ChoiceProperty
+from .box import Box, justify_content_kwargs
 
 
-class Column(Container):
+class Column(Box):
     """
     A vertical container box.
     """
@@ -11,24 +11,9 @@ class Column(Container):
     def icon(cls):
         return '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><path fill="currentColor" d="M104 32H64a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H64V48h40Zm88-176h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40Z"/></svg>'  # noqa
 
-    def create_child_wrapper(self, child, index):
-        """
-        Wrap the child element in a div with grid styles set appropriately.
-        """
-        child_wrapper = document.createElement("div")
-        child_wrapper.style.setProperty("grid-column", 1)
-        child_wrapper.style.setProperty("grid-row", index)
-        child_wrapper.appendChild(child.element)
-        child.set_position(child_wrapper)
+    flex_direction = "column"
 
-        return child_wrapper
-
-    def update_child_wrapper(self, child, index):
-        """
-        Wrap the child element in a div with grid styles set appropriately.
-        """
-        child_wrapper = child.element.parentElement
-        child_wrapper.style.setProperty("grid-column", 1)
-        child_wrapper.style.setProperty("grid-row", index)
-        child_wrapper.appendChild(child.element)
-        child.set_position(child_wrapper)
+    justify_content = ChoiceProperty(
+        "Vertical alignment of children",
+        **justify_content_kwargs,
+    )
diff --git a/src/invent/ui/containers/grid.py b/src/invent/ui/containers/grid.py
index 8de64f7..915705f 100644
--- a/src/invent/ui/containers/grid.py
+++ b/src/invent/ui/containers/grid.py
@@ -1,5 +1,5 @@
-from pyscript import document
-from ..core import Container, IntegerProperty
+from ..core import Container, ChoiceProperty, IntegerProperty
+from ..core.component import _TSHIRT_SIZES
 
 
 class Grid(Container):
@@ -7,14 +7,29 @@ class Grid(Container):
     A grid.
     """
 
-    columns = IntegerProperty("Number of columns", 4)
-    column_gap = IntegerProperty("Space between columns", 0)
-    row_gap = IntegerProperty("Space between rows", 0)
+    column_gap = ChoiceProperty(
+        "The gap between columns in the grid.",
+        choices=_TSHIRT_SIZES,
+        default_value="M",
+    )
+    row_gap = ChoiceProperty(
+        "The gap between rows in the grid.",
+        choices=_TSHIRT_SIZES,
+        default_value="M",
+    )
+
+    columns = IntegerProperty("Number of columns.", 4)
 
     @classmethod
     def icon(cls):
         return '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><path fill="currentColor" d="M216 48H40a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m-112 96v-32h48v32Zm48 16v32h-48v-32ZM40 112h48v32H40Zm64-16V64h48v32Zm64 16h48v32h-48Zm48-16h-48V64h48ZM88 64v32H40V64Zm-48 96h48v32H40Zm176 32h-48v-32h48z"/></svg>'  # noqa
 
+    def on_column_gap_changed(self):
+        self._set_gap(self.column_gap, "column-gap")
+
+    def on_row_gap_changed(self):
+        self._set_gap(self.row_gap, "row-gap")
+
     def on_columns_changed(self):
         self.element.style.gridTemplateColumns = "auto " * self.columns
 
@@ -22,51 +37,15 @@ def render(self):
         """
         Render the component.
         """
-        element = document.createElement("div")
+        element = super().render()
         element.style.display = "grid"
-        element.style.gridTemplateColumns = "auto " * self.columns
-        element.style.columnGap = self.column_gap
-        element.style.rowGap = self.row_gap
-        element.classList.add(f"invent-{type(self).__name__.lower()}")
-
-        # Render the container's children.
-        self.render_children(element)
-
-        # Implementation detail: add child elements in the child class's own
-        # render method. See Column and Row classes for examples of this.
         return element
 
-    def create_child_wrapper(self, child, index):
-        """
-        Wrap the child element in a div with grid styles set appropriately.
-        """
-        child_wrapper = document.createElement("div")
-
+    def update_child(self, child, index):
         grid_row_span = child.row_span
         if grid_row_span:
-            child_wrapper.style.gridRow = "span " + str(grid_row_span)
+            child.element.style.gridRow = "span " + str(grid_row_span)
 
         grid_column_span = child.column_span
         if grid_column_span:
-            child_wrapper.style.gridColumn = "span " + str(grid_column_span)
-
-        child_wrapper.appendChild(child.element)
-        child.set_position(child_wrapper)
-
-        return child_wrapper
-
-    def update_child_wrapper(self, child, index):
-        """
-        Wrap the child element in a div with grid styles set appropriately.
-        """
-        child_wrapper = child.element.parentElement
-        grid_row_span = child.row_span
-        if grid_row_span:
-            child_wrapper.style.gridRow = "span " + str(grid_row_span)
-
-        grid_column_span = child.column_span
-        if grid_column_span:
-            child_wrapper.style.gridColumn = "span " + str(grid_column_span)
-
-        child_wrapper.appendChild(child.element)
-        child.set_position(child_wrapper)
+            child.element.style.gridColumn = "span " + str(grid_column_span)
diff --git a/src/invent/ui/containers/page.py b/src/invent/ui/containers/page.py
index b757a73..4756326 100644
--- a/src/invent/ui/containers/page.py
+++ b/src/invent/ui/containers/page.py
@@ -47,7 +47,7 @@ def show(self):
         """
         Make the page visible to the user.
         """
-        self.element.style.display = "grid"
+        self.element.style.display = "flex"
 
     def hide(self):
         """
diff --git a/src/invent/ui/containers/row.py b/src/invent/ui/containers/row.py
index e41429c..c9858a7 100644
--- a/src/invent/ui/containers/row.py
+++ b/src/invent/ui/containers/row.py
@@ -1,8 +1,8 @@
-from pyscript import document
-from ..core import Container
+from ..core import ChoiceProperty
+from .box import Box, justify_content_kwargs
 
 
-class Row(Container):
+class Row(Box):
     """
     A horizontal container box.
     """
@@ -11,56 +11,9 @@ class Row(Container):
     def icon(cls):
         return '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><path fill="currentColor" d="M208 136H48a16 16 0 0 0-16 16v40a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-40a16 16 0 0 0-16-16m0 56H48v-40h160zm0-144H48a16 16 0 0 0-16 16v40a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m0 56H48V64h160z"/></svg>'  # noqa
 
-    def render_children(self, element):
-        """
-        Render the container's children.
-        """
-        self._update_template_columns(element)
-        super().render_children(element)
+    flex_direction = "row"
 
-    def update_children(self):
-        """
-        Update the container's children.
-        """
-        self._update_template_columns(self.element)
-        super().update_children()
-
-    def create_child_wrapper(self, child, index):
-        """
-        Wrap the child element in a div with grid styles set appropriately.
-        """
-        child_wrapper = document.createElement("div")
-        child_wrapper.style.setProperty("grid-column", index)
-        child_wrapper.style.setProperty("grid-row", 1)
-        child_wrapper.appendChild(child.element)
-        child.set_position(child_wrapper)
-
-        return child_wrapper
-
-    def update_child_wrapper(self, child, index):
-        """
-        Wrap the child element in a div with grid styles set appropriately.
-        """
-        child_wrapper = child.element.parentElement
-        child_wrapper.style.setProperty("grid-column", index)
-        child_wrapper.style.setProperty("grid-row", 1)
-        child_wrapper.appendChild(child.element)
-        child.set_position(child_wrapper)
-
-    def _update_template_columns(self, element):
-        """
-        Update the container's template columns.
-        """
-
-        template_columns = []
-        for item in self.content:
-            if (
-                item.element.classList.contains("drop-zone")
-                and len(self.content) > 1
-            ):
-                template_columns.append("0px")
-
-            else:
-                template_columns.append("auto")
-
-        element.style.gridTemplateColumns = " ".join(template_columns)
+    justify_content = ChoiceProperty(
+        "Horizontal alignment of children",
+        **justify_content_kwargs,
+    )
diff --git a/src/invent/ui/core/__init__.py b/src/invent/ui/core/__init__.py
index 7b17bac..976cbf2 100644
--- a/src/invent/ui/core/__init__.py
+++ b/src/invent/ui/core/__init__.py
@@ -1,6 +1,4 @@
 from .component import (
-    _VALID_HORIZONTALS,
-    _VALID_VERTICALS,
     _DEFAULT_ICON,
     Component,
     Container,
@@ -21,15 +19,10 @@
 )
 
 __all__ = [
-    "_VALID_HORIZONTALS",
-    "_VALID_VERTICALS",
     "_DEFAULT_ICON",
-    "Column",
     "Component",
     "Container",
-    "Grid",
     "MessageBlueprint",
-    "Row",
     "Widget",
     "BooleanProperty",
     "ChoiceProperty",
diff --git a/src/invent/ui/core/component.py b/src/invent/ui/core/component.py
index 4d51d71..f2ef040 100644
--- a/src/invent/ui/core/component.py
+++ b/src/invent/ui/core/component.py
@@ -33,10 +33,9 @@
 )
 
 
-#: Valid flags for horizontal positions.
-_VALID_HORIZONTALS = {"LEFT", "CENTER", "RIGHT"}
-#: Valid flags for vertical positions.
-_VALID_VERTICALS = {"TOP", "MIDDLE", "BOTTOM"}
+ALIGNMENTS = ["start", "center", "end"]
+ALIGNMENTS_STRETCH = ALIGNMENTS + ["stretch"]
+
 #: T-shirt sizes used to indicate relative sizes of things.
 _TSHIRT_SIZES = (
     None,
@@ -110,8 +109,7 @@ class Component:
     A base class for all user interface components (Widget, Container).
 
     Ensures they all have optional names and ids. If they're not given, will
-    auto-generate them for the user. The position of the component determines
-    how it will be drawn in its parent.
+    auto-generate them for the user.
     """
 
     # Used for quick component look-up.
@@ -131,10 +129,34 @@ class Component:
         "The component is visible is set to True.", default_value=True
     )
 
-    # Properties that are used by the container that a component is in.
-    position = TextProperty(
-        "The component's position inside it's parent.",
-        default_value="FILL",
+    # TODO: once this is extracted into a layout class, make the description
+    # say whether the alignment is:
+    #   * horizontal (Column) or vertical (Row and Grid)
+    #   * inside its parent (Row and Column) or inside its grid cell (Grid)
+    align_self = ChoiceProperty(
+        "The component's alignment.",
+        choices=ALIGNMENTS_STRETCH,
+        default_value="stretch",
+        map_to_style="align-self",
+    )
+
+    # This property is only valid in a Grid, so its description can be more
+    # specific.
+    justify_self = ChoiceProperty(
+        "The component's horizontal alignment inside its grid cell.",
+        choices=ALIGNMENTS_STRETCH,
+        default_value="stretch",
+        map_to_style="justify-self",
+    )
+
+    # TODO: validate input
+    # TODO: once this is extracted into a layout class, make the description
+    # say whether the space is horizontal (Row) or vertical (Column).
+    flex = TextProperty(
+        "How much space the component will consume. May be blank to take no "
+        "extra space, 'auto' to take an equal portion of any free space, or "
+        "an integer to take the given proportion of the total space.",
+        map_to_style="flex",
     )
 
     column_span = TextProperty(
@@ -209,14 +231,6 @@ def on_visible_changed(self):
         """
         self.element.style.visibility = "visible" if self.visible else "hidden"
 
-    def on_position_changed(self):
-        """
-        Automatically called to update the position information relating to
-        the HTML element associated with the component.
-        """
-        if self.element.parentElement:
-            self.set_position(self.element.parentElement)
-
     def on_column_span_changed(self):
         """
         Automatically called to update the column span information relating to
@@ -380,91 +394,6 @@ def get_from_datastore(self, property_name):
 
         return getattr(self, f"_{property_name}_from_datastore", None)
 
-    def parse_position(self):
-        """
-        Parse "self.position" as: "VERTICAL-HORIZONTAL", "VERTICAL" or
-        "HORIZONTAL" values.
-
-        Valid values are defined in _VALID_VERTICALS and _VALID_HORIZONTALS.
-
-        Returns a tuple of (vertical_position, horizontal_position). Each
-        return value could be None.
-        """
-        definition = self.position.upper().split("-")
-        # Default values for the horizontal and vertical positions.
-        horizontal_position = None
-        vertical_position = None
-        if len(definition) == 1:
-            # Unary position (e.g. "TOP" or "CENTER")
-            unary_position = definition[0]
-            if unary_position in _VALID_HORIZONTALS:
-                horizontal_position = unary_position
-            elif unary_position in _VALID_VERTICALS:
-                vertical_position = unary_position
-        elif len(definition) == 2:
-            # Binary position (e.g. "TOP-CENTER" or "BOTTOM-RIGHT")
-            if definition[0] in _VALID_VERTICALS:
-                vertical_position = definition[0]
-            if definition[1] in _VALID_HORIZONTALS:
-                horizontal_position = definition[1]
-        if not (horizontal_position or vertical_position):
-            # Bail out if we don't have a valid position state.
-            raise ValueError(f"'{self.position}' is not a valid position.")
-        return vertical_position, horizontal_position
-
-    def set_position(self, container):
-        """
-        Given the value of "self.position", will adjust the CSS for the
-        rendered "self.element", and its container, so the resulting HTML puts
-        the element into the expected position in the container.
-        """
-
-        def reset():
-            """
-            Reset the style state for the component and its parent container.
-            """
-            self.element.style.removeProperty("width")
-            self.element.style.removeProperty("height")
-            container.style.removeProperty("align-self")
-            container.style.removeProperty("justify-self")
-
-        if self.position.upper() == "FILL":
-            reset()
-            # Fill the full extent of the container.
-            self.element.style.width = "100%"
-            self.element.style.height = "100%"
-            return
-
-        # Parse into horizontal and vertical positions.
-        try:
-            vertical_position, horizontal_position = self.parse_position()
-            reset()
-        except ValueError:
-            return
-
-        # Check vertical position and adjust the container via CSS magic.
-        if vertical_position == "TOP":
-            container.style.setProperty("align-self", "start")
-        elif vertical_position == "MIDDLE":
-            container.style.setProperty("align-self", "center")
-        elif vertical_position == "BOTTOM":
-            container.style.setProperty("align-self", "end")
-        # Check the horizontal position and adjust the container.
-        if horizontal_position == "LEFT":
-            container.style.setProperty("justify-self", "start")
-        elif horizontal_position == "CENTER":
-            container.style.setProperty("justify-self", "center")
-        elif horizontal_position == "RIGHT":
-            container.style.setProperty("justify-self", "end")
-        # Ensure a vertical only position ensures a full horizontal fill.
-        if not horizontal_position:
-            container.style.setProperty("justify-self", "stretch")
-            self.element.style.width = "100%"
-        # Ensure a horizontal only position ensures a full vertical fill.
-        if not vertical_position:
-            container.style.setProperty("align-self", "stretch")
-            self.element.style.height = "100%"
-
     def update_attribute(self, attribute_name, attribute_value):
         """
         Convenience method to update an HTML attribute on self.element. If
@@ -486,7 +415,6 @@ class Widget(Component):
     * A unique human friendly name that's meaningful in the context of the
       application (if none is given, one is automatically generated).
     * A unique id (if none is given, one is automatically generated).
-    * An indication of the widget's preferred position.
     * A render function that takes the widget's container and renders
       itself as an HTML element into the container.
     * An optional indication of the channel[s] to which it broadcasts
@@ -587,11 +515,6 @@ class Container(Component):
         maximum=100,
         minimum=0,
     )
-    gap = ChoiceProperty(
-        "The gap between items in the container",
-        choices=_TSHIRT_SIZES,
-        default_value="M",
-    )
     background_color = TextProperty("The color of the container's background.")
     border_color = TextProperty("The color of the container's border.")
     border_width = ChoiceProperty(
@@ -620,7 +543,9 @@ def __init__(self, **kwargs):
 
     def on_content_changed(self):
         self.element.innerHTML = ""
-        self.render_children(self.element)
+        for child in self.content:
+            self.element.appendChild(child.element)
+        self.update_children()
 
     def on_height_changed(self):
         self.element.style.height = f"{self.height}%"
@@ -665,11 +590,7 @@ def on_border_style_changed(self):
         else:
             self.element.style.removeProperty("border-style")
 
-    def on_gap_changed(self):
-        """
-        Set the gap between elements in the container (translating from t-shirt
-        sizes).
-        """
+    def _set_gap(self, gap, attr):
         sizes = {
             "XS": "2px",
             "S": "4px",
@@ -679,9 +600,9 @@ def on_gap_changed(self):
         }
         size = "0px"
 
-        if self.gap is not None:
-            size = sizes[self.gap.upper()]
-        self.element.style.setProperty("gap", size)
+        if gap is not None:
+            size = sizes[gap.upper()]
+        self.element.style.setProperty(attr, size)
 
     def append(self, item):
         """
@@ -692,9 +613,7 @@ def append(self, item):
         self.content.append(item)
 
         # Update the DOM.
-        self.element.appendChild(
-            self.create_child_wrapper(item, len(self.content))
-        )
+        self.element.appendChild(item.element)
 
         # Update the grid indices of the container's children.
         self.update_children()
@@ -708,15 +627,12 @@ def insert(self, index, item):
         self.content.insert(index, item)
 
         # Update the DOM.
-        #
-        # We wrap all children in a <div> that is a grid area.
-        wrapper = self.create_child_wrapper(item, index)
-
         if item is self.content[-1]:
-            self.element.appendChild(wrapper)
-
+            self.element.appendChild(item.element)
         else:
-            self.element.insertBefore(wrapper, self.element.childNodes[index])
+            self.element.insertBefore(
+                item.element, self.element.childNodes[index]
+            )
 
         # Update the grid indices of the container's children.
         self.update_children()
@@ -730,7 +646,7 @@ def remove(self, item):
         self.content.remove(item)
 
         # Update the DOM.
-        item.element.parentElement.remove()
+        item.element.remove()
 
         # Update the grid indices of the container's children.
         self.update_children()
@@ -770,34 +686,22 @@ def contains(self, component):
 
     def render(self):
         """
-        Return a div element representing the container (set with the expected
-        height and width).
+        Return a div element representing the container.
 
         Subclasses should call this, then override with the specific details
         for how to add their children in a way that reflects the way they
         lay out their widgets.
         """
         element = document.createElement("div")
-        element.style.display = "grid"
         element.classList.add(f"invent-{type(self).__name__.lower()}")
-
-        # Render the container's children.
-        #
-        # See Column, Grid and Row classes for implementation details.
-        self.render_children(element)
-
         return element
 
-    def render_children(self, element):
-        """
-        Render the container's children.
-        """
-        for index, child in enumerate(self.content, start=1):
-            element.appendChild(self.create_child_wrapper(child, index))
-
     def update_children(self):
         """
         Update the container's children.
         """
         for counter, child in enumerate(self.content, start=1):
-            self.update_child_wrapper(child, counter)
+            self.update_child(child, counter)
+
+    def update_child(self, child, index):
+        pass
diff --git a/src/invent/ui/core/property.py b/src/invent/ui/core/property.py
index d1bc239..9b79c92 100644
--- a/src/invent/ui/core/property.py
+++ b/src/invent/ui/core/property.py
@@ -63,17 +63,19 @@ def __init__(
         default_value=None,
         required=False,
         map_to_attribute=None,
+        map_to_style=None,
     ):
         """
         All properties must have a description. They may have a default value,
         a flag indicating if it is a required property and an indication of the
-        default HTML attribute of the parent object's element to which to map
+        HTML or CSS attribute of the parent object's element to which to map
         its value.
         """
         self.description = description
         self.required = required
         self.default_value = self.validate(default_value)
         self.map_to_attribute = map_to_attribute
+        self.map_to_style = map_to_style
 
     def __set_name__(self, owner, name):
         """
@@ -149,9 +151,8 @@ def _react_on_change(self, obj, property_name):
         Ensure any reactive behaviour relating to the setting of the property
         is enacted. This involves two steps:
 
-        1. If the property has a map_to_attribute value, ensure the new value
-           is directly set on the parent object's element's attribute whose
-           name is the value of map_to_attribute.
+        1. If the property has a map_to_attribute or map_to_style value, ensure
+           the new value is directly set on the object's element.
         2. Call the object's on_changed handler for the specified property
            name, if it exists.
         """
@@ -161,6 +162,13 @@ def _react_on_change(self, obj, property_name):
             obj.update_attribute(
                 self.map_to_attribute, getattr(obj, self.private_name)
             )
+
+        # Map the value to a CSS property.
+        if self.map_to_style and obj.element:
+            obj.element.style.setProperty(
+                self.map_to_style, getattr(obj, self.private_name)
+            )
+
         # Handle the existence of an on_FOO_changed function.
         on_changed = getattr(obj, "on" + property_name + "_changed", None)
         if on_changed:
@@ -204,10 +212,9 @@ def __init__(
         self,
         description,
         default_value=None,
-        required=False,
-        map_to_attribute=None,
         minimum=None,
         maximum=None,
+        **kwargs
     ):
         """
         In addition to the Property related attributes, the min and max
@@ -216,9 +223,7 @@ def __init__(
         """
         self.minimum = minimum
         self.maximum = maximum
-        super().__init__(
-            description, default_value, required, map_to_attribute
-        )
+        super().__init__(description, default_value, **kwargs)
 
     def coerce(self, value):
         """
@@ -312,10 +317,9 @@ def __init__(
         self,
         description,
         default_value=None,
-        required=False,
-        map_to_attribute=None,
         min_length=None,
         max_length=None,
+        **kwargs,
     ):
         """
         In addition to the Property related attributes, the min_length and
@@ -324,9 +328,7 @@ def __init__(
         """
         self.min_length = min_length
         self.max_length = max_length
-        super().__init__(
-            description, default_value, required, map_to_attribute
-        )
+        super().__init__(description, default_value, **kwargs)
 
     def coerce(self, value):
         """
@@ -383,12 +385,9 @@ def __init__(
         self,
         description,
         default_value=None,
-        required=False,
-        map_to_attribute=None,
+        **kwargs,
     ):
-        super().__init__(
-            description, default_value or list(), required, map_to_attribute
-        )
+        super().__init__(description, default_value or list(), **kwargs)
 
     def coerce(self, value):
         if value is None:
@@ -407,18 +406,14 @@ def __init__(
         self,
         description,
         choices,
-        default_value=None,
-        required=False,
-        map_to_attribute=None,
+        **kwargs,
     ):
         """
         In addition to the Property related attributes, the choices enumerate
         a set of valid values.
         """
         self.choices = choices
-        super().__init__(
-            description, default_value, required, map_to_attribute
-        )
+        super().__init__(description, **kwargs)
 
     def validate(self, value):
         """
diff --git a/src/invent/ui/widgets/image.py b/src/invent/ui/widgets/image.py
index ca2f86d..a9434f8 100644
--- a/src/invent/ui/widgets/image.py
+++ b/src/invent/ui/widgets/image.py
@@ -36,11 +36,6 @@ class Image(Widget):
         "Sent when the image is touched.",
     )
 
-    position = TextProperty(
-        "The component's position inside it's parent.",
-        default_value="MIDDLE-CENTER",
-    )
-
     @classmethod
     def icon(cls):
         return '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><path fill="currentColor" d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></svg>'  # noqa
diff --git a/src/tools/builder/public/css/page-editor.css b/src/tools/builder/public/css/page-editor.css
index 8e677a0..061df2f 100644
--- a/src/tools/builder/public/css/page-editor.css
+++ b/src/tools/builder/public/css/page-editor.css
@@ -1,21 +1,3 @@
-.drop-zone {
-    padding: 1rem;
-    width: 100%;
-    background-color: #f3f4f6;
-    border-width: 1px;
-    border-style: dashed;
-    border-color: rgb(209 213 219);
-    border-radius: 0.5rem;
-    text-align: center;
-}
-
-.drop-zone span {
-    font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
-    font-size: 0.875rem; 
-    line-height: 1.25rem; 
-    font-weight: 500;
-}
-
 .invent-empty {
     background-color: #f3f4f6;
     padding: 1rem;
@@ -27,37 +9,13 @@
     text-align: center;
 }
 
-.drop-zone-active {
+.drop-zone-outside {
     background-color: #ede9fe;
     border-color: red;
     border-width: 4px;
     border-style: solid;
 }
 
-.drop-zone-active-above {
-    border-top-color: red;
-    border-top-width: 4px;
-    border-top-style: solid;
+.drop-zone-inside {
+    background-color: red;
 }
-
-.drop-zone-active-below {
-    border-bottom-color: red;
-    border-bottom-width: 4px;
-    border-bottom-style: solid;
-}
-
-.drop-zone-active-left-of {
-    border-left-color: red;
-    border-left-width: 4px;
-    border-left-style: solid;
-}
-
-.drop-zone-active-right-of {
-    border-right-color: red;
-    border-right-width: 4px;
-    border-right-style: solid;
-}
-
-.drop-zone-active span {
-    color: #8b5cf6;
-}
\ No newline at end of file
diff --git a/src/tools/builder/public/python/builder.py b/src/tools/builder/public/python/builder.py
index dcbfca9..6c73fad 100644
--- a/src/tools/builder/public/python/builder.py
+++ b/src/tools/builder/public/python/builder.py
@@ -1,6 +1,7 @@
 """The Python-side of the Invent Builder."""
 
 
+from collections import OrderedDict
 import json
 from pyscript import document
 from pyscript.ffi import create_proxy
@@ -33,7 +34,7 @@ def __init__(self):
 
         # The current component insertion position.
         #
-        # It will be one of "left-of", "right-of", "above", "below".
+        # It will be one of "left", "right", "top", "bottom".
         self._insertion_position = None
 
     def set_js_builder_model(self, js_builder_model):
@@ -220,7 +221,11 @@ def get_component_properties(self, component_id):
         if component is None:
             raise ValueError(f"No such component: {component_id}")
 
-        properties = type(component).blueprint()["properties"]
+        # Plain dicts aren't ordered yet in Micropython
+        # (https://github.com/micropython/micropython/issues/6170).
+        properties = OrderedDict(
+            sorted(type(component).blueprint()["properties"].items())
+        )
         for name, value in properties.items():
             if hasattr(component, f"_{name}_from_datastore"):
                 value["is_from_datastore"] = True
@@ -244,9 +249,10 @@ def set_component_property(self, component_id, property_name, value, is_from_dat
         else:
             setattr(component, property_name, value)
 
-    def get_page_element_by_id(self, page_id):
+    def show_page(self, page_id):
         result = self._app.get_page_by_id(page_id)
         if result:
+            result.show()
             return result.element
         
     # Channels ####################################################################
@@ -370,14 +376,8 @@ def on_dragend(event):
 
         component._on_drop_proxy = create_proxy(on_drop)
 
-        # Pages...
-        if component.parent is None:
-            element = component.element
-
-        # Everything else...
-        else:
-            # Attach to the *wrapper* we put around each grid item.
-            element = component.element.parentNode
+        element = component.element
+        if component.parent is not None:
             element.setAttribute("draggable", "true")
 
         element.addEventListener("click", component._on_click_proxy)
@@ -474,10 +474,10 @@ def _get_insertion_position(self, event, component):
         container = component if component.is_container else component.parent
 
         if isinstance(container, Column):
-            insertion_position = "above" if event.offsetY <= (component.element.offsetHeight * .5) else "below"
+            insertion_position = "top" if event.offsetY <= (component.element.offsetHeight * .5) else "bottom"
 
         elif isinstance(container, Row) or isinstance(container, Grid):
-            insertion_position = "left-of" if event.offsetX <= (component.element.offsetWidth * .5) else "right-of"
+            insertion_position = "left" if event.offsetX <= (component.element.offsetWidth * .5) else "right"
 
         else:
             raise ValueError("Unsupported container type:", container)
@@ -546,7 +546,7 @@ def _on_drop_component(self, event, component):
 
         # Otherwise, insert the new component before or after as appropriate.
         else:
-            if self._insertion_position in ["left-of", "above"]:
+            if self._insertion_position in ["left", "top"]:
                 insert_before = component.content[0] if component.is_container else component
                 self.insert_component_before(insert_before, component_to_drop)
             else:
@@ -564,14 +564,8 @@ def _remove_js_event_handlers_from_component(self, component):
         """
         Recursively remove JS event handlers from the specified component.
         """
-        # Pages...
-        if component.parent is None:
-            element = component.element
-
-        # Everything else...
-        else:
-            # Remove from the *wrapper* we put around every grid item.
-            element = component.element.parentNode
+        element = component.element
+        if component.parent is not None:
             element.setAttribute("draggable", "false")
 
         element.removeEventListener("click", component._on_click_proxy)
@@ -590,37 +584,77 @@ def _add_drop_zone_active_classes(self, component):
         """
         Add the appropriate drop zone active classes to a component.
         """
-        # All components EXCEPT Pages are wrapped in a <div> that is a "grid-area".
-        #
-        # So, when we add the drop zone active classes for a Page we put them on the
-        # Page's element itself, but for everything else we put them on the "grid-area"
-        # wrapper.
-        element = component.element if isinstance(component, Page) else component.element.parentNode
+        element = component.element
 
         if component.is_container and len(component.content) == 0:
-            element.classList.add(f"drop-zone-active")
+            element.classList.add("drop-zone-outside")
 
         else:
-            element.classList.add(f"drop-zone-active-{self._insertion_position}")
+            drop_zone = document.createElement("div")
+            element.parentElement.append(drop_zone)
+            drop_zone.className = "drop-zone-inside"
+            drop_zone.style.position = "absolute"
+
+            if self._insertion_position in ["top", "bottom"]:
+                self._position_drop_zone(
+                    element, drop_zone, "top", "bottom", "height", "left", "width"
+                )
+            elif self._insertion_position in ["left", "right"]:
+                self._position_drop_zone(
+                    element, drop_zone, "left", "right", "width", "top", "height"
+                )
+            else:
+                raise AssertionError(
+                    f"unknown position {self._insertion_position!r}"
+                )
+
+    def _position_drop_zone(
+        self,
+        element,
+        drop_zone,
+        main_start_attr,
+        main_end_attr,
+        main_size_attr,
+        cross_start_attr,
+        cross_size_attr,
+    ):
+        # Converts e.g. "top" to "offsetTop"
+        def offset(attr):
+            return "offset" + attr[0].upper() + attr[1:]
+
+        thickness = 4  # px
+
+        main_start = getattr(element, offset(main_start_attr))
+        main_size = getattr(element, offset(main_size_attr))
+        if self._insertion_position == main_start_attr:
+            setattr(drop_zone.style, main_start_attr, f"{main_start}px")
+        elif self._insertion_position == main_end_attr:
+            setattr(
+                drop_zone.style,
+                main_start_attr,
+                f"{main_start + main_size - thickness}px",
+            )
+        else:
+            raise AssertionError(
+                f"unknown position {self._insertion_position!r}"
+            )
+        setattr(drop_zone.style, main_size_attr, f"{thickness}px")
+
+        cross_start = getattr(element, offset(cross_start_attr))
+        cross_size = getattr(element, offset(cross_size_attr))
+        setattr(drop_zone.style, cross_start_attr, f"{cross_start}px")
+        setattr(drop_zone.style, cross_size_attr, f"{cross_size}px")
 
     def _remove_drop_zone_active_classes(self, component):
         """
         Remove any drop zone active classes from a component.
         """
-        # All components EXCEPT Pages are wrapped in a <div> that is a "grid-area".
-        #
-        # So, when we add the drop zone active classes for a Page we put them on the
-        # Page's element itself, but for everything else we put them on the "grid-area"
-        # wrapper.
-        element = component.element if isinstance(component, Page) else component.element.parentNode
-
-        element.classList.remove(
-            "drop-zone-active",
-            "drop-zone-active-left-of",
-            "drop-zone-active-right-of",
-            "drop-zone-active-above",
-            "drop-zone-active-below"
-        )
+        element = component.element
+
+        element.classList.remove("drop-zone-outside")
+        drop_zone = element.parentElement.querySelector(".drop-zone-inside")
+        if drop_zone:
+            drop_zone.remove()
 
     def find_nearest_component(self, event):
         import math
diff --git a/src/tools/builder/public/python/invent.zip b/src/tools/builder/public/python/invent.zip
deleted file mode 100644
index 9b7b3e8..0000000
Binary files a/src/tools/builder/public/python/invent.zip and /dev/null differ
diff --git a/src/tools/builder/src/utilities/builder-utilities.ts b/src/tools/builder/src/utilities/builder-utilities.ts
index d5719c1..5e30d28 100644
--- a/src/tools/builder/src/utilities/builder-utilities.ts
+++ b/src/tools/builder/src/utilities/builder-utilities.ts
@@ -23,8 +23,8 @@ export class BuilderUtilities {
 		return JSON.parse(this.builder().get_pages());
 	}
 
-	public static getPageElementById(pageId: WidgetPropertyModel): HTMLElement {
-		return this.builder().get_page_element_by_id(pageId);
+	public static showPage(pageId: WidgetPropertyModel): HTMLElement {
+		return this.builder().show_page(pageId);
 	}
 
 	public static addPage(name: string): PageModel {
diff --git a/src/tools/builder/src/views/builder/components/page-editor/page-editor-model.ts b/src/tools/builder/src/views/builder/components/page-editor/page-editor-model.ts
index c285150..fb467eb 100644
--- a/src/tools/builder/src/views/builder/components/page-editor/page-editor-model.ts
+++ b/src/tools/builder/src/views/builder/components/page-editor/page-editor-model.ts
@@ -27,8 +27,7 @@ class PageEditorModel extends ComponentModelBase {
 
 	public onPageLoad(pages: Array<PageModel>, page: PageModel, addWidgetToPage: Function): void {
 		const pageEditor: HTMLIFrameElement = document.getElementById(`${page.properties.id}-editor`) as HTMLIFrameElement;
-		const pageElement: HTMLElement = BuilderUtilities.getPageElementById(page.properties.id);
-		pageElement.style.display = "grid";
+		const pageElement: HTMLElement = BuilderUtilities.showPage(page.properties.id);
 		pageEditor.contentDocument?.body.insertBefore(pageElement, pageEditor.contentDocument?.body.firstChild);
 	}
 }
diff --git a/static/invent.zip b/static/invent.zip
deleted file mode 100644
index 9b7b3e8..0000000
Binary files a/static/invent.zip and /dev/null differ
diff --git a/utils/serve.py b/utils/serve.py
index 68cdb55..351b862 100644
--- a/utils/serve.py
+++ b/utils/serve.py
@@ -8,6 +8,7 @@
 
 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements
 """
+import argparse
 from http import server
 
 
@@ -21,4 +22,9 @@ def end_headers(self):
 
 
 if __name__ == "__main__":
-    server.test(HandlerClass=MyHTTPRequestHandler)
+    parser = argparse.ArgumentParser()
+    parser.add_argument("port", type=int, default=8000)
+    args = parser.parse_args()
+    server.test(
+        HandlerClass=MyHTTPRequestHandler, bind="localhost", port=args.port
+    )


Test patch:
diff --git a/static/test_suite.zip b/static/test_suite.zip
deleted file mode 100644
index 8888e07..0000000
Binary files a/static/test_suite.zip and /dev/null differ
diff --git a/tests/ui/core/test_component.py b/tests/ui/core/test_component.py
index b3abf8c..7df2cce 100644
--- a/tests/ui/core/test_component.py
+++ b/tests/ui/core/test_component.py
@@ -70,10 +70,10 @@ class TestComponent(core.Component):
         def render(self):
             return document.createElement("div")
 
-    tc = TestComponent(name="test1", id="12345", position="TOP-LEFT")
+    tc = TestComponent(name="test1", id="12345", visible=False)
     assert tc.name == "test1"
     assert tc.id == "12345"
-    assert tc.position == "TOP-LEFT"
+    assert tc.visible is False
     assert tc.parent is None
 
 
@@ -229,8 +229,6 @@ def icon(cls):
     assert result["properties"]["id"]["default_value"] is None
     assert result["properties"]["channel"]["property_type"] == "TextProperty"
     assert result["properties"]["channel"]["default_value"] is None
-    assert result["properties"]["position"]["property_type"] == "TextProperty"
-    assert result["properties"]["position"]["default_value"] == "FILL"
     assert result["properties"]["foo"]["property_type"] == "TextProperty"
     assert result["properties"]["foo"]["default_value"] == "bar"
     assert (
@@ -299,6 +297,7 @@ def render(self):
             return document.createElement("div")
 
     w = MyWidget()
+    # There is no attribute called "test" on the widget's element.
     assert w.element.hasAttribute("test") is False
     # Update an attribute (add it).
     w.update_attribute("test", "yes")
@@ -388,7 +387,7 @@ def my_second_handler(message):
 
 def test_widget_init_defaults():
     """
-    Ensure an instance of a Widget class has a default id, position and
+    Ensure an instance of a Widget class has default values for id, visible and
     channel.
     """
 
@@ -402,15 +401,15 @@ def render(self):
     assert w.id is not None
     assert w.id.startswith("invent-")
     assert len(w.id[7:]) == 10
-    # The default position is FILL.
-    assert w.position == "FILL"
+    # The default visibility is True.
+    assert w.visible is True
     # The default channel for widget related messages is None.
     assert w.channel is None
 
 
 def test_widget_init_override():
     """
-    It's possible to override the default values for id, position and channel.
+    It's possible to override the default values for id, visible and channel.
     """
 
     class MyWidget(core.Widget):
@@ -419,10 +418,10 @@ def render(self):
             return document.createElement("div")
 
     w = MyWidget(
-        name="test", id="foo", position="FILL", channel="test_channel"
+        name="test", id="foo", visible=False, channel="test_channel"
     )
     assert w.id == "foo"
-    assert w.position == "FILL"
+    assert w.visible is False
     assert w.channel == "test_channel"
 
 
@@ -448,113 +447,6 @@ def render(self):
         assert mock_publish.call_count == 1
 
 
-def test_widget_parse_position():
-    """
-    Any valid definition of a widget's position should result in the correct
-    horizontal and vertical values.
-    """
-
-    class MyWidget(core.Widget):
-
-        def render(self):
-            return document.createElement("div")
-
-    w = MyWidget(name="test widget")
-    w.element = document.createElement("div")
-    container = document.createElement("div")
-    container.appendChild(w.element)
-    for h in core._VALID_HORIZONTALS:
-        w.position = h
-        assert w.parse_position() == (None, h)
-        for v in core._VALID_VERTICALS:
-            w.position = v
-            assert w.parse_position() == (v, None)
-            w.position = f"{v}-{h}"
-            assert w.parse_position() == (v, h)
-    with pytest.raises(ValueError):
-        # Invalid position values result in a ValueError.
-        w.position = "NOT-VALID"
-        w.parse_position()
-
-
-def test_widget_set_position_fill():
-    """
-    Ensure the widget's element has the CSS width and height set to the
-    expected value of 100%.
-    """
-
-    class MyWidget(core.Widget):
-
-        def render(self):
-            return document.createElement("div")
-
-    w = MyWidget(position="FILL")
-    w.element = document.createElement("div")
-    container = document.createElement("div")
-    container.appendChild(w.element)
-    w.set_position(container)
-    assert w.element.style.width == "100%"
-    assert w.element.style.height == "100%"
-
-
-def test_widget_set_position():
-    """
-    The widget's container has the expected alignment/justify value set for
-    each combination of the valid horizontal and vertical positions.
-    """
-
-    class MyWidget(core.Widget):
-
-        def render(self):
-            return document.createElement("div")
-
-    expected_vertical = {
-        "TOP": "start",
-        "MIDDLE": "center",
-        "BOTTOM": "end",
-        "": "stretch",
-    }
-    expected_horizontal = {
-        "LEFT": "start",
-        "CENTER": "center",
-        "RIGHT": "end",
-        "": "stretch",
-    }
-    for h_key, h_val in expected_horizontal.items():
-        for v_key, v_val in expected_vertical.items():
-            w = MyWidget()
-            w.element = document.createElement("div")
-            container = document.createElement("div")
-            container.appendChild(w.element)
-            if v_key and h_key:
-                w.position = f"{v_key}-{h_key}"
-            else:
-                w.position = f"{v_key}{h_key}"
-            # Ignore NoneNone
-            if w.position:
-                w.set_position(container)
-                if v_key:
-                    assert (
-                        container.style.getPropertyValue("align-self") == v_val
-                    )
-                else:
-                    assert (
-                        container.style.getPropertyValue("align-self") == v_val
-                    )
-                    assert w.element.style.height == "100%"
-                if h_key:
-                    assert (
-                        container.style.getPropertyValue("justify-self")
-                        == h_val
-                    )
-                else:
-                    assert (
-                        container.style.getPropertyValue("justify-self")
-                        == h_val
-                    )
-                    assert w.element.style.width == "100%"
-
-
 def test_container_on_content_changed():
     """
     The children are re-rendered when the content list is changed.
diff --git a/tests/ui/core/test_property.py b/tests/ui/core/test_property.py
index ed0395c..06c9108 100644
--- a/tests/ui/core/test_property.py
+++ b/tests/ui/core/test_property.py
@@ -112,6 +112,21 @@ def on_my_property_changed(self):
     fw.on_my_property_changed.assert_called_once_with()
 
 
+def test_property_map_to_style():
+    """
+    If the property is given a map_to_style, any value is set as a CSS style.
+    """
+    class FakeWidget:
+        my_property = Property("A test", map_to_style="hyphenated-name")
+
+    fw = FakeWidget()
+    fw.element = mock.Mock()
+    fw.my_property = "the value"
+    fw.element.style.setProperty.assert_called_once_with(
+        "hyphenated-name", "the value"
+    )
+
+
 def test_property_as_dict():
     """
     The expected JSON serializable Python dictionary defining the property's