diff --git a/python_payload/main_st4m.py b/python_payload/main_st4m.py
new file mode 100644
index 0000000000000000000000000000000000000000..44a1c642cabd378d92a32f2fe5c4337e7f627583
--- /dev/null
+++ b/python_payload/main_st4m.py
@@ -0,0 +1,171 @@
+"""
+Experimental/Research UI/UX framework (st4m).
+
+To run, rename this file to main.py.
+
+See st4m/README.md for more information.
+"""
+
+
+import st4m
+
+from st4m.goose import Optional, List, ABCBase, abstractmethod
+from st4m.ui.view import View, ViewManager, ViewTransitionBlend
+from st4m.ui.menu import MenuItem, MenuController, MenuItemBack, MenuItemForeground, MenuItemNoop
+from st4m import Responder, InputState, Ctx
+
+import math
+
+
+vm = ViewManager(ViewTransitionBlend())
+
+
+def lerp(a: float, b: float, v: float) -> float:
+    if v <= 0:
+        return a
+    if v >= 1.0:
+        return b
+    return a + (b - a) * v
+
+
+class Sun(Responder):
+    """
+    A rotating sun widget.
+    """
+    def __init__(self) -> None:
+        self.x = 0.0
+        self.y = 0.0
+        self.size = 50.0
+        self.ts = 1.0
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        self.ts += delta_ms
+        pass
+
+    def draw(self, ctx: Ctx) -> None:
+
+        nrays = 10
+        angle_per_ray = 6.28 / nrays
+        for i in range(nrays):
+            angle = i * angle_per_ray + self.ts / 4000
+            angle %= 3.14159*2
+
+            if angle > 2 and angle < 4:
+                continue
+
+            ctx.save()
+            ctx.rgb(0.5, 0.5, 0)
+            ctx.line_width = 30
+            ctx.translate(-120, 0).rotate(angle)
+            ctx.move_to(20, 0)
+            ctx.line_to(260, 0)
+            ctx.stroke()
+            ctx.restore()
+
+        ctx.save()
+        ctx.rgb(0.92, 0.89, 0)
+        ctx.translate(-120, 0)
+
+        ctx.arc(self.x, self.y, self.size, 0, 6.29, 0)
+        ctx.fill()
+        ctx.restore()
+
+
+class MainMenu(MenuController):
+    """
+    A circular menu with a rotating sun.
+    """
+
+    __slots__ = (
+        '_ts',
+        '_sun',
+    )
+
+    def __init__(self, items: List[MenuItem], vm: ViewManager) -> None:
+        self._ts = 0
+        self._sun = Sun()
+        super().__init__(items, vm)
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        super().think(ins, delta_ms)
+        self._sun.think(ins, delta_ms)
+        self._ts += delta_ms
+
+    def _draw_text_angled(self, ctx: Ctx, text: str, angle: float, activity: float) -> None:
+        size = lerp(20, 40, activity)
+        color = lerp(0, 1, activity)
+        if color < 0.01:
+            return
+
+        ctx.save()
+        ctx.translate(-120, 0).rotate(angle).translate(140, 0)
+        ctx.font_size = size
+        ctx.rgba(1.0, 1.0, 1.0, color).move_to(0, 0).text(text)
+        ctx.restore()
+
+    def draw(self, ctx: Ctx) -> None:
+        ctx.gray(0)
+        ctx.rectangle(-120, -120, 240, 240).fill()
+        
+        self._sun.draw(ctx)
+
+        ctx.font_size = 40
+        ctx.text_align = ctx.CENTER
+        ctx.text_baseline = ctx.MIDDLE
+
+        angle_per_item = 0.4
+
+        current = self._scroll_controller.current_position()
+
+        for ix, item in enumerate(self._items):
+            rot = (ix - current) * angle_per_item
+            self._draw_text_angled(ctx, item.label(), rot, 1-abs(rot))
+    
+
+class SimpleMenu(MenuController):
+    """
+    A simple line-by-line menu.
+    """
+    def draw(self, ctx: Ctx) -> None:
+        ctx.gray(0)
+        ctx.rectangle(-120, -120, 240, 240).fill()
+
+        ctx.text_align = ctx.CENTER
+        ctx.text_baseline = ctx.MIDDLE
+
+        current = self._scroll_controller.current_position()
+
+        ctx.gray(1)
+        for ix, item in enumerate(self._items):
+            offs = (ix - current) * 30
+            size = lerp(30, 20, abs(offs / 20))
+            ctx.font_size = size
+            ctx.move_to(0, offs).text(item.label())
+
+
+menu_music = SimpleMenu([
+    MenuItemBack(),
+    MenuItemNoop("Harmonic"),
+    MenuItemNoop("Melodic"),
+    MenuItemNoop("TinySynth"),
+    MenuItemNoop("CrazySynth"),
+    MenuItemNoop("Sequencer"),
+], vm)
+
+menu_apps = SimpleMenu([
+    MenuItemBack(),
+    MenuItemNoop("captouch"),
+    MenuItemNoop("worms"),
+], vm)
+
+menu_main = MainMenu([
+    MenuItemForeground("Music", menu_music),
+    MenuItemForeground("Apps", menu_apps),
+    MenuItemNoop("Settings"),
+], vm)
+
+vm.push(menu_main)
+
+reactor = st4m.Reactor()
+reactor.set_top(vm)
+reactor.run()
diff --git a/python_payload/mypystubs/hardware.pyi b/python_payload/mypystubs/hardware.pyi
new file mode 100644
index 0000000000000000000000000000000000000000..e5fc2f9a52e66bca5d3771ebd13bc7b1f7693745
--- /dev/null
+++ b/python_payload/mypystubs/hardware.pyi
@@ -0,0 +1,18 @@
+import time
+
+from st4m.ui.ctx import Ctx
+
+
+def freertos_sleep(ms: int) -> None: ...
+
+def get_ctx() -> Ctx: ...
+
+def display_pipe_full() -> bool: ...
+
+def display_update(c: Ctx) -> None: ...
+
+def get_captouch(ix: int) -> bool: ...
+
+def left_button_get() -> int: ...
+
+def right_button_get() -> int: ...
\ No newline at end of file
diff --git a/python_payload/mypystubs/time.pyi b/python_payload/mypystubs/time.pyi
new file mode 100644
index 0000000000000000000000000000000000000000..2a276106b5b96623eaca8a784f2df1184c13e462
--- /dev/null
+++ b/python_payload/mypystubs/time.pyi
@@ -0,0 +1 @@
+def ticks_ms() -> int: pass
\ No newline at end of file
diff --git a/python_payload/st4m/README.md b/python_payload/st4m/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..f1aafcbdb4226fbeddf9005a9c3bc56a97b012e7
--- /dev/null
+++ b/python_payload/st4m/README.md
@@ -0,0 +1,24 @@
+st4m
+====
+
+Experimental, heavily-typed flow3r UI/UX framework.
+
+Proof of concept of:
+ 1. Strongly typed Python (using MyPy) which is also used as docs.
+ 2. A deep class hierarchy that is still performant enough.
+ 3. Pretty UX.
+
+Does not implement application lifecycle, but that could be built upon
+View/ViewWithInput.
+
+Should be trickle-merged into st3m.
+
+Typechecking
+------------
+
+    MYPYPATH=$(pwd)/python_payload/mypystubs mypy python_payload/main_st4m.py --strict
+
+Running
+-------
+
+Move `main_st4m.py` to `main.py` then either run on badge or in simulator.
diff --git a/python_payload/st4m/__init__.py b/python_payload/st4m/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..033876cbeb5b3f2b0756b1d02df4342ebf8ba835
--- /dev/null
+++ b/python_payload/st4m/__init__.py
@@ -0,0 +1,11 @@
+from st4m.reactor import Reactor, Responder
+from st4m.ui.ctx import Ctx
+from st4m.input import InputState, InputController
+
+__all__ = [
+    'Reactor', 'Responder',
+
+    'InputState', 'InputController',
+
+    'Ctx',
+]
\ No newline at end of file
diff --git a/python_payload/st4m/goose.py b/python_payload/st4m/goose.py
new file mode 100644
index 0000000000000000000000000000000000000000..77b9ecaa797300e2dac5a5c7268095c5536694af
--- /dev/null
+++ b/python_payload/st4m/goose.py
@@ -0,0 +1,49 @@
+"""
+Goose implements Goose Typing (tm), a set of shims which allows the st4m
+codebase to use both Python type annotations, Abstract Base Classes and run
+under Micropython.
+"""
+
+# Detect whether we're in MyPy or just executing.
+try:
+    from typing import TYPE_CHECKING
+except:
+    TYPE_CHECKING = False
+
+if TYPE_CHECKING:
+    # We're in MyPy.
+    from abc import ABCMeta, abstractmethod
+    class ABCBase(metaclass=ABCMeta):
+        pass
+
+    from typing import List, Optional
+    from enum import Enum
+else:
+    # We're in CPython or Micropython.
+    class ABCBase:
+        pass
+
+    def abstractmethod(f):
+        def _fail():
+            raise Exception("abstract method call")
+        return _fail
+
+    try:
+        from typing import List, Optional
+        from enum import Enum
+    except ImportError:
+        # We're in Micropython.
+        List = None
+        Optional = None
+
+        class Enum:
+            pass
+
+__all__ = [
+    'TYPE_CHECKING',
+    'ABCBase', 'abstractmethod',
+
+    'List', 'Optional',
+
+    'Enum',
+]
\ No newline at end of file
diff --git a/python_payload/st4m/input.py b/python_payload/st4m/input.py
new file mode 100644
index 0000000000000000000000000000000000000000..576bb906cbd084f911d5e46c1361169c05b51262
--- /dev/null
+++ b/python_payload/st4m/input.py
@@ -0,0 +1,271 @@
+from st4m.goose import List, Optional, Enum
+from st4m.ui.ctx import Ctx
+
+import hardware
+
+
+class InputState:
+    """
+    Current state of inputs from badge user. Passed via think() to every
+    Responder.
+    
+    If you want to detect edges, use the stateful InputController.
+    """
+    def __init__(self, petal_pressed: List[bool], left_button: int, right_button: int) -> None:
+        self.petal_pressed = petal_pressed
+        self.left_button = left_button
+        self.right_button = right_button
+    
+    @classmethod
+    def gather(cls) -> 'InputState':
+        """
+        Build InputState from current hardware state. Should only be used by the
+        Reactor.
+        """
+        petal_pressed = [
+            hardware.get_captouch(i)
+            for i in range(10)
+        ]
+        left_button = hardware.left_button_get()
+        right_button = hardware.right_button_get()
+
+        return InputState(petal_pressed, left_button, right_button)
+
+
+class RepeatSettings:
+    def __init__(self, first: float, subsequent: float) -> None:
+        self.first = first
+        self.subsequent = subsequent
+
+
+class Pressable:
+    """
+    A pressable button or button-acting object (like captouch petal in button
+    mode).
+
+    Carries information about current and previous state of button, allowing to
+    detect edges (pressed/released) and state (down/up). Additionally implements
+    button repeating.
+    """
+
+    PRESSED = 'pressed'
+    REPEATED = 'repeated'
+    RELEASED = 'released'
+    DOWN = 'down'
+    UP = 'up'
+
+    def __init__(self, state: bool) -> None:
+        self._state = state
+        self._prev_state = state
+        self._repeat = RepeatSettings(400, 200)
+
+        self._pressed_at: Optional[float] = None
+        self._repeating = False
+        self._repeated = False
+
+        self._ignoring = 0
+
+    def _update(self, ts: int, state: bool) -> None:
+        if self._ignoring > 0:
+            self._ignoring -= 1
+
+        self._prev_state = self._state
+        self._state = state
+        self._repeated = False
+
+        if state == False:
+            self._pressed_at = None
+            self._repeating = False
+        else:
+            if self._pressed_at is None:
+                self._pressed_at = ts
+
+        repeat = self._repeat
+        if state and repeat is not None and self._pressed_at is not None:
+            if not self._repeating:
+                if ts > self._pressed_at + repeat.first:
+                    self._repeating = True
+                    self._repeated = True
+                    self._prev_state = False
+                    self._pressed_at = ts
+            else:
+                if ts > self._pressed_at + repeat.subsequent:
+                    self._prev_state = False
+                    self._pressed_at = ts
+                    self._repeated = True
+        
+
+    @property
+    def state(self) -> str:
+        """
+        Returns one of Pressable.{UP,DOWN,PRESSED,RELEASED,REPEATED}.
+        """
+        prev = self._prev_state
+        cur = self._state
+
+        if self._ignoring > 0:
+            return self.UP
+
+        if self._repeated:
+            return self.REPEATED
+
+        if cur and not prev:
+            return self.PRESSED
+        if not cur and prev:
+            return self.RELEASED
+        if cur and prev:
+            return self.DOWN
+        return self.UP
+
+    @property
+    def pressed(self) -> bool:
+        """
+        True if the button has just been pressed.
+        """
+        return self.state == self.PRESSED
+
+    @property
+    def repeated(self) -> bool:
+        """
+        True if the button has been held long enough that a virtual 'repeat'
+        press should be acted upon.
+        """
+        return self.state == self.REPEATED
+
+    @property
+    def released(self) -> bool:
+        """
+        True if the button has just been released.
+        """
+        return self.state == self.RELEASED
+
+    @property
+    def down(self) -> bool:
+        """
+        True if the button is held down, after first being pressed.
+        """
+        return self.state == self.DOWN
+
+    @property
+    def up(self) -> bool:
+        """
+        True if the button is currently not being held down.
+        """
+        return self.state == self.UP
+
+    def _ignore_pressed(self) -> None:
+        """
+        Pretend the button isn't being pressed for the next two update
+        iterations. Used to prevent spurious presses to be routed to apps that
+        have just been foregrounded.
+        """
+        self._ignoring = 2
+        self._repeating = False
+        self._repeated = False
+    
+    def __repr__(self) -> str:
+        return '<Pressable: ' + self.state + '>'
+    
+
+class PetalState(Pressable):
+    def __init__(self, ix: int) -> None:
+        self.ix = ix
+        super().__init__(False)
+
+
+class CaptouchState:
+    """
+    State of capacitive touch petals.
+
+    The petals are indexed from 0 to 9 (inclusive). Petal 0 is above the USB-C
+    socket, then the numbering continues counter-clockwise.
+    """
+    __slots__ = ('petals')
+
+    def __init__(self) -> None:
+        self.petals = [
+            PetalState(i)
+            for i in range(10)
+        ]
+    
+    def _update(self, ts: int, hr: InputState) -> None:
+        for i, petal in enumerate(self.petals):
+            petal._update(ts, hr.petal_pressed[i])
+    
+    def _ignore_pressed(self) -> None:
+        for petal in self.petals:
+            petal._ignore_pressed()
+
+
+class TriSwitchHandedness(Enum):
+    """
+    Left or right shoulder button.
+    """
+    left = 'left'
+    right = 'right'
+
+
+class TriSwitchState:
+    """
+    State of a tri-stat shoulder button
+    """
+    __slots__ = ('left', 'middle', 'right', 'handedness')
+
+    def __init__(self, h: TriSwitchHandedness) -> None:
+        self.handedness = h
+
+        self.left = Pressable(False)
+        self.middle = Pressable(False)
+        self.right = Pressable(False)
+
+    def _update(self, ts: int, hr: InputState) -> None:
+        st = hr.left_button if self.handedness == TriSwitchHandedness.left else hr.right_button
+        self.left._update(ts, st == -1)
+        self.middle._update(ts, st == 2)
+        self.right._update(ts, st == 1)
+
+    def _ignore_pressed(self) -> None:
+        self.left._ignore_pressed()
+        self.middle._ignore_pressed()
+        self.right._ignore_pressed()
+    
+
+class InputController:
+    """
+    A stateful input controller. It accepts InputState updates from the Reactor
+    and allows a Responder to detect input events, like a button having just
+    been pressed.
+
+    To use, instantiate within a Responder and call think() from your
+    responder's think().
+
+    Then, access the captouch/left_shoulder/right_shoulder fields.
+    """
+    __slots__ = (
+        'captouch',
+        'left_shoulder',
+        'right_shoulder',
+        '_ts',
+    )
+    def __init__(self) -> None:
+        self.captouch = CaptouchState()
+        self.left_shoulder = TriSwitchState(TriSwitchHandedness.left)
+        self.right_shoulder = TriSwitchState(TriSwitchHandedness.right)
+        self._ts = 0
+
+    def think(self, hr: InputState, delta_ms: int) -> None:
+        self._ts += delta_ms
+        self.captouch._update(self._ts, hr)
+        self.left_shoulder._update(self._ts, hr)
+        self.right_shoulder._update(self._ts, hr)
+
+    def _ignore_pressed(self) -> None:
+        """
+        Pretend input buttons aren't being pressed for the next two update
+        iterations. Used to prevent spurious presses to be routed to apps that
+        have just been foregrounded.
+        """
+        self.captouch._ignore_pressed()
+        self.left_shoulder._ignore_pressed()
+        self.right_shoulder._ignore_pressed()
+
diff --git a/python_payload/st4m/reactor.py b/python_payload/st4m/reactor.py
new file mode 100644
index 0000000000000000000000000000000000000000..558186f254396ff104bbecca74d9fb5f869f6206
--- /dev/null
+++ b/python_payload/st4m/reactor.py
@@ -0,0 +1,122 @@
+from st4m.goose import ABCBase, abstractmethod, List, Optional
+from st4m.input import InputState
+from st4m.ui.ctx import Ctx
+
+import time, hardware
+
+class Responder(ABCBase):
+    """
+    Responder is an interface from the Reactor to any running Micropython code
+    that wishes to access input state or draw to the screen.
+
+    A Responder can be a system menu, an application, a graphical widget, etc.
+
+    The Reactor will call think and draw methods at a somewhat-constant pace in
+    order to maintain a smooth system-wide update rate and framerate.
+    """
+    @abstractmethod
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        """
+        think() will be called when the Responder should process the InputState
+        and perform internal logic. delta_ms will be set to the number of
+        milliseconds elapsed since the last reactor think loop.
+
+        The code must not sleep or block during this callback, as that will
+        impact the system tickrate and framerate.
+        """
+        pass
+
+    @abstractmethod
+    def draw(self, ctx: Ctx) -> None:
+        """
+        draw() will be called when the Responder should draw, ie. generate a drawlist by performing calls on the given ctx object.
+
+        Depending on what calls the Responder, the ctx might either represent
+        the surface of the entire screen, or some composited-subview (eg. an
+        application screen that is currently being transitioned out by sliding
+        left). Unless specified otherwise by the compositing stack, the screen
+        coordinates are +/- 120 in both X and Y (positive numbers towards up and
+        right), with 0,0 being the middle of the screen.
+        
+        The Reactor will then rasterize and blit the result.
+
+        The code must not sleep or block during this callback, as that will
+        impact the system tickrate and framerate.
+        """
+        pass
+
+
+class Reactor:
+    """
+    The Reactor is the main Micropython scheduler of the st4m system and any
+    running payloads.
+
+    It will attempt to run a top Responder with a fixed tickrate a framerate
+    that saturates the display rasterization/blitting pipeline.
+    """
+    __slots__ = ('_top', '_tickrate_ms', '_last_tick', '_ctx', '_ts')
+    def __init__(self) -> None:
+        self._top: Optional[Responder] = None
+        self._tickrate_ms: int = 20
+        self._ts: int = 0
+        self._last_tick: Optional[int] = None
+        self._ctx: Optional[Ctx] = None
+
+    def set_top(self, top: Responder) -> None:
+        """
+        Set top Responder. It will be called by the reactor in a loop once run()
+        is called.
+
+        This can be also called after the reactor is started.
+        """
+        self._top = top
+
+    def run(self) -> None:
+        """
+        Run the reactor forever, processing the top Responder in a loop.
+        """
+        while True:
+            self._run_once()
+
+    def _run_once(self) -> None:
+        start = time.ticks_ms()
+        deadline = start + self._tickrate_ms
+
+        self._run_top(start)
+
+        end = time.ticks_ms()
+        wait = deadline - end
+        if wait > 0:
+            hardware.freertos_sleep(wait)
+        else:
+            print('too long', wait)
+
+    def _run_top(self, start: int) -> None:
+        # Skip if we have no top Responder.
+        if self._top is None:
+            return
+
+		# Calculate delta (default to tickrate if running first iteration).
+        delta = self._tickrate_ms
+        if self._last_tick is not None:
+            delta = start - self._last_tick
+        self._last_tick = start
+
+        self._ts += delta
+
+        hr = InputState.gather()
+
+		# Think!
+        self._top.think(hr, delta)
+
+        # Draw!
+        if self._ctx is None:
+            self._ctx = hardware.get_ctx()
+            if self._ctx is not None:
+                self._ctx.save()
+                self._top.draw(self._ctx)
+                self._ctx.restore()
+        if self._ctx is not None and not hardware.display_pipe_full():
+            hardware.display_update(self._ctx)
+            self._ctx = None
+
diff --git a/python_payload/st4m/ui/__init__.py b/python_payload/st4m/ui/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/python_payload/st4m/ui/ctx.py b/python_payload/st4m/ui/ctx.py
new file mode 100644
index 0000000000000000000000000000000000000000..7a442cfaec90ed3b51cfc22072e4d15547cf2f8d
--- /dev/null
+++ b/python_payload/st4m/ui/ctx.py
@@ -0,0 +1,315 @@
+from st4m.goose import ABCBase, abstractmethod, List
+
+class Ctx(ABCBase):
+    """
+    Ctx is the rendering/rasterization API used by st4m.
+
+    It's a WebCanvas-style vector API, with an implicit pen which can be moved
+    and can draw lines, arcs, text, etc.
+
+    In st4m, the Ctx object is backed by a drawlist generator. That is, any
+    operation performed on the ctx object will cause an entry to be added to an
+    in-memory draw list. Then, when the rasterizer is ready, it will rasterize
+    said drawlist to pixels in a separate thread.
+
+	A Ctx object is passed to all draw() calls to Responder. This object should
+	only be used within the lifecycle of the draw method and must not be
+	persisted.
+
+    For more information, see: https://ctx.graphics/
+    """
+    __slots__ = ('font_size', 'text_align', 'text_baseline', 'line_width', 'global_alpha')
+
+    CENTER = 'center'
+    MIDDLE = 'middle'
+
+    @abstractmethod
+    def __init__(self) -> None:
+        self.font_size: float = 10
+        self.text_align: str = 'start'
+        self.text_baseline: str = 'alphabetic'
+        self.line_width: float = 1.0
+        self.global_alpha: float = 1.0
+
+    @abstractmethod
+    def begin_path(self) -> 'Ctx':
+        """
+        Clears the current path if any.
+        """
+        pass
+
+    @abstractmethod
+    def save(self) -> 'Ctx':
+        """
+        Stores the transform, clipping state, fill and stroke sources, font
+        size, stroking and dashing options.
+        """
+        pass
+
+    @abstractmethod
+    def restore(self) -> 'Ctx':
+        """
+        Restores the state previously saved with save, calls to save/restore
+        should be balanced.
+        """
+        pass
+
+    @abstractmethod
+    def start_frame(self) -> 'Ctx':
+        """
+        Prepare for rendering a new frame, clears internal drawlist and
+        initializes the state.
+
+        TODO(q3k): we probably shouldn't expose this
+        """
+        pass
+
+    @abstractmethod
+    def end_frame(self) -> 'Ctx':
+        """
+        We're done rendering a frame, this does nothing on a context created for
+        a framebuffer, where drawing commands are immediate.
+
+        TODO(q3k): we probably shouldn't expose this
+        """
+        pass
+
+    @abstractmethod
+    def start_group(self) -> 'Ctx':
+        """
+        Start a compositing group.
+        """
+        pass
+
+    @abstractmethod
+    def end_group(self) -> 'Ctx':
+        """
+        End a compositing group, the global alpha, compositing mode and blend
+        mode set before this call is used to apply the group.
+        """
+        pass
+
+    @abstractmethod
+    def clip(self) -> 'Ctx':
+        """
+        Use the current path as a clipping mask, subsequent draw calls are
+        limited by the path. The only way to increase the visible area is to
+        first call save and then later restore to undo the clip.
+        """
+        pass
+
+    @abstractmethod
+    def rotate(self, x: float) -> 'Ctx':
+        """
+        Add rotation to the user to device space transform.
+        """
+        pass
+
+    @abstractmethod
+    def scale(self, x: float, y: float) -> 'Ctx':
+        """
+        Scales the user to device transform.
+        """
+        pass
+
+    @abstractmethod
+    def translate(self, x: float, y: float) -> 'Ctx':
+        """
+        Adds translation to the user to device transform.
+        """
+        pass
+
+    @abstractmethod
+    def apply_transform(self, a: float, b: float, c: float, d: float, e: float, f: float, g: float, h: float, i: float) -> 'Ctx':
+        """
+        Adds a 3x3 matrix on top of the existing user to device space transform.
+
+        TODO(q3k): we probably shouldn't expose this
+        """
+        pass
+
+    @abstractmethod
+    def line_to(self, x: float, y: float) -> 'Ctx':
+        """
+        Draws a line segment from the position of the last
+        {line,move,curve,quad}_to) to the given coordinates.
+        """
+        pass
+
+    @abstractmethod
+    def move_to(self, x: float, y: float) -> 'Ctx':
+        """
+        Moves the virtual pen to the given coordinates without drawing anything.
+        """
+        pass
+
+    @abstractmethod
+    def curve_to(self, cx0: float, cy0: float, cx1: float, cy1: float, x: float, y: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def quad_to(self, cx: float, cy: float, x: float, y: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def gray(self, a: float) -> 'Ctx':
+        """
+        Set current draw color to a floating point grayscale value from 0 to 1.
+        """
+        pass
+
+    @abstractmethod
+    def rgb(self, r: float, g: float, b: float) -> 'Ctx':
+        """
+        Set current draw color to an RGB color defined by component values from
+        0 to 1.
+        """
+        pass
+
+    @abstractmethod
+    def rgba(self, r: float, g: float, b: float, a: float) -> 'Ctx': 
+        """
+        Set current draw color to an RGBA color defined by component values from
+        0 to 1.
+        """
+        pass
+
+    @abstractmethod
+    def arc_to(self, x1: float, y1: float, x2: float, y2: float, radius: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def rel_line_to(self, x: float, y: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def rel_move_to(self, x: float, y: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def rel_curve_to(self, cx0: float, cy0: float, cx1: float, cy1: float, x: float, y: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def rel_quad_to(self, cx: float, cy: float, x: float, y: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def rel_arc_to(self, x1: float, y1: float, x2: float, y2: float, radius: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def rectangle(self, x: float, y: float, w: float, h: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def round_rectangle(self, x: float, y: float, w: float, h: float, r: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def arc(self, x: float, y: float, radius: float, angle1: float, angle2: float, direction: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def close_path(self) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def preserve(self) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def fill(self) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def stroke(self) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def paint(self) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def linear_gradient(self, x0: float, y0: float, x1: float, y1: float) -> 'Ctx':
+        """
+        Change the source to a linear gradient from x0,y0 to x1,y1, by default
+        an empty gradient from black to white exists, add stops with
+        gradient_add_stop to specify a custom gradient.
+
+        TODO(q3k): check gradient_add_stop
+        """
+        pass
+
+    @abstractmethod
+    def radial_gradient(self, x0: float, y0: float, r0: float, x1: float, y1: float, r1: float) -> 'Ctx':
+        """
+        Change the source to a radial gradient from a circle x0,y0 with radius0
+        to an outer circle x1,y1 with radidus r1.
+
+        NOTE: currently only the second circle's origin is used, but both
+        radiuses are in use.
+        """
+        pass
+
+    @abstractmethod
+    def logo(self, x: float, y: float, dim: float) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
+
+    @abstractmethod
+    def text(self, text: str) -> 'Ctx':
+        """
+        TOD(q3k): document
+        """
+        pass
\ No newline at end of file
diff --git a/python_payload/st4m/ui/interactions.py b/python_payload/st4m/ui/interactions.py
new file mode 100644
index 0000000000000000000000000000000000000000..a665d17df56f38db5030b8067ca686b0e4c769cb
--- /dev/null
+++ b/python_payload/st4m/ui/interactions.py
@@ -0,0 +1,151 @@
+import st4m
+
+from st4m.input import InputState
+from st4m.ui.ctx import Ctx
+from st4m import Responder
+
+
+class ScrollController(st4m.Responder):
+    """
+    ScrolLController is a controller for physically scrollable one-dimensional
+    lists.
+
+    It allows navigating with explicit 'left'/'right' commands (triggered by eg.
+    buttons). In the future it will also allow navigating by captouch gestures.
+
+    Its output is the information about a 'target position' (integer from 0 to
+    Nitems-1) a 'current position' (float from -inf to +inf).
+
+    The target position is the user intent, ie. what the user likely selected.
+    This value should be used to interpret what the user has currently selected.
+
+    The current position is the animated 'current' position, which includes
+    effects like acceleration and past-end-of-list bounce-back. This value
+    should be used to render the current state of the scrolling list.
+    """
+    __slots__ = (
+        '_nitems',
+        '_target_position',
+        '_current_position',
+        '_velocity',
+    )
+
+    def __init__(self) -> None:
+        self._nitems = 0
+        self._target_position = 0
+        self._current_position = 0.0
+        self._velocity: float = 0.0
+    
+    def set_item_count(self, count: int) -> None:
+        """
+        Set how many items this scrollable list contains. Currently, updating
+        the item count does not gracefully handle current/target position
+        switching - it will be clamped to the new length. A different API should
+        provide the ability to remove a [start,end) range of items from the list
+        and update positions gracefully according to that.
+        """
+        if count < 0:
+            count = 0
+        self._nitems = count
+
+    def scroll_left(self) -> None:
+        """
+        Call when the user wants to scroll left by discrete action (eg. button
+        press).
+        """
+        self._target_position -= 1
+        self._velocity = -10
+
+    def scroll_right(self) -> None:
+        """
+        Call when the user wants to scroll right by discrete action (eg. button
+        press).
+        """
+        self._target_position += 1
+        self._velocity = 10
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        if self._nitems == 0:
+            self._target_position = 0
+            self._current_position = 0
+            self._velocity = 0
+            return
+
+        if self._target_position < 0:
+            self._target_position = 0
+        if self._target_position >= self._nitems:
+            self._target_position = self._nitems - 1
+
+        self._physics_step(delta_ms / 1000.0)
+
+    def draw(self, ctx: Ctx) -> None:
+        pass
+
+    def current_position(self) -> float:
+        """
+        Return current position, a float from -inf to +inf.
+
+        In general, this value will be within [O, Nitems), but might go out of
+        bounds when rendering the bounceback effect.
+
+        With every tick, this value moves closer and closer to target_position().
+
+        Use this value to animate the scroll list.
+        """
+        return self._current_position
+
+    def target_position(self) -> int:
+        """
+        Return user-selected 'target' position, an integer in [0, Nitems).
+
+        Use this value to interpret the user selection when eg. a 'select'
+        button is pressed.
+        """
+        return self._target_position
+
+    def at_left_limit(self) -> bool:
+        """
+        Returns true if the scrollable list is at its leftmost (0) position.
+        """
+        return self._target_position <= 0
+    
+    def at_right_limit(self) -> bool:
+        """
+        Returns true if the scrollable list is as its rightmost (Nitems-1)
+        position.
+        """
+        return self._target_position >= self._nitems - 1
+
+    def _physics_step(self, delta: float) -> None:
+        diff = float(self._target_position) - self._current_position
+        max_velocity = 500
+        velocity = self._velocity
+
+        if abs(diff) > 0.1:
+            # Apply force to reach target position.
+            if diff > 0:
+                velocity += 80 * delta
+            else:
+                velocity -= 80 * delta
+
+            # Clamp velocity.
+            if velocity > max_velocity:
+                velocity = max_velocity
+            if velocity < -max_velocity:
+                velocity = -max_velocity
+            self._velocity = velocity
+        else:
+            # Try to snap to target position.
+            pos = self._velocity > 0 and diff > 0
+            neg = self._velocity < 0 and diff < 0
+            if pos or neg:
+                self._current_position = self._target_position
+                self._velocity = 0
+
+        self._physics_integrate(delta)
+
+    def _physics_integrate(self, delta: float) -> None:
+        self._velocity -= self._velocity * delta * 10
+        self._current_position += self._velocity * delta
+
+
diff --git a/python_payload/st4m/ui/menu.py b/python_payload/st4m/ui/menu.py
new file mode 100644
index 0000000000000000000000000000000000000000..a2d85b4fbe58c9a9b41eced349d5ac227fd0ecad
--- /dev/null
+++ b/python_payload/st4m/ui/menu.py
@@ -0,0 +1,138 @@
+import st4m
+
+from st4m import Responder
+from st4m.goose import ABCBase, abstractmethod, List, Optional
+from st4m.input import InputState, InputController
+from st4m.ui.view import ViewWithInputState, View, ViewManager, ViewTransitionSwipeLeft, ViewTransitionSwipeRight
+from st4m.ui.interactions import ScrollController
+from st4m.ui.ctx import Ctx
+
+
+class MenuItem(ABCBase):
+    """
+    An abstract MenuItem to be implemented by concrete impementations.
+
+    A MenuItem implementation can be added to a MenuController 
+    """
+
+    @abstractmethod
+    def press(self, vm: Optional[ViewManager]) -> None:
+        """
+        Called when the menu item is 'pressed'/'activated'.
+
+        vm will be set the the active ViewManager if the menu is used in
+        ViewManager context.
+        """
+        pass
+
+    @abstractmethod
+    def label(self) -> str:
+        """
+        Return the printable label of the menu item.
+        """
+        pass
+
+
+class MenuItemForeground(MenuItem):
+    """
+    A MenuItem which, when activated, navigates to the given View.
+    """
+    def __init__(self, label: str, r: View) -> None:
+        self._r = r
+        self._label = label
+
+    def press(self, vm: Optional[ViewManager]) -> None:
+        if vm is not None:
+            vm.push(self._r, ViewTransitionSwipeLeft())
+
+    def label(self) -> str:
+        return self._label
+
+
+class MenuItemNoop(MenuItem):
+    """
+    A MenuItem which does nothing.
+    """
+    def __init__(self, label: str) -> None:
+        self._label = label
+
+    def press(self, vm: Optional[ViewManager]) -> None:
+        pass
+
+    def label(self) -> str:
+        return self._label
+
+
+class MenuItemBack(MenuItem):
+    """
+    A MenuItem which, when activatd, navigates back in history.
+    """
+    def press(self, vm: Optional[ViewManager]) -> None:
+        if vm is not None:
+            vm.pop(ViewTransitionSwipeRight())
+
+    def label(self) -> str:
+        return "Back"
+
+
+class MenuController(ViewWithInputState):
+    """
+    Base class for menus. Reacts to canonical inputs (left shoulder button) to
+    move across and select actions from the menu.
+
+    Implementers must implement draw() and use self._items and
+    self._scroll_controller to display menu items accordingly.
+    """
+    __slots__ = (
+        '_items',
+        '_scroll_controller',
+        '_view_manager',
+    )
+
+    def __init__(self, items: List[MenuItem], vm: Optional[ViewManager]) -> None:
+        self._items = items
+        self._scroll_controller = ScrollController()
+        self._scroll_controller.set_item_count(len(items))
+        self._view_manager = vm
+
+        super().__init__()
+
+    def _parse_state(self) -> None:
+        left = self.input.left_shoulder.left
+        right = self.input.left_shoulder.right
+        
+        if left.pressed:
+            self._scroll_controller.scroll_left()
+            return
+        if right.pressed:
+            self._scroll_controller.scroll_right()
+            return
+
+        if not self._scroll_controller.at_left_limit() and left.repeated:
+            self._scroll_controller.scroll_left()
+            return
+
+        if not self._scroll_controller.at_right_limit() and right.repeated:
+            self._scroll_controller.scroll_right()
+            return
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        super().think(ins, delta_ms)
+
+        self._scroll_controller.think(ins, delta_ms)
+
+        if self.input.left_shoulder.middle.pressed:
+            self.select()
+        self._parse_state()
+
+    def draw(self, ctx: Ctx) -> None:
+        pass
+
+    def select(self) -> None:
+        """
+        Call to activate the currently selected item.
+
+        Automatically called on canonical user input.
+        """
+        self._items[self._scroll_controller.target_position()].press(self._view_manager)
+
diff --git a/python_payload/st4m/ui/view.py b/python_payload/st4m/ui/view.py
new file mode 100644
index 0000000000000000000000000000000000000000..a66d9194f357d2854ae3333791ac216e2477dbfe
--- /dev/null
+++ b/python_payload/st4m/ui/view.py
@@ -0,0 +1,198 @@
+from st4m.reactor import Responder
+from st4m.goose import ABCBase, abstractmethod, Optional, List
+from st4m.input import InputState, InputController
+from st4m.ui.ctx import Ctx
+
+
+class View(Responder):
+    """
+    A View extends a reactor Responder with callbacks related to the Responder's
+    lifecycle in terms of being foregrounded or backgrounded.
+
+    These signals can be used to alter input processing, se the
+    ViewWithInputState class.
+    """
+
+    def on_enter(self) -> None:
+        """
+        Called when the View has just become active. This is guaranteed to be
+        called before think().
+        """
+        pass
+
+
+class ViewWithInputState(View):
+    """
+    A base class helper for implementing views which respond to inputs and who
+    want to do their own, separate input processing.
+
+    Derive this class, then use self.input to access the InputController.
+
+    Remember to call super().think() in think()!
+    """
+    __slots__ = (
+        'input',
+    )
+
+    def __init__(self) -> None:
+        self.input = InputController()
+
+    def on_enter(self) -> None:
+        self.input._ignore_pressed()
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        self.input.think(ins, delta_ms)
+
+
+class ViewTransition(ABCBase):
+    """
+    A transition from one View/Responder to another.
+
+    Can be implemented by the user to provide transition animations.
+    """
+    @abstractmethod
+    def draw(self, ctx: Ctx, transition: float, incoming: Responder, outgoing: Responder) -> None:
+        """
+        Called when the ViewManager performs a transition from the outgoing
+        responder to the incoming responder. The implementer should draw both
+        Responders when appropriate.
+
+        The transition value is a float from 0 to 1 which represents the
+        progress of the transition.
+        """
+        pass
+
+
+class ViewTransitionBlend(ViewTransition):
+    """
+    Transition from one view to another by opacity blending.
+    """
+    def draw(self, ctx: Ctx, transition: float, incoming: Responder, outgoing: Responder) -> None:
+        ctx.start_group()
+        outgoing.draw(ctx)
+        ctx.end_group()
+
+        ctx.start_group()
+        ctx.global_alpha = transition
+        incoming.draw(ctx)
+        ctx.end_group()
+
+
+class ViewTransitionSwipeLeft(ViewTransition):
+    """
+    Swipe the outoing view to the left and replace it with the incoming view.
+    """
+    def draw(self, ctx: Ctx, transition: float, incoming: Responder, outgoing: Responder) -> None:
+        ctx.save()
+        ctx.translate(transition * -240, 0)
+        outgoing.draw(ctx)
+        ctx.restore()
+
+        ctx.save()
+        ctx.translate(240 + transition * -240, 0)
+        incoming.draw(ctx)
+        ctx.restore()
+
+
+class ViewTransitionSwipeRight(ViewTransition):
+    """
+    Swipe the outoing view to the right and replace it with the incoming view.
+    """
+    def draw(self, ctx: Ctx, transition: float, incoming: Responder, outgoing: Responder) -> None:
+        ctx.save()
+        ctx.translate(transition * 240, 0)
+        outgoing.draw(ctx)
+        ctx.restore()
+
+        ctx.save()
+        ctx.translate(-240 + transition * 240, 0)
+        incoming.draw(ctx)
+        ctx.restore()
+
+
+class ViewManager(Responder):
+    """
+    The ViewManager implements stateful routing between Views.
+
+    It manages a history of Views, to which new Views can be pushed and then
+    popped.
+    """
+    def __init__(self, vt: ViewTransition) -> None:
+        """
+        Create a new ViewManager with a default ViewTransition.
+        """
+        self._incoming: Optional[View] = None
+        self._outgoing: Optional[View] = None
+
+		# Transition time.
+        self._time_ms = 150
+
+        self._default_vt = vt
+        self._overriden_vt: Optional[ViewTransition] = None
+
+        self._transitioning = False
+        self._transition = 0.0
+        self._history: List[View] = []
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        if self._transitioning:
+            self._transition += (delta_ms / 1000.0) * (1000 / self._time_ms)
+            if self._transition >= 1.0:
+                self._transition = 0
+                self._transitioning = False
+                
+                self._outgoing = None
+        
+        if self._outgoing is not None:
+            self._outgoing.think(ins, delta_ms)
+        if self._incoming is not None:
+            self._incoming.think(ins, delta_ms)
+
+    def draw(self, ctx: Ctx) -> None:
+        if self._transitioning:
+            vt = self._default_vt
+            if self._overriden_vt is not None:
+                vt = self._overriden_vt
+
+            if self._incoming is not None and self._outgoing is not None:
+                vt.draw(ctx, self._transition, self._incoming, self._outgoing)
+                return
+        if self._incoming is not None:
+            self._incoming.draw(ctx)
+
+    def replace(self, r: View, overide_vt: Optional[ViewTransition] = None) -> None:
+        """
+        Replace the existing view with the given View, optionally using a given
+        ViewTransition instead of the default.
+
+        The new view will _not_ be added to history!
+        """
+        self._outgoing = self._incoming
+        self._incoming = r
+        self._incoming.on_enter()
+        self._overriden_vt = overide_vt
+        if self._outgoing is None:
+            return
+
+        self._transitioning = True
+        self._transition = 0.0
+
+    def push(self, r: View, override_vt: Optional[ViewTransition] = None) -> None:
+        """
+        Push a View to the history stack and start transitioning to it. If set,
+        override_vt will be used instead of the default ViewTransition
+        animation.
+        """
+        if self._incoming is not None:
+            self._history.append(self._incoming)
+
+        self.replace(r, override_vt)
+
+    def pop(self, override_vt: Optional[ViewTransition] = None) -> None:
+        """
+        Pop a view from the history stack and start transitioning to it. If set,
+        override_vt will be used instead of the default ViewTransition
+        animation.
+        """
+        r = self._history.pop()
+        self.replace(r, override_vt)
\ No newline at end of file