Files
sideline/docs/superpowers/plans/2026-03-19-figment-mode.md
Gene Johnson 7014a9d5cd docs: add figment mode TDD implementation plan
8-task plan covering SVG rasterization, overlay rendering,
FigmentEffect plugin, trigger protocol, and scroll loop integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00

31 KiB

Figment Mode Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a periodic full-screen SVG glyph overlay ("figment mode") that renders flickery, theme-colored half-block art on top of the running news ticker.

Architecture: Hybrid EffectPlugin + overlay. FigmentEffect (effect plugin) owns the lifecycle, timer, and state machine. render_figment_overlay() (in layers.py) handles ANSI overlay rendering. engine/figment_render.py handles SVG→half-block rasterization. engine/figment_trigger.py defines the extensible input protocol.

Tech Stack: Python 3.10+, cairosvg (SVG→PNG), Pillow (image processing), existing effect plugin system (ABC-based), existing theme gradients.

Spec: docs/superpowers/specs/2026-03-19-figment-mode-design.md


Chunk 1: Foundation

Task 1: Merge main and add cairosvg dependency

The feat/figment branch is behind main by 2 commits (the ABC plugin migration). Must merge first so EffectPlugin is ABC-based.

Files:

  • Modify: pyproject.toml:28-38

  • Step 1: Merge main into feat/figment

git merge main

Expected: Fast-forward or clean merge. No conflicts (branch only added docs).

  • Step 2: Add cairosvg optional dependency

In pyproject.toml, add a figment extras group after the mic group (line 32):

figment = [
    "cairosvg>=2.7.0",
]
  • Step 3: Sync dependencies
uv sync --all-extras

Expected: cairosvg installs successfully.

  • Step 4: Verify cairosvg works
uv run python -c "import cairosvg; print('cairosvg OK')"

Expected: prints cairosvg OK

  • Step 5: Commit
git add pyproject.toml uv.lock
git commit -m "build: add cairosvg optional dependency for figment mode"

Task 2: Test fixture SVG and event types

Files:

  • Create: tests/fixtures/test.svg

  • Modify: engine/events.py:12-21 (add FIGMENT_TRIGGER), engine/events.py:62-68 (add FigmentTriggerEvent)

  • Step 1: Create minimal test SVG

Create tests/fixtures/test.svg — a simple 100x100 black rectangle on white:

<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
  <rect x="10" y="10" width="80" height="80" fill="black"/>
</svg>
  • Step 2: Add FIGMENT_TRIGGER event type

In engine/events.py, add to the EventType enum (after STREAM_END = auto() at line 20):

    FIGMENT_TRIGGER = auto()

And add the event dataclass at the end of the file (after StreamEvent):

@dataclass
class FigmentTriggerEvent:
    """Event emitted when a figment is triggered."""

    action: str
    value: float | str | None = None
    timestamp: datetime | None = None
  • Step 3: Run existing tests to verify no breakage
uv run pytest tests/test_events.py -v

Expected: All existing event tests pass.

  • Step 4: Commit
git add tests/fixtures/test.svg engine/events.py
git commit -m "feat(figment): add test fixture SVG and FIGMENT_TRIGGER event type"

Task 3: Trigger protocol and command types

Files:

  • Create: engine/figment_trigger.py

  • Create: tests/test_figment_trigger.py

  • Step 1: Write failing tests for FigmentCommand and FigmentAction

Create tests/test_figment_trigger.py:

"""Tests for engine.figment_trigger module."""

from enum import Enum

from engine.figment_trigger import FigmentAction, FigmentCommand


class TestFigmentAction:
    def test_is_enum(self):
        assert issubclass(FigmentAction, Enum)

    def test_has_trigger(self):
        assert FigmentAction.TRIGGER.value == "trigger"

    def test_has_set_intensity(self):
        assert FigmentAction.SET_INTENSITY.value == "set_intensity"

    def test_has_set_interval(self):
        assert FigmentAction.SET_INTERVAL.value == "set_interval"

    def test_has_set_color(self):
        assert FigmentAction.SET_COLOR.value == "set_color"

    def test_has_stop(self):
        assert FigmentAction.STOP.value == "stop"


