Files
Mainline/docs/superpowers/specs/2026-03-19-figment-mode-design.md
Gene Johnson 2cc8dbfc02 docs: address spec review feedback for figment mode
- Rename FigmentPlugin to FigmentEffect (discovery convention)
- Define FigmentState dataclass and FigmentPhase enum
- Clarify chain exclusion (no-op process, not in chain order)
- Add isinstance() downcast for type-safe scroll.py access
- Use FigmentAction enum instead of string literals
- Add Error Handling section (missing deps, empty dir, resize)
- Add Goals, Out of Scope sections
- Split tests per module (test_figment_render, test_figment_trigger)
- Add FIGMENT_TRIGGER to modified files (events.py)
- Document timing formula (progress += FRAME_DT / phase_duration)

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

14 KiB

Figment Mode Design Spec

Periodic full-screen SVG glyph overlay with flickery animation, theme-aware coloring, and extensible physical device control.

Overview

Figment mode displays a randomly selected SVG from the figments/ directory as a flickery, glitchy half-block terminal overlay on top of the running ticker. It appears once per minute (configurable), holds for ~4.5 seconds with a three-phase animation (progressive reveal, strobing hold, dissolve), then fades back to the ticker. Colors are randomly chosen from the existing theme gradients.

The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time.

Goals

  • Display SVG figments as half-block terminal art overlaid on the running ticker
  • Three-phase animation: progressive reveal, strobing hold, dissolve
  • Random color from existing theme gradients (green, orange, purple)
  • Configurable interval and duration via C&C
  • Extensible input abstraction for physical device control (MQTT, serial, etc.)

Out of Scope

  • Multi-figment simultaneous display (one at a time)
  • SVG animation support (static SVGs only; animation comes from the overlay phases)
  • Custom color palettes beyond existing themes
  • MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters)

Architecture: Hybrid Plugin + Overlay

The figment is an EffectPlugin for lifecycle, discovery, and configuration, but delegates rendering to a layers-style overlay helper. This avoids stretching the EffectPlugin.process() contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management.

Important: The plugin class is named FigmentEffect (not FigmentPlugin) to match the *Effect naming convention required by discover_plugins() in effects_plugins/__init__.py. The plugin is not added to the EffectChain order list — its process() is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in scroll.py, outside the chain.

Component Diagram

                     +-------------------+
                     |  FigmentTrigger   |  (Protocol)
                     |  - NtfyTrigger    |  (v1)
                     |  - MqttTrigger    |  (future)
                     |  - SerialTrigger  |  (future)
                     +--------+----------+
                              |
                              | FigmentCommand
                              v
+------------------+    +-----------------+    +----------------------+
| figment_render   |<---| FigmentEffect   |--->| render_figment_      |
| .py              |    | (EffectPlugin)  |    | overlay() in         |
|                  |    |                 |    | layers.py            |
| SVG -> PIL ->    |    | Timer, state    |    |                      |
| half-block cache |    | machine, SVG    |    | ANSI cursor-position |
|                  |    | selection       |    | commands for overlay |
+------------------+    +-----------------+    +----------------------+
                              |
                              | get_figment_state()
                              v
                     +-------------------+
                     | scroll.py         |
                     +-------------------+

Section 1: SVG Rasterization

File: engine/figment_render.py

Reuses the same PIL-based half-block encoding that engine/render.py uses for OTF fonts.

Pipeline

  1. Load: cairosvg.svg2png() converts SVG to PNG bytes in memory (no temp files)
  2. Resize: PIL scales to fit terminal — width = tw(), height = th() * 2 pixels (each terminal row encodes 2 pixel rows via half-blocks)
  3. Threshold: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible
  4. Half-block encode: Walk pixel pairs top-to-bottom. For each 2-row pair, emit (both lit), (top only), (bottom only), or space (neither)
  5. Cache: Results cached per (svg_path, terminal_width, terminal_height) — invalidated on terminal resize

Dependency

cairosvg added as an optional dependency in pyproject.toml (like sounddevice). If cairosvg is not installed, the FigmentEffect class will fail to import, and discover_plugins() will silently skip it (the existing except Exception: pass in discovery handles this). The plugin simply won't appear in the registry.

Key Function

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

Section 2: Figment Overlay Rendering

Integration point: engine/layers.py

New function following the render_message_overlay() pattern.

FigmentState Dataclass

Defined in effects_plugins/figment.py, passed between the plugin and the overlay renderer:

@dataclass
class FigmentState:
    phase: FigmentPhase          # enum: REVEAL, HOLD, DISSOLVE
    progress: float              # 0.0 to 1.0 within current phase
    rows: list[str]              # rasterized half-block rows (uncolored)
    gradient: list[int]          # 12-color ANSI 256 gradient from chosen theme
    center_row: int              # top row for centering in viewport
    center_col: int              # left column for centering in viewport

Function Signature

def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
    """Return ANSI cursor-positioning commands for the current figment frame."""

Animation Phases (~4.5 seconds total)

Progress advances each frame as: progress += config.FRAME_DT / phase_duration. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete.

Phase Duration Behavior
Reveal ~1.5s Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed.
Hold ~1.5s Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency.
Dissolve ~1.5s Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed.

Color

A random theme gradient is selected from THEME_REGISTRY at trigger time. Applied via lr_gradient() — the same function that colors headlines and messages.

Positioning

