diff --git a/python_payload/main.py b/python_payload/main.py
index 44d53748545c61df99df44963980060cac5dce5e..df12e6501e2ac6f0a19b67c51e91e1eaba73f13c 100644
--- a/python_payload/main.py
+++ b/python_payload/main.py
@@ -11,6 +11,11 @@ log.info(f"free memory: {gc.mem_free()}")
 import st3m
 
 from st3m.goose import Optional, List, ABCBase, abstractmethod
+
+from st3m import settings
+
+settings.load_all()
+
 from st3m.ui.view import View, ViewManager, ViewTransitionBlend
 from st3m.ui.menu import (
     MenuItemBack,
@@ -19,6 +24,7 @@ from st3m.ui.menu import (
 )
 
 from st3m.ui.elements.menus import FlowerMenu, SimpleMenu, SunMenu
+from st3m.ui.elements import overlays
 
 log.info("import apps done")
 log.info(f"free memory: {gc.mem_free()}")
@@ -39,8 +45,9 @@ leds.set_rgb(0, 255, 0, 0)
 vm = ViewManager(ViewTransitionBlend())
 
 
+# Preload all applications.
+# TODO(q3k): only load these on demand.
 from apps.demo_worms4 import app as worms
-
 from apps.harmonic_demo import app as harmonic
 from apps.melodic_demo import app as melodic
 from apps.nick import app as nick
@@ -54,6 +61,10 @@ melodic._view_manager = vm
 nick._view_manager = vm
 captouch_demo._view_manager = vm
 
+# Build menu structure
+
+menu_settings = settings.build_menu(vm)
+
 menu_music = SimpleMenu(
     [
         MenuItemBack(),
@@ -88,14 +99,32 @@ menu_main = SunMenu(
         MenuItemForeground("Badge", menu_badge),
         MenuItemForeground("Music", menu_music),
         MenuItemForeground("Apps", menu_apps),
-        MenuItemNoop("Settings"),
+        MenuItemForeground("Settings", menu_settings),
     ],
     vm,
 )
 
+
 vm.push(menu_main)
 
 reactor = st3m.Reactor()
-reactor.set_top(vm)
-# reactor.set_top(pr)
+
+# Set up top-level compositor (for combining viewmanager with overlays).
+compositor = overlays.Compositor(vm)
+
+
+# Tie compositor's debug overlay to setting.
+def _onoff_debug_update() -> None:
+    compositor.enabled[overlays.OverlayKind.Debug] = settings.onoff_debug.value
+
+
+_onoff_debug_update()
+settings.onoff_debug.subscribe(_onoff_debug_update)
+
+# Configure debug overlay fragments.
+debug = overlays.OverlayDebug()
+debug.add_fragment(overlays.DebugReactorStats(reactor))
+compositor.add_overlay(debug)
+
+reactor.set_top(compositor)
 reactor.run()
diff --git a/python_payload/st3m/goose.py b/python_payload/st3m/goose.py
index 979cba2e7154855955355f70b7c4bc52d84fcab5..51b32bf5c3d21123e10a8d613ebf5872c867da06 100644
--- a/python_payload/st3m/goose.py
+++ b/python_payload/st3m/goose.py
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
     class ABCBase(metaclass=ABCMeta):
         pass
 
-    from typing import List, Optional, Tuple, Dict, Any
+    from typing import List, Optional, Tuple, Dict, Any, Callable
     from enum import Enum
 else:
     # We're in CPython or Micropython.
@@ -31,7 +31,7 @@ else:
         return _fail
 
     try:
-        from typing import List, Optional, Tuple, Dict, Any
+        from typing import List, Optional, Tuple, Dict, Any, Callable
         from enum import Enum
     except ImportError:
         # We're in Micropython.
@@ -40,6 +40,7 @@ else:
         Tuple = None
         Dict = None
         Any = None
+        Callable = None
 
         class Enum:
             pass
@@ -55,4 +56,5 @@ __all__ = [
     "Tuple",
     "Dict",
     "Any",
+    "Callable",
 ]
diff --git a/python_payload/st3m/reactor.py b/python_payload/st3m/reactor.py
index 0957f7f99df8f89e4312f61a3654ec59a0944a28..2d0b7906b66d4fc25a585a242fb07b837c1c988e 100644
--- a/python_payload/st3m/reactor.py
+++ b/python_payload/st3m/reactor.py
@@ -48,6 +48,24 @@ class Responder(ABCBase):
         pass
 
 
+class ReactorStats:
+    def __init__(self) -> None:
+        self.run_times: List[int] = []
+        self.render_times: List[int] = []
+
+    def record_run_time(self, ms: int) -> None:
+        self.run_times.append(ms)
+        delete = len(self.run_times) - 20
+        if delete > 0:
+            self.run_times = self.run_times[delete:]
+
+    def record_render_time(self, ms: int) -> None:
+        self.render_times.append(ms)
+        delete = len(self.render_times) - 20
+        if delete > 0:
+            self.render_times = self.render_times[delete:]
+
+
 class Reactor:
     """
     The Reactor is the main Micropython scheduler of the st3m system and any
@@ -57,14 +75,24 @@ class Reactor:
     that saturates the display rasterization/blitting pipeline.
     """
 
-    __slots__ = ("_top", "_tickrate_ms", "_last_tick", "_ctx", "_ts")
+    __slots__ = (
+        "_top",
+        "_tickrate_ms",
+        "_last_tick",
+        "_ctx",
+        "_ts",
+        "_last_ctx_get",
+        "stats",
+    )
 
     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._last_ctx_get: Optional[int] = None
         self._ctx: Optional[Ctx] = None
+        self.stats = ReactorStats()
 
     def set_top(self, top: Responder) -> None:
         """
@@ -89,11 +117,11 @@ class Reactor:
         self._run_top(start)
 
         end = time.ticks_ms()
+        elapsed = end - start
+        self.stats.record_run_time(elapsed)
         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.
@@ -117,6 +145,11 @@ class Reactor:
         if self._ctx is None:
             self._ctx = hardware.get_ctx()
             if self._ctx is not None:
+                if self._last_ctx_get is not None:
+                    diff = start - self._last_ctx_get
+                    self.stats.record_render_time(diff)
+                self._last_ctx_get = start
+
                 self._ctx.save()
                 self._top.draw(self._ctx)
                 self._ctx.restore()
diff --git a/python_payload/st3m/settings.py b/python_payload/st3m/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..9581b66eaf61ac3bd28632dbad33ad7cec338408
--- /dev/null
+++ b/python_payload/st3m/settings.py
@@ -0,0 +1,344 @@
+"""
+Settings framework for flow3r badge.
+
+We call settings 'tunables', trying to emphasize that they are some kind of
+value that can be changed.
+
+Settings are persisted in /flash/settings.json, loaded on startup and saved on
+request.
+"""
+
+import json
+
+from st3m import Ctx, InputState, Responder, logging
+from st3m.goose import ABCBase, abstractmethod, Any, Dict, List, Optional, Callable
+from st3m.ui.menu import MenuController, MenuItem, MenuItemBack
+from st3m.ui.elements.menus import SimpleMenu
+from st3m.ui.view import ViewManager
+from st3m.utils import lerp, ease_out_cubic, reduce
+
+log = logging.Log(__name__, level=logging.INFO)
+
+
+class Tunable(ABCBase):
+    """
+    Base class for all settings. An instance of a Tunable is some kind of
+    setting with some kind of value. Each setting has exactly one instance of
+    Tunable in memory that represents its current configuration.
+
+    Other than a common interface, this also implements a mechanism for
+    downstream consumers to subscribe to tunable changes, and allows notifying
+    them.
+    """
+
+    def __init__(self) -> None:
+        self._subscribers: List[Callable[[], None]] = []
+
+    def subscribe(self, s: Callable[[], None]) -> None:
+        """
+        Subscribe to be updated when this tunable value's changes.
+        """
+        self._subscribers.append(s)
+
+    def notify(self) -> None:
+        """
+        Notify all subscribers.
+        """
+        for s in self._subscribers:
+            s()
+
+    @abstractmethod
+    def name(self) -> str:
+        """
+        Human-readable name of this setting.
+        """
+        ...
+
+    @abstractmethod
+    def get_widget(self) -> "TunableWidget":
+        """
+        Widget that will be used to render this setting in menus.
+        """
+        ...
+
+    @abstractmethod
+    def save(self) -> Dict[str, Any]:
+        """
+        Return dictionary that contains this setting's persistance data. Will be
+        merged with all other tunable's results.
+        """
+        ...
+
+    @abstractmethod
+    def load(self, d: Dict[str, Any]) -> None:
+        """
+        Load in-memory state from persisted data.
+        """
+        ...
+
+
+class TunableWidget(Responder):
+    """
+    A tunable's widget as rendered in menus.
+    """
+
+    @abstractmethod
+    def press(self, vm: Optional[ViewManager]) -> None:
+        """
+        Called when the menu item is 'pressed', ie. selected/activated. A widget
+        should react to this as the primary way to let the users manipulate the
+        value of the tunable from a menu.
+        """
+        ...
+
+
+class UnaryTunable(Tunable):
+    """
+    Basic implementation of a Tunable for single values. Most settings will are
+    UnaryTunables, with notable exceptions being things like lists or optional
+    settings.
+
+    UnaryTunable implements persistence by always being saved/loaded to same
+    json.style.path (only traversing nested dictionaries).
+    """
+
+    def __init__(self, name: str, key: str, default: Any):
+        """
+        Create an UnaryTunable with a given human-readable name, some
+        persistence key and some default value.
+        """
+        super().__init__()
+        self.key = key
+        self._name = name
+        self.value: Any = default
+
+    def name(self) -> str:
+        return self._name
+
+    def set_value(self, v: Any) -> None:
+        """
+        Call to set value in-memory and notify all listeners.
+        """
+        self.value = v
+        self.notify()
+
+    def save(self) -> Dict[str, Any]:
+        res: Dict[str, Any] = {}
+        sub = res
+
+        parts = self.key.split(".")
+        for i, part in enumerate(parts):
+            if i == len(parts) - 1:
+                sub[part] = self.value
+            else:
+                sub[part] = {}
+                sub = sub[part]
+        return res
+
+    def load(self, d: Dict[str, Any]) -> None:
+        def _get(v: Dict[str, Any], k: str) -> Any:
+            if k in v:
+                return v[k]
+            else:
+                return {}
+
+        path = self.key.split(".")
+        k = path[-1]
+        d = reduce(_get, path[:-1], d)
+        if k in d:
+            self.value = d[k]
+
+
+class OnOffTunable(UnaryTunable):
+    """
+    OnOffTunable is a UnaryTunable that has two values: on or off, and is
+    rendered accordingly as a slider switch.
+    """
+
+    def __init__(self, name: str, key: str, default: bool) -> None:
+        super().__init__(name, key, default)
+
+    def get_widget(self) -> TunableWidget:
+        return OnOffWidget(self)
+
+    def press(self, vm: Optional[ViewManager]) -> None:
+        if self.value == True:
+            self.set_value(False)
+        else:
+            self.set_value(True)
+
+
+class OnOffWidget(TunableWidget):
+    """
+    OnOffWidget is a TunableWidget for OnOffTunables. It renders a slider
+    switch.
+    """
+
+    def __init__(self, tunable: "OnOffTunable") -> None:
+        self._tunable = tunable
+
+        # Value from 0 to animation_duration indicating animation progress
+        # (starts at animation_duration, ends at 0).
+        self._animating: float = 0
+
+        # Last and previous read value from tunable.
+        self._state = tunable.value == True
+        self._prev_state = self._state
+
+        # Value from 0 to 1, representing desired animation position. Linear
+        # between both. 1 represents rendering _state, 0 represents render the
+        # opposite of _state.
+        self._progress = 1.0
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        animation_duration = 0.2
+
+        self._state = self._tunable.value == True
+
+        if self._prev_state != self._state:
+            # State switched.
+
+            # Start new animation, making sure to take into consideration
+            # whatever animation is already taking place.
+            self._animating = animation_duration - self._animating
+        else:
+            # Continue animation.
+            self._animating -= delta_ms / 1000
+            if self._animating < 0:
+                self._animating = 0
+
+        # Calculate progress value.
+        self._progress = 1.0 - (self._animating / animation_duration)
+        self._prev_state = self._state
+
+    def draw(self, ctx: Ctx) -> None:
+        ctx.scale(0.8, 0.8)
+        # TODO(pippin): graphical glitches without the two next lines
+        ctx.rectangle(-200, -200, 10, 10)
+        ctx.fill()
+
+        value = self._state
+        v = self._progress
+        v = ease_out_cubic(v)
+        if not value:
+            v = 1.0 - v
+
+        ctx.rgb(lerp(0, 0.4, v), lerp(0, 0.6, v), lerp(0, 0.4, v))
+
+        ctx.round_rectangle(0, -10, 40, 20, 5)
+        ctx.line_width = 2
+        ctx.fill()
+
+        ctx.round_rectangle(0, -10, 40, 20, 5)
+        ctx.line_width = 2
+        ctx.gray(lerp(0.3, 1, v))
+        ctx.stroke()
+
+        ctx.gray(1)
+        ctx.round_rectangle(lerp(2, 22, v), -8, 16, 16, 5)
+        ctx.fill()
+
+    def press(self, vm: Optional[ViewManager]) -> None:
+        self._tunable.set_value(not self._state)
+
+
+class SettingsMenuItem(MenuItem):
+    """
+    A MenuItem which draws its label offset to the left, and a Tunable's widget
+    to the right.
+    """
+
+    def __init__(self, tunable: UnaryTunable):
+        self.tunable = tunable
+        self.widget = tunable.get_widget()
+
+    def press(self, vm: Optional[ViewManager]) -> None:
+        self.widget.press(vm)
+
+    def label(self) -> str:
+        return self.tunable._name
+
+    def draw(self, ctx: Ctx) -> None:
+        ctx.move_to(40, 0)
+        ctx.text_align = ctx.RIGHT
+        super().draw(ctx)
+        ctx.stroke()
+
+        ctx.save()
+        ctx.translate(50, 0)
+        self.widget.draw(ctx)
+        ctx.restore()
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        self.widget.think(ins, delta_ms)
+
+
+class SettingsMenuItemBack(MenuItemBack):
+    """
+    Extends MenuItemBack to save settings on exit.
+    """
+
+    def press(self, vm: Optional[ViewManager]) -> None:
+        save_all()
+        super().press(vm)
+
+
+class SettingsMenu(SimpleMenu):
+    """
+    SimpleMenu but smol.
+    """
+
+    SIZE_LARGE = 20
+    SIZE_SMALL = 15
+
+
+# Actual tunables / settings.
+onoff_debug = OnOffTunable("Debug Overlay", "system.debug", False)
+all_settings: List[UnaryTunable] = [
+    onoff_debug,
+]
+
+
+def load_all() -> None:
+    """
+    Load all settings from flash.
+    """
+    data = {}
+    try:
+        with open("/flash/settings.json", "r") as f:
+            data = json.load(f)
+    except Exception as e:
+        log.warning("Could not load settings: " + str(e))
+        return
+
+    log.info("Loaded settings from flash")
+    for setting in all_settings:
+        setting.load(data)
+
+
+def save_all() -> None:
+    """
+    Save all settings to flash.
+    """
+    res = {}
+    for setting in all_settings:
+        res.update(setting.save())
+    try:
+        with open("/flash/settings.json", "w") as f:
+            json.dump(res, f)
+    except Exception as e:
+        log.warning("Could not save settings: " + str(e))
+        return
+
+    log.info("Saved settings to flash")
+
+
+def build_menu(vm: ViewManager) -> SimpleMenu:
+    """
+    Return a SimpleMenu for all settings.
+    """
+    mib: MenuItem = SettingsMenuItemBack()
+    positions: List[MenuItem] = [
+        mib,
+    ] + [SettingsMenuItem(t) for t in all_settings]
+    return SettingsMenu(positions, vm)
diff --git a/python_payload/st3m/ui/ctx.py b/python_payload/st3m/ui/ctx.py
index 094e336084c0debaa18ad62f7e613436f1027524..5c9c7aedb404af49af9a21c6891e989e80793479 100644
--- a/python_payload/st3m/ui/ctx.py
+++ b/python_payload/st3m/ui/ctx.py
@@ -29,6 +29,7 @@ class Ctx(ABCBase):
     )
 
     CENTER = "center"
+    RIGHT = "right"
     MIDDLE = "middle"
 
     @abstractmethod
diff --git a/python_payload/st3m/ui/elements/menus.py b/python_payload/st3m/ui/elements/menus.py
index 69f3414daf631a8876a7e9f21022af6801cab150..48170deebf190192d6d2de677447d9b43fa59333 100644
--- a/python_payload/st3m/ui/elements/menus.py
+++ b/python_payload/st3m/ui/elements/menus.py
@@ -14,21 +14,30 @@ class SimpleMenu(MenuController):
     A simple line-by-line menu.
     """
 
+    SIZE_LARGE = 30
+    SIZE_SMALL = 20
+
     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())
+            offs = (ix - current) * self.SIZE_LARGE
+            ctx.save()
+            ctx.move_to(0, 0)
+            ctx.font_size = self.SIZE_LARGE
+            ctx.text_align = ctx.CENTER
+            ctx.text_baseline = ctx.MIDDLE
+            ctx.translate(0, offs)
+            scale = lerp(
+                1, self.SIZE_SMALL / self.SIZE_LARGE, abs(offs / self.SIZE_SMALL)
+            )
+            ctx.scale(scale, scale)
+            item.draw(ctx)
+            ctx.restore()
 
 
 class SunMenu(MenuController):
@@ -51,8 +60,8 @@ class SunMenu(MenuController):
         self._sun.think(ins, delta_ms)
         self._ts += delta_ms
 
-    def _draw_text_angled(
-        self, ctx: Ctx, text: str, angle: float, activity: float
+    def _draw_item_angled(
+        self, ctx: Ctx, item: MenuItem, angle: float, activity: float
     ) -> None:
         size = lerp(20, 40, activity)
         color = lerp(0, 1, activity)
@@ -62,7 +71,8 @@ class SunMenu(MenuController):
         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.rgba(1.0, 1.0, 1.0, color).move_to(0, 0)
+        item.draw(ctx)
         ctx.restore()
 
     def draw(self, ctx: Ctx) -> None:
@@ -81,7 +91,7 @@ class SunMenu(MenuController):
 
         for ix, item in enumerate(self._items):
             rot = (ix - current) * angle_per_item
-            self._draw_text_angled(ctx, item.label(), rot, 1 - abs(rot))
+            self._draw_item_angled(ctx, item, rot, 1 - abs(rot))
 
 
 class FlowerMenu(MenuController):
diff --git a/python_payload/st3m/ui/elements/overlays.py b/python_payload/st3m/ui/elements/overlays.py
new file mode 100644
index 0000000000000000000000000000000000000000..a02cceccd964a25baae1c9c0173cf164c3cc485f
--- /dev/null
+++ b/python_payload/st3m/ui/elements/overlays.py
@@ -0,0 +1,140 @@
+"""
+Composition/overlay system.
+
+This is different from a menu system. Instead of navigation, it should be used
+for persistent, anchored symbols like charging symbols, toasts, debug overlays,
+etc.
+"""
+
+from st3m import Responder, Ctx, InputState, Reactor
+from st3m.goose import Dict, Enum, List, ABCBase, abstractmethod
+
+
+class OverlayKind(Enum):
+    # Battery, USB, ...
+    Indicators = 0
+    # Naughty debug developers for naughty developers and debuggers.
+    Debug = 1
+
+
+_all_kinds = [
+    OverlayKind.Indicators,
+    OverlayKind.Debug,
+]
+
+
+class Overlay(Responder):
+    """
+    An Overlay is a Responder with some kind of OverlayKind attached.g
+    """
+
+    kind: OverlayKind
+
+
+class Compositor(Responder):
+    """
+    A Compositor blends together some main Responder (usually a ViewManager)
+    alongside with active Overlays. Overlays can be enabled/disabled by kind.
+    """
+
+    def __init__(self, main: Responder):
+        self.main = main
+        self.overlays: Dict[OverlayKind, List[Responder]] = {}
+        self.enabled: Dict[OverlayKind, bool] = {
+            OverlayKind.Indicators: True,
+            OverlayKind.Debug: True,
+        }
+
+    def _enabled_overlays(self) -> List[Responder]:
+        res: List[Responder] = []
+        for kind in _all_kinds:
+            if not self.enabled.get(kind, False):
+                continue
+            for overlay in self.overlays.get(kind, []):
+                res.append(overlay)
+        return res
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        self.main.think(ins, delta_ms)
+        for overlay in self._enabled_overlays():
+            overlay.think(ins, delta_ms)
+
+    def draw(self, ctx: Ctx) -> None:
+        self.main.draw(ctx)
+        for overlay in self._enabled_overlays():
+            overlay.draw(ctx)
+
+    def add_overlay(self, ov: Overlay) -> None:
+        """
+        Add some Overlay to the Compositor. It will be drawn if enabled.
+        """
+        if ov.kind not in self.overlays:
+            self.overlays[ov.kind] = []
+        self.overlays[ov.kind].append(ov)
+
+
+class DebugFragment(ABCBase):
+    """
+    Something which wishes to provide some text to the OverlayDebug.
+    """
+
+    @abstractmethod
+    def text(self) -> str:
+        ...
+
+
+class DebugReactorStats(DebugFragment):
+    """
+    DebugFragment which provides the OverlayDebug with information about the
+    active reactor.
+    """
+
+    def __init__(self, reactor: Reactor):
+        self._stats = reactor.stats
+
+    def text(self) -> str:
+        res = []
+
+        rts = self._stats.run_times
+        if len(rts) > 0:
+            avg = sum(rts) / len(rts)
+            res.append(f"tick: {int(avg)}ms")
+
+        rts = self._stats.render_times
+        if len(rts) > 0:
+            avg = sum(rts) / len(rts)
+            res.append(f"fps: {int(1/(avg/1000))}")
+
+        return " ".join(res)
+
+
+class OverlayDebug(Overlay):
+    """
+    Overlay which renders a text bar at the bottom of the screen with random
+    bits of trivia.
+    """
+
+    kind = OverlayKind.Debug
+
+    def __init__(self) -> None:
+        self.fragments: List[DebugFragment] = []
+
+    def add_fragment(self, f: DebugFragment) -> None:
+        self.fragments.append(f)
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        pass
+
+    def text(self) -> str:
+        text = [f.text() for f in self.fragments if f.text() != ""]
+        return " ".join(text)
+
+    def draw(self, ctx: Ctx) -> None:
+        ctx.save()
+        ctx.text_align = ctx.CENTER
+        ctx.text_baseline = ctx.MIDDLE
+        ctx.font = ctx.get_font_name(0)
+        ctx.font_size = 10
+        ctx.gray(0).rectangle(-120, 80, 240, 12).fill()
+        ctx.gray(1).move_to(0, 86).text(self.text())
+        ctx.restore()
diff --git a/python_payload/st3m/ui/menu.py b/python_payload/st3m/ui/menu.py
index 329e90e55b692353d48241f1a5048ea10fb0fc57..51d81a15955e4939f6817d886a3be870269fc274 100644
--- a/python_payload/st3m/ui/menu.py
+++ b/python_payload/st3m/ui/menu.py
@@ -14,11 +14,16 @@ from st3m.ui.interactions import ScrollController
 from st3m.ui.ctx import Ctx
 
 
-class MenuItem(ABCBase):
+class MenuItem(Responder):
     """
     An abstract MenuItem to be implemented by concrete impementations.
 
     A MenuItem implementation can be added to a MenuController
+
+    Every MenuItems is also a Responder that will be called whenever said
+    MenuItem should be rendered within a Menu. A Default draw/think
+    implementation is provided which simply render's this MenuItem's label as
+    text.
     """
 
     @abstractmethod
@@ -38,6 +43,12 @@ class MenuItem(ABCBase):
         """
         pass
 
+    def draw(self, ctx: Ctx) -> None:
+        ctx.text(self.label())
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        pass
+
 
 class MenuItemForeground(MenuItem):
     """
@@ -128,6 +139,8 @@ class MenuController(ViewWithInputState):
 
     def think(self, ins: InputState, delta_ms: int) -> None:
         super().think(ins, delta_ms)
+        for item in self._items:
+            item.think(ins, delta_ms)
 
         self._scroll_controller.think(ins, delta_ms)
 
diff --git a/python_payload/st3m/utils.py b/python_payload/st3m/utils.py
index cdcafc4b559e8bab7236a12c8f4299f038ed209e..e45779251c44feb48b9d5f8b978cc30fc10d22bf 100644
--- a/python_payload/st3m/utils.py
+++ b/python_payload/st3m/utils.py
@@ -1,7 +1,10 @@
 import math
 
+from st3m.goose import Any
+
 
 def lerp(a: float, b: float, v: float) -> float:
+    """Interpolate between a and b, based on v in [0, 1]."""
     if v <= 0:
         return a
     if v >= 1.0:
@@ -9,8 +12,33 @@ def lerp(a: float, b: float, v: float) -> float:
     return a + (b - a) * v
 
 
+def ease_cubic(x: float) -> float:
+    """Cubic ease-in/ease-out function. Maps [0, 1] to [0, 1]."""
+    if x < 0.5:
+        return 4 * x * x * x
+    else:
+        return 1 - ((-2 * x + 2) ** 3) / 2
+
+
+def ease_out_cubic(x: float) -> float:
+    """Cubic ease-out function. Maps [0, 1] to [0, 1]."""
+    return 1 - (1 - x) ** 3
+
+
 def xy_from_polar(r: float, phi: float) -> tuple[float, float]:
     return (r * math.sin(phi), r * math.cos(phi))  # x  # y
 
 
+def reduce(function: Any, iterable: Any, initializer: Any = None) -> Any:
+    """functools.reduce but portable and poorly typed."""
+    it = iter(iterable)
+    if initializer is None:
+        value = next(it)
+    else:
+        value = initializer
+    for element in it:
+        value = function(value, element)
+    return value
+
+
 tau = math.pi * 2