From 2cc8dbfc025ef7e76dbfa5b73b76a17353e7c4d8 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Thu, 19 Mar 2026 00:36:19 -0700 Subject: [PATCH] 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 --- .../specs/2026-03-19-figment-mode-design.md | 139 ++++++++++++++---- 1 file changed, 111 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/specs/2026-03-19-figment-mode-design.md b/docs/superpowers/specs/2026-03-19-figment-mode-design.md index d75c346..c6bd3f9 100644 --- a/docs/superpowers/specs/2026-03-19-figment-mode-design.md +++ b/docs/superpowers/specs/2026-03-19-figment-mode-design.md @@ -8,10 +8,27 @@ Figment mode displays a randomly selected SVG from the `figments/` directory as 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 ``` @@ -25,7 +42,7 @@ The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, | FigmentCommand v +------------------+ +-----------------+ +----------------------+ -| figment_render |<---| FigmentPlugin |--->| render_figment_ | +| figment_render |<---| FigmentEffect |--->| render_figment_ | | .py | | (EffectPlugin) | | overlay() in | | | | | | layers.py | | SVG -> PIL -> | | Timer, state | | | @@ -37,7 +54,6 @@ The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, v +-------------------+ | scroll.py | - | (5-line block) | +-------------------+ ``` @@ -57,7 +73,7 @@ Reuses the same PIL-based half-block encoding that `engine/render.py` uses for O ### Dependency -`cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). Figment mode gracefully degrades if unavailable. +`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 @@ -72,6 +88,21 @@ def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]: New function following the `render_message_overlay()` pattern. +### FigmentState Dataclass + +Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer: + +```python +@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 ```python @@ -81,11 +112,13 @@ def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[ ### 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. | -| **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. | -| **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. | +| **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 @@ -95,15 +128,19 @@ A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Appli 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: FigmentPlugin (Effect Plugin) +## Section 3: FigmentEffect (Effect Plugin) **File: `effects_plugins/figment.py`** -An `EffectPlugin(ABC)` subclass that owns the figment lifecycle. +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 frames. At the configured interval (default 60s), triggers a new figment. +- **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. @@ -120,7 +157,7 @@ dissolve ──(progress >= 1.0)──> idle ### Interface -The `process()` method is a no-op (returns buffer unchanged) since rendering is handled by the overlay. The plugin exposes state via: +The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via: ```python def get_figment_state(self, frame_number: int) -> FigmentState | None: @@ -129,6 +166,19 @@ def get_figment_state(self, frame_number: int) -> FigmentState | None: 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: + +```python +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) @@ -154,12 +204,21 @@ class FigmentTrigger(Protocol): ### FigmentCommand ```python +class FigmentAction(Enum): + TRIGGER = "trigger" + SET_INTENSITY = "set_intensity" + SET_INTERVAL = "set_interval" + SET_COLOR = "set_color" + STOP = "stop" + @dataclass class FigmentCommand: - action: str # "trigger" | "set_intensity" | "set_interval" | "set_color" | "stop" + action: FigmentAction value: float | str | None = None ``` +Uses an enum for consistency with `EventType` in `engine/events.py`. + ### Adapters | Adapter | Transport | Dependency | Status | @@ -168,25 +227,32 @@ class FigmentCommand: | `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 `FigmentPlugin` 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. +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 -Any trigger can publish a `FIGMENT_TRIGGER` event to the EventBus, allowing other components to react (logging, multi-display sync). +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` — 5 lines following the ntfy overlay pattern: +Minimal change to `engine/scroll.py`: ```python -# In stream() setup: -figment_plugin = registry.get("figment") +# 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 display.show(): -if figment_plugin and figment_plugin.config.enabled: - figment_state = figment_plugin.get_figment_state(frame_number) +# 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) @@ -198,28 +264,45 @@ Figment overlay appends **after** effects processing but **before** the ntfy mes - Ntfy messages always appear on top of figments (higher priority) - Existing glitch/noise effects run over the ticker underneath the figment -## Section 6: File Summary +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` | FigmentPlugin — lifecycle, timer, state machine, SVG selection | +| `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, FigmentCommand, NtfyTrigger adapter | -| `tests/test_figment.py` | Plugin lifecycle, state machine, timer, overlay rendering | -| `tests/fixtures/test.svg` | Minimal SVG for testing rasterization | +| `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` | 5-line figment overlay integration | +| `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, timer accuracy, SVG rasterization output, FigmentCommand parsing -- **Integration**: Plugin discovery, overlay rendering with mock terminal dimensions, C&C command handling -- **Fixture**: Minimal `test.svg` (simple shape) for deterministic rasterization tests +- **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