Figment is centered in the viewport. Each visible row is an ANSI \033[row;colH command appended to the buffer, identical to how the message overlay works.

Section 3: FigmentEffect (Effect Plugin)

File: effects_plugins/figment.py

An EffectPlugin(ABC) subclass named FigmentEffect to match the *Effect discovery convention.

Chain Exclusion

FigmentEffect is registered in the EffectRegistry (for C&C access and config management) but is not added to the EffectChain order list. Its process() returns the buffer unchanged. The enabled flag is checked directly by scroll.py when deciding whether to call get_figment_state(), not by the chain.

Responsibilities

  • Timer: Tracks elapsed time via config.FRAME_DT accumulation. At the configured interval (default 60s), triggers a new figment.
  • SVG selection: Randomly picks from figments/*.svg. Avoids repeating the last shown.
  • State machine: idle -> reveal -> hold -> dissolve -> idle. Tracks phase progress (0.0 to 1.0).
  • Color selection: Picks a random theme key ("green", "orange", "purple") at trigger time.
  • Rasterization: Calls rasterize_svg() on trigger, caches result for the display duration.

State Machine

idle ──(timer fires or trigger received)──> reveal
reveal ──(progress >= 1.0)──> hold
hold ──(progress >= 1.0)──> dissolve
dissolve ──(progress >= 1.0)──> idle

Interface

The process() method returns the buffer unchanged (no-op). The plugin exposes state via:

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

This mirrors the ntfy_poller.get_active_message() pattern.

Scroll Loop Access

scroll.py imports FigmentEffect directly and uses isinstance() to safely downcast from the registry:

from effects_plugins.figment import FigmentEffect

plugin = registry.get("figment")
figment = plugin if isinstance(plugin, FigmentEffect) else None

This is a one-time setup check, not per-frame. If cairosvg is missing, the import is wrapped in a try/except and figment stays None.

EffectConfig

  • enabled: bool (default False — opt-in)
  • intensity: float — scales strobe frequency and reveal/dissolve speed
  • params:
    • interval_secs: 60 (time between figments)
    • display_secs: 4.5 (total animation duration)
    • figment_dir: "figments" (SVG source directory)

Controllable via C&C: /effects figment on, /effects figment intensity 0.7.

Section 4: Input Abstraction (FigmentTrigger)

File: engine/figment_trigger.py

Protocol

class FigmentTrigger(Protocol):
    def poll(self) -> FigmentCommand | None: ...

FigmentCommand

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

Uses an enum for consistency with EventType in engine/events.py.

Adapters

Adapter Transport Dependency Status
NtfyTrigger Existing C&C ntfy topic None (reuses ntfy) v1
MqttTrigger MQTT broker paho-mqtt (optional) Future
SerialTrigger USB serial pyserial (optional) Future

NtfyTrigger v1: Subscribes as a callback on the existing NtfyPoller. Parses messages with a /figment prefix (e.g., /figment trigger, /figment intensity 0.8). This is separate from the /effects figment on C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API.

Integration

The FigmentEffect accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone.

EventBus Bridge

A new FIGMENT_TRIGGER variant is added to the EventType enum in engine/events.py, with a corresponding FigmentTriggerEvent dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync).

Section 5: Scroll Loop Integration

Minimal change to engine/scroll.py:

# In stream() setup (with safe import):
try:
    from effects_plugins.figment import FigmentEffect
    _plugin = registry.get("figment")
    figment = _plugin if isinstance(_plugin, FigmentEffect) else None
except ImportError:
    figment = None

# In frame loop, after effects processing, before ntfy message overlay:
if figment and figment.config.enabled:
    figment_state = figment.get_figment_state(frame_number)
    if figment_state is not None:
        figment_overlay = render_figment_overlay(figment_state, w, h)
        buf.extend(figment_overlay)

Overlay Priority

Figment overlay appends after effects processing but before the ntfy message overlay. This means:

  • Ntfy messages always appear on top of figments (higher priority)
  • Existing glitch/noise effects run over the ticker underneath the figment

Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering.

Section 6: Error Handling

Scenario Behavior
cairosvg not installed FigmentEffect fails to import; discover_plugins() silently skips it; scroll.py import guard sets figment = None
figments/ directory missing Plugin logs warning at startup, stays in permanent idle state
figments/ contains zero .svg files Same as above: warning, permanent idle
Malformed SVG cairosvg raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent idle with warning
Terminal resize during animation Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update

Section 7: File Summary

New Files

File Purpose
effects_plugins/figment.py FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase
engine/figment_render.py SVG to half-block rasterization pipeline
engine/figment_trigger.py FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter
figments/ SVG source directory (ships with sample SVGs)
tests/test_figment.py FigmentEffect lifecycle, state machine transitions, timer
tests/test_figment_render.py SVG rasterization, caching, edge cases
tests/test_figment_trigger.py FigmentCommand parsing, NtfyTrigger adapter
tests/fixtures/test.svg Minimal SVG for deterministic rasterization tests

Modified Files

File Change
engine/scroll.py Figment overlay integration (setup + per-frame block)
engine/layers.py Add render_figment_overlay() function
engine/events.py Add FIGMENT_TRIGGER to EventType enum, add FigmentTriggerEvent dataclass
pyproject.toml Add cairosvg as optional dependency

Testing Strategy

  • Unit: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage
  • Integration: Plugin discovery (verify FigmentEffect is found by discover_plugins()), overlay rendering with mock terminal dimensions, C&C command handling via /effects figment on
  • Edge cases: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation
  • Fixture: Minimal test.svg (simple rectangle) for deterministic rasterization tests