class TestFigmentCommand:
    def test_trigger_command(self):
        cmd = FigmentCommand(action=FigmentAction.TRIGGER)
        assert cmd.action == FigmentAction.TRIGGER
        assert cmd.value is None

    def test_set_intensity_command(self):
        cmd = FigmentCommand(action=FigmentAction.SET_INTENSITY, value=0.8)
        assert cmd.value == 0.8

    def test_set_color_command(self):
        cmd = FigmentCommand(action=FigmentAction.SET_COLOR, value="orange")
        assert cmd.value == "orange"
  • Step 2: Run test to verify it fails
uv run pytest tests/test_figment_trigger.py -v

Expected: FAIL — ModuleNotFoundError: No module named 'engine.figment_trigger'

  • Step 3: Write FigmentTrigger protocol, FigmentAction, FigmentCommand

Create engine/figment_trigger.py:

"""
Figment trigger protocol and command types.

Defines the extensible input abstraction for triggering figment displays
from any control surface (ntfy, MQTT, serial, etc.).
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Protocol


class FigmentAction(Enum):
    TRIGGER = "trigger"
    SET_INTENSITY = "set_intensity"
    SET_INTERVAL = "set_interval"
    SET_COLOR = "set_color"
    STOP = "stop"


@dataclass
class FigmentCommand:
    action: FigmentAction
    value: float | str | None = None


class FigmentTrigger(Protocol):
    """Protocol for figment trigger sources.

    Any input source (ntfy, MQTT, serial) can implement this
    to trigger and control figment displays.
    """

    def poll(self) -> FigmentCommand | None: ...
  • Step 4: Run tests to verify they pass
uv run pytest tests/test_figment_trigger.py -v

Expected: All 8 tests pass.

  • Step 5: Commit
git add engine/figment_trigger.py tests/test_figment_trigger.py
git commit -m "feat(figment): add trigger protocol and command types"

Chunk 2: SVG Rasterization

Task 4: SVG to half-block rasterizer

Files:

  • Create: engine/figment_render.py

  • Create: tests/test_figment_render.py

  • Step 1: Write failing tests for rasterize_svg

Create tests/test_figment_render.py:

"""Tests for engine.figment_render module."""

import os

from engine.figment_render import rasterize_svg

FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")


class TestRasterizeSvg:
    def test_returns_list_of_strings(self):
        rows = rasterize_svg(FIXTURE_SVG, 40, 20)
        assert isinstance(rows, list)
        assert all(isinstance(r, str) for r in rows)

    def test_output_height_matches_terminal_height(self):
        rows = rasterize_svg(FIXTURE_SVG, 40, 20)
        assert len(rows) == 20

    def test_output_contains_block_characters(self):
        rows = rasterize_svg(FIXTURE_SVG, 40, 20)
        all_chars = "".join(rows)
        block_chars = {"█", "▀", "▄"}
        assert any(ch in all_chars for ch in block_chars)

    def test_different_sizes_produce_different_output(self):
        rows_small = rasterize_svg(FIXTURE_SVG, 20, 10)
        rows_large = rasterize_svg(FIXTURE_SVG, 80, 40)
        assert len(rows_small) == 10
        assert len(rows_large) == 40

    def test_nonexistent_file_raises(self):
        import pytest
        with pytest.raises(Exception):
            rasterize_svg("/nonexistent/file.svg", 40, 20)


class TestRasterizeCache:
    def test_cache_returns_same_result(self):
        rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
        rows2 = rasterize_svg(FIXTURE_SVG, 40, 20)
        assert rows1 == rows2

    def test_cache_invalidated_by_size_change(self):
        rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
        rows2 = rasterize_svg(FIXTURE_SVG, 60, 30)
        assert len(rows1) != len(rows2)
  • Step 2: Run tests to verify they fail
uv run pytest tests/test_figment_render.py -v

Expected: FAIL — ModuleNotFoundError: No module named 'engine.figment_render'

  • Step 3: Implement rasterize_svg

Create engine/figment_render.py:

"""
SVG to half-block terminal art rasterization.

Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
"""

from __future__ import annotations

from io import BytesIO

import cairosvg
from PIL import Image

_cache: dict[tuple[str, int, int], list[str]] = {}


def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
    """Convert SVG file to list of half-block terminal rows (uncolored).

    Args:
        svg_path: Path to SVG file.
        width: Target terminal width in columns.
        height: Target terminal height in rows.

    Returns:
        List of strings, one per terminal row, containing block characters.
    """
    cache_key = (svg_path, width, height)
    if cache_key in _cache:
        return _cache[cache_key]

    # SVG -> PNG in memory
    png_bytes = cairosvg.svg2png(
        url=svg_path,
        output_width=width,
        output_height=height * 2,  # 2 pixel rows per terminal row
    )

    # PNG -> greyscale PIL image
    img = Image.open(BytesIO(png_bytes)).convert("L")
    img = img.resize((width, height * 2), Image.Resampling.LANCZOS)

    data = img.tobytes()
    pix_w = width
    pix_h = height * 2
    threshold = 80

    # Half-block encode: walk pixel pairs
    rows: list[str] = []
    for y in range(0, pix_h, 2):
        row: list[str] = []
        for x in range(pix_w):
            top = data[y * pix_w + x] > threshold
            bot = data[(y + 1) * pix_w + x] > threshold if y + 1 < pix_h else False
            if top and bot:
                row.append("█")
            elif top:
                row.append("▀")
            elif bot:
                row.append("▄")
            else:
                row.append(" ")
        rows.append("".join(row))

    _cache[cache_key] = rows
    return rows


def clear_cache() -> None:
    """Clear the rasterization cache (e.g., on terminal resize)."""
    _cache.clear()
  • Step 4: Run tests to verify they pass
uv run pytest tests/test_figment_render.py -v

Expected: All 7 tests pass.

  • Step 5: Commit
git add engine/figment_render.py tests/test_figment_render.py
git commit -m "feat(figment): add SVG to half-block rasterization pipeline"

Chunk 3: FigmentEffect Plugin

Task 5: FigmentEffect state machine and lifecycle

This is the core plugin. It manages the timer, SVG selection, state machine, and exposes get_figment_state().

Files:

  • Create: effects_plugins/figment.py

  • Create: tests/test_figment.py

  • Step 1: Write failing tests for FigmentState, FigmentPhase, and state machine

Create tests/test_figment.py:

"""Tests for the FigmentEffect plugin."""

import os
from enum import Enum
from unittest.mock import patch

import pytest

from effects_plugins.figment import FigmentEffect, FigmentPhase, FigmentState
from engine.effects.types import EffectConfig, EffectContext


FIXTURE_SVG = os.path.join(
    os.path.dirname(__file__), "fixtures", "test.svg"
)
FIGMENTS_DIR = os.path.join(os.path.dirname(__file__), "fixtures")


class TestFigmentPhase:
    def test_is_enum(self):
        assert issubclass(FigmentPhase, Enum)

    def test_has_all_phases(self):
        assert hasattr(FigmentPhase, "REVEAL")
        assert hasattr(FigmentPhase, "HOLD")
        assert hasattr(FigmentPhase, "DISSOLVE")


class TestFigmentState:
    def test_creation(self):
        state = FigmentState(
            phase=FigmentPhase.REVEAL,
            progress=0.5,
            rows=["█▀▄", " █ "],
            gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
            center_row=5,
            center_col=10,
        )
        assert state.phase == FigmentPhase.REVEAL
        assert state.progress == 0.5
        assert len(state.rows) == 2


class TestFigmentEffectInit:
    def test_name(self):
        effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
        assert effect.name == "figment"

    def test_default_config(self):
        effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
        assert effect.config.enabled is False
        assert effect.config.intensity == 1.0
        assert effect.config.params["interval_secs"] == 60
        assert effect.config.params["display_secs"] == 4.5

    def test_process_is_noop(self):
        effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
        buf = ["line1", "line2"]
        ctx = EffectContext(
            terminal_width=80,
            terminal_height=24,
            scroll_cam=0,
            ticker_height=20,
        )
        result = effect.process(buf, ctx)
        assert result == buf
        assert result is buf

    def test_configure(self):
        effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
        new_cfg = EffectConfig(enabled=True, intensity=0.5)
        effect.configure(new_cfg)
        assert effect.config.enabled is True
        assert effect.config.intensity == 0.5


class TestFigmentStateMachine:
    def test_idle_initially(self):
        effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
        effect.config.enabled = True
        state = effect.get_figment_state(0, 80, 24)
        # Timer hasn't fired yet, should be None (idle)
        assert state is None

    def test_trigger_starts_reveal(self):
        effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
        effect.config.enabled = True
        effect.trigger(80, 24)
        state = effect.get_figment_state(1, 80, 24)
        assert state is not None
        assert state.phase == FigmentPhase.REVEAL

    def test_full_cycle(self):
        effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
        effect.config.enabled = True
        effect.config.params["display_secs"] = 0.15  # 3 phases x 0.05s

        effect.trigger(40, 20)

        # Advance through reveal (30 frames at 0.05s = 1.5s, but we shrunk it)
        # With display_secs=0.15, each phase is 0.05s = 1 frame
        state = effect.get_figment_state(1, 40, 20)
        assert state is not None
        assert state.phase == FigmentPhase.REVEAL

        # Advance enough frames to get through all phases
        last_state = None
        for frame in range(2, 100):
            state = effect.get_figment_state(frame, 40, 20)
            if state is None:
                break
            last_state = state

        # Should have completed the full cycle back to idle
        assert state is None

    def test_timer_fires_at_interval(self):
        effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
        effect.config.enabled = True
        effect.config.params["interval_secs"] = 0.1  # 2 frames at 20fps

        # Frame 0: idle
        state = effect.get_figment_state(0, 40, 20)
        assert state is None

        # Advance past interval (0.1s = 2 frames)
        state = effect.get_figment_state(1, 40, 20)
        state = effect.get_figment_state(2, 40, 20)
        state = effect.get_figment_state(3, 40, 20)
        # Timer should have fired by now
        assert state is not None


class TestFigmentEdgeCases:
    def test_empty_figment_dir(self, tmp_path):
        effect = FigmentEffect(figment_dir=str(tmp_path))
        effect.config.enabled = True
        effect.trigger(40, 20)
        state = effect.get_figment_state(1, 40, 20)
        # No SVGs available — should stay idle
        assert state is None

    def test_missing_figment_dir(self):
        effect = FigmentEffect(figment_dir="/nonexistent/path")
        effect.config.enabled = True
        effect.trigger(40, 20)
        state = effect.get_figment_state(1, 40, 20)
        assert state is None

    def test_disabled_ignores_trigger(self):
        effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
        effect.config.enabled = False
        effect.trigger(80, 24)
        state = effect.get_figment_state(1, 80, 24)
        assert state is None
  • Step 2: Run tests to verify they fail
uv run pytest tests/test_figment.py -v

Expected: FAIL — ImportError

  • Step 3: Implement FigmentEffect

Create effects_plugins/figment.py:

"""
Figment effect plugin — periodic SVG glyph overlay.

Owns the figment lifecycle: timer, SVG selection, state machine.
Delegates rendering to render_figment_overlay() in engine/layers.py.

Named FigmentEffect (not FigmentPlugin) to match the *Effect discovery
convention in effects_plugins/__init__.py.

NOT added to the EffectChain order — process() is a no-op. The overlay
rendering is handled by scroll.py calling get_figment_state().
"""

from __future__ import annotations

import random
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path

from engine import config
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
from engine.figment_render import rasterize_svg
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
from engine.themes import THEME_REGISTRY


class FigmentPhase(Enum):
    REVEAL = auto()
    HOLD = auto()
    DISSOLVE = auto()


@dataclass
class FigmentState:
    phase: FigmentPhase
    progress: float
    rows: list[str]
    gradient: list[int]
    center_row: int
    center_col: int


class FigmentEffect(EffectPlugin):
    name = "figment"
    config = EffectConfig(
        enabled=False,
        intensity=1.0,
        params={
            "interval_secs": 60,
            "display_secs": 4.5,
            "figment_dir": "figments",
        },
    )

    def __init__(self, figment_dir: str | None = None, triggers: list[FigmentTrigger] | None = None):
        self.config = EffectConfig(
            enabled=False,
            intensity=1.0,
            params={
                "interval_secs": 60,
                "display_secs": 4.5,
                "figment_dir": figment_dir or "figments",
            },
        )
        self._triggers = triggers or []
        self._phase: FigmentPhase | None = None
        self._progress: float = 0.0
        self._rows: list[str] = []
        self._gradient: list[int] = []
        self._center_row: int = 0
        self._center_col: int = 0
        self._timer: float = 0.0
        self._last_svg: str | None = None
        self._svg_files: list[str] = []
        self._scan_svgs()

    def _scan_svgs(self) -> None:
        figment_dir = Path(self.config.params["figment_dir"])
        if figment_dir.is_dir():
            self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))

    def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
        return buf

    def configure(self, cfg: EffectConfig) -> None:
        self.config = cfg
        self._scan_svgs()

    def trigger(self, w: int, h: int) -> None:
        """Manually trigger a figment display."""
        if not self._svg_files:
            return

        # Pick a random SVG, avoid repeating
        candidates = [s for s in self._svg_files if s != self._last_svg]
        if not candidates:
            candidates = self._svg_files
        svg_path = random.choice(candidates)
        self._last_svg = svg_path

        # Rasterize
        try:
            self._rows = rasterize_svg(svg_path, w, h)
        except Exception:
            return

        # Pick random theme gradient
        theme_key = random.choice(list(THEME_REGISTRY.keys()))
        self._gradient = THEME_REGISTRY[theme_key].main_gradient

        # Center in viewport
        figment_h = len(self._rows)
        figment_w = max((len(r) for r in self._rows), default=0)
        self._center_row = max(0, (h - figment_h) // 2)
        self._center_col = max(0, (w - figment_w) // 2)

        # Start reveal phase
        self._phase = FigmentPhase.REVEAL
        self._progress = 0.0

    def get_figment_state(self, frame_number: int, w: int, h: int) -> FigmentState | None:
        """Tick the state machine and return current state, or None if idle."""
        if not self.config.enabled:
            return None

        # Poll triggers
        for trig in self._triggers:
            cmd = trig.poll()
            if cmd is not None:
                self._handle_command(cmd, w, h)

        # Tick timer when idle
        if self._phase is None:
            self._timer += config.FRAME_DT
            interval = self.config.params.get("interval_secs", 60)
            if self._timer >= interval:
                self._timer = 0.0
                self.trigger(w, h)

        # Tick animation
        if self._phase is not None:
            display_secs = self.config.params.get("display_secs", 4.5)
            phase_duration = display_secs / 3.0
            self._progress += config.FRAME_DT / phase_duration

            if self._progress >= 1.0:
                self._progress = 0.0
                if self._phase == FigmentPhase.REVEAL:
                    self._phase = FigmentPhase.HOLD
                elif self._phase == FigmentPhase.HOLD:
                    self._phase = FigmentPhase.DISSOLVE
                elif self._phase == FigmentPhase.DISSOLVE:
                    self._phase = None
                    return None

            return FigmentState(
                phase=self._phase,
                progress=self._progress,
                rows=self._rows,
                gradient=self._gradient,
                center_row=self._center_row,
                center_col=self._center_col,
            )

        return None

    def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
        if cmd.action == FigmentAction.TRIGGER:
            self.trigger(w, h)
        elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(cmd.value, (int, float)):
            self.config.intensity = float(cmd.value)
        elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(cmd.value, (int, float)):
            self.config.params["interval_secs"] = float(cmd.value)
        elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
            if cmd.value in THEME_REGISTRY:
                self._gradient = THEME_REGISTRY[cmd.value].main_gradient
        elif cmd.action == FigmentAction.STOP:
            self._phase = None
            self._progress = 0.0
  • Step 4: Run tests to verify they pass
uv run pytest tests/test_figment.py -v

Expected: All tests pass.

  • Step 5: Verify plugin discovery finds FigmentEffect
uv run python -c "
from engine.effects.registry import EffectRegistry, set_registry
set_registry(EffectRegistry())
from effects_plugins import discover_plugins
plugins = discover_plugins()
print('Discovered:', list(plugins.keys()))
assert 'figment' in plugins, 'FigmentEffect not discovered!'
print('OK')
"

Expected: Prints Discovered: ['noise', 'glitch', 'fade', 'firehose', 'figment'] and OK.

  • Step 6: Commit
git add effects_plugins/figment.py tests/test_figment.py
git commit -m "feat(figment): add FigmentEffect plugin with state machine and timer"

Chunk 4: Overlay Rendering and Scroll Integration

Task 6: Figment overlay renderer in layers.py

Files:

  • Modify: engine/layers.py:1-4 (add import), append render_figment_overlay() function

  • Create: tests/test_figment_overlay.py

  • Step 1: Write failing tests for render_figment_overlay

Create tests/test_figment_overlay.py:

"""Tests for render_figment_overlay in engine.layers."""

from effects_plugins.figment import FigmentPhase, FigmentState
from engine.layers import render_figment_overlay


def _make_state(phase=FigmentPhase.HOLD, progress=0.5):
    return FigmentState(
        phase=phase,
        progress=progress,
        rows=["█▀▄ █", " ▄█▀ ", "█   █"],
        gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
        center_row=10,
        center_col=37,
    )


class TestRenderFigmentOverlay:
    def test_returns_list_of_strings(self):
        state = _make_state()
        result = render_figment_overlay(state, 80, 24)
        assert isinstance(result, list)
        assert all(isinstance(s, str) for s in result)

    def test_contains_ansi_positioning(self):
        state = _make_state()
        result = render_figment_overlay(state, 80, 24)
        # Should contain cursor positioning escape codes
        assert any("\033[" in s for s in result)

    def test_reveal_phase_partial(self):
        state = _make_state(phase=FigmentPhase.REVEAL, progress=0.0)
        result = render_figment_overlay(state, 80, 24)
        # At progress 0.0, very few cells should be visible
        # Result should still be a valid list
        assert isinstance(result, list)

    def test_hold_phase_full(self):
        state = _make_state(phase=FigmentPhase.HOLD, progress=0.5)
        result = render_figment_overlay(state, 80, 24)
        # During hold, content should be present
        assert len(result) > 0

    def test_dissolve_phase(self):
        state = _make_state(phase=FigmentPhase.DISSOLVE, progress=0.9)
        result = render_figment_overlay(state, 80, 24)
        # At high dissolve progress, most cells are gone
        assert isinstance(result, list)

    def test_empty_rows(self):
        state = FigmentState(
            phase=FigmentPhase.HOLD,
            progress=0.5,
            rows=[],
            gradient=[46] * 12,
            center_row=0,
            center_col=0,
        )
        result = render_figment_overlay(state, 80, 24)
        assert result == []
  • Step 2: Run tests to verify they fail
uv run pytest tests/test_figment_overlay.py -v

Expected: FAIL — ImportError: cannot import name 'render_figment_overlay' from 'engine.layers'

  • Step 3: Implement render_figment_overlay

Add to the end of engine/layers.py (after get_effect_chain()):

def render_figment_overlay(
    figment_state,
    w: int,
    h: int,
) -> list[str]:
    """Render figment overlay as ANSI cursor-positioning commands.

    Args:
        figment_state: FigmentState with phase, progress, rows, gradient, centering.
        w: terminal width
        h: terminal height

    Returns:
        List of ANSI strings to append to display buffer.
    """
    from engine.render import lr_gradient, _color_codes_to_ansi

    rows = figment_state.rows
    if not rows:
        return []

    phase = figment_state.phase
    progress = figment_state.progress
    gradient = figment_state.gradient
    center_row = figment_state.center_row
    center_col = figment_state.center_col

    cols = _color_codes_to_ansi(gradient)

    # Determine cell visibility based on phase
    # Build a visibility mask for non-space cells
    cell_positions = []
    for r_idx, row in enumerate(rows):
        for c_idx, ch in enumerate(row):
            if ch != " ":
                cell_positions.append((r_idx, c_idx))

    n_cells = len(cell_positions)
    if n_cells == 0:
        return []

    # Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
    rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
    shuffled = list(cell_positions)
    rng.shuffle(shuffled)

    # Phase-dependent visibility
    from effects_plugins.figment import FigmentPhase

    if phase == FigmentPhase.REVEAL:
        visible_count = int(n_cells * progress)
        visible = set(shuffled[:visible_count])
    elif phase == FigmentPhase.HOLD:
        visible = set(cell_positions)
        # Strobe: dim some cells periodically
        if int(progress * 20) % 3 == 0:
            # Dim ~30% of cells for strobe effect
            dim_count = int(n_cells * 0.3)
            visible -= set(shuffled[:dim_count])
    elif phase == FigmentPhase.DISSOLVE:
        remaining_count = int(n_cells * (1.0 - progress))
        visible = set(shuffled[:remaining_count])
    else:
        visible = set(cell_positions)

    # Build overlay commands
    overlay: list[str] = []
    n_cols = len(cols)
    max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)

    for r_idx, row in enumerate(rows):
        scr_row = center_row + r_idx + 1  # 1-indexed
        if scr_row < 1 or scr_row > h:
            continue

        line_buf: list[str] = []
        has_content = False

        for c_idx, ch in enumerate(row):
            scr_col = center_col + c_idx + 1
            if scr_col < 1 or scr_col > w:
                continue

            if ch != " " and (r_idx, c_idx) in visible:
                # Apply gradient color
                shifted = (c_idx / max(max_x - 1, 1)) % 1.0
                idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
                line_buf.append(f"{cols[idx]}{ch}{RST}")
                has_content = True
            else:
                line_buf.append(" ")

        if has_content:
            # Trim trailing spaces
            line_str = "".join(line_buf).rstrip()
            if line_str.strip():
                overlay.append(
                    f"\033[{scr_row};{center_col + 1}H{line_str}{RST}"
                )

    return overlay
  • Step 4: Run tests to verify they pass
uv run pytest tests/test_figment_overlay.py -v

Expected: All 6 tests pass.

  • Step 5: Commit
git add engine/layers.py tests/test_figment_overlay.py
git commit -m "feat(figment): add render_figment_overlay() to layers.py"

Task 7: Scroll loop integration

Files:

  • Modify: engine/scroll.py:18-24 (add import), engine/scroll.py:30 (setup), engine/scroll.py:125-127 (frame loop)

  • Step 1: Add figment import and setup to stream()

In engine/scroll.py, add the import for render_figment_overlay to the existing layers import block (line 18-24):

from engine.layers import (
    apply_glitch,
    process_effects,
    render_firehose,
    render_figment_overlay,
    render_message_overlay,
    render_ticker_zone,
)

Then add the figment setup inside stream(), after the frame_number = 0 line (line 54):

    # Figment overlay (optional — requires cairosvg)
    try:
        from effects_plugins.figment import FigmentEffect
        from engine.effects.registry import get_registry

        _fg_plugin = get_registry().get("figment")
        figment = _fg_plugin if isinstance(_fg_plugin, FigmentEffect) else None
    except ImportError:
        figment = None
  • Step 2: Add figment overlay to frame loop

In the frame loop, insert the figment overlay block between the effects processing (line 120) and the message overlay (line 126). Insert after the else: block at line 124:

        # Figment overlay (between effects and ntfy message)
        if figment and figment.config.enabled:
            figment_state = figment.get_figment_state(frame_number, w, h)
            if figment_state is not None:
                figment_buf = render_figment_overlay(figment_state, w, h)
                buf.extend(figment_buf)
  • Step 3: Run full test suite
uv run pytest tests/ -v

Expected: All tests pass (existing + new). The 3 pre-existing warmup_topics failures remain.

  • Step 4: Commit
git add engine/scroll.py
git commit -m "feat(figment): integrate figment overlay into scroll loop"

Task 8: Run lint and final verification

  • Step 1: Run ruff linter
uv run ruff check .

Fix any issues found.

  • Step 2: Run ruff formatter
uv run ruff format .
  • Step 3: Run full test suite one more time
uv run pytest tests/ -v

Expected: All tests pass (except the 3 pre-existing warmup_topics failures).

  • Step 4: Commit any lint/format fixes
git add -u
git commit -m "style: apply ruff formatting to figment modules"

(Skip this commit if ruff made no changes.)