Adding an effect
Authoring, review criteria, and submission for built-in and core effects.
Hypercolor ships two categories of effects: 11 native built-ins compiled directly into hypercolor-core, and a large library of HTML effects built from the TypeScript SDK. This page covers both contribution paths: when to choose each, how to implement it, and what the review checklist looks for.
If you want to write an effect for personal use or a community release, start with the TypeScript SDK authoring guide instead. This page is for contributors targeting main.
πWhich path is right?
| Path | Where it lives | Toolchain | When to use it |
|---|---|---|---|
| Native Rust | crates/hypercolor-core/src/effect/builtin/ | Rust | Always-available fallbacks, diagnostics, utility patterns, audio-reactive primitives that need tight timing |
| HTML / TypeScript SDK | sdk/src/effects/<name>/ | Bun + TypeScript | Visual effects, generative art, WebGL shaders, anything that leans on the browser canvas API |
The 11 native built-ins (solid_color, gradient, rainbow, breathing, audio_pulse, color_wave, color_zones, screen_cast, media_player, calibration, and web_viewport, which is Servo-only) exist because they must work even when Servo is not compiled in. New native effects should meet that same bar. Everything else belongs in the SDK.
There is no runnable wgpu/GPU shader lane in Hypercolor. EffectSource::Shader is reserved. GLSL effects run as WebGL2 inside Servoβs renderer, not as native GPU compute. Frame wgpu shaders as future work and never suggest authors target that path.
πNative Rust effects
πFile layout
Each native built-in lives in its own submodule under crates/hypercolor-core/src/effect/builtin/:
crates/hypercolor-core/src/effect/builtin/
βββ mod.rs # registration, create_builtin_renderer()
βββ common.rs # shared control + preset constructors
βββ breathing.rs # example: a complete built-in
βββ your_effect.rs # your new effect goes hereπImplement the EffectRenderer trait
Every native effect is a struct that implements EffectRenderer from crates/hypercolor-core/src/effect/traits.rs. The trait is Send but not Sync, so keep that in mind if the effect holds non-Sync state.
The two methods you must implement are init (called once on activation) and render_into (called once per frame):
use hypercolor_types::canvas::{Canvas, RgbaF32};
use hypercolor_types::effect::{
ControlDefinition, ControlValue, EffectCategory, EffectMetadata, EffectSource,
PresetTemplate,
};
use crate::effect::traits::{EffectRenderer, FrameInput, prepare_target_canvas};
use super::common::{builtin_effect_id, slider_control};
pub struct YourEffectRenderer {
speed: f32,
brightness: f32,
}
impl YourEffectRenderer {
#[must_use]
pub fn new() -> Self {
Self { speed: 1.0, brightness: 1.0 }
}
}
impl Default for YourEffectRenderer {
fn default() -> Self { Self::new() }
}
impl EffectRenderer for YourEffectRenderer {
fn init(&mut self, _metadata: &EffectMetadata) -> anyhow::Result<()> {
Ok(())
}
fn render_into(&mut self, input: &FrameInput<'_>, canvas: &mut Canvas) -> anyhow::Result<()> {
prepare_target_canvas(canvas, input.canvas_width, input.canvas_height);
// input.time_secs and input.delta_secs carry timing;
// input.audio carries audio analysis when audio_reactive = true.
// Fill canvas pixels here.
Ok(())
}
fn set_control(&mut self, name: &str, value: &ControlValue) {
match name {
"speed" => {
if let Some(v) = value.as_f32() { self.speed = v.max(0.1); }
}
"brightness" => {
if let Some(v) = value.as_f32() { self.brightness = v.clamp(0.0, 1.0); }
}
_ => {}
}
}
fn destroy(&mut self) {}
}Key constraints:
render_intoreturnsanyhow::Result, so use?, neverunwrap().- Always call
prepare_target_canvasat the top ofrender_intoso the canvas resizes correctly when the daemon config changes. Never hardcode dimensions. - Math goes in linear RGB or Oklab; final pixel values go to the canvas in sRGB. Use
RgbaF32::to_srgba()for the conversion. unsafe_codeis forbidden in application, driver, and domain crates (the only exceptions are the audited platform-interop crates). No unsafe blocks in effect code.
πWrite a metadata constructor
The registry learns about an effect through its metadata() function. Use the helpers in common.rs for controls and presets. slider_control takes (id, name, default, min, max, step, group, tooltip):
use std::path::PathBuf;
use hypercolor_types::effect::{EffectCategory, EffectMetadata, EffectSource};
use super::common::{builtin_effect_id, slider_control};
pub(super) fn metadata() -> EffectMetadata {
EffectMetadata {
id: builtin_effect_id("your_effect"),
name: "Your Effect".into(),
author: "Your Name".into(),
version: "0.1.0".into(),
description: "One-sentence description of what it does.".into(),
category: EffectCategory::Ambient,
tags: vec!["your-effect".into()],
controls: vec![
slider_control("speed", "Speed", 1.0, 0.1, 10.0, 0.1, "Motion", "Animation rate."),
slider_control("brightness", "Brightness", 1.0, 0.0, 1.0, 0.01, "Output", "Output brightness."),
],
presets: vec![],
audio_reactive: false,
screen_reactive: false,
source: EffectSource::Native {
path: PathBuf::from("builtin/your_effect"),
},
license: Some("Apache-2.0".into()),
}
}πRegister the effect
Edit crates/hypercolor-core/src/effect/builtin/mod.rs in three places:
- Declare the module:
mod your_effect;- Re-export the renderer:
pub use self::your_effect::YourEffectRenderer;- Add to
builtin_metadata()andcreate_builtin_renderer():
// in builtin_metadata():
your_effect::metadata(),
// in create_builtin_renderer():
"your_effect" => Some(Box::new(YourEffectRenderer::new())),πWrite tests
Tests go in a tests/ directory, not inline #[cfg(test)] blocks. The whole workspace follows this rule. Name the file your_effect_tests.rs and cover at minimum:
- Canvas is non-empty after
render_into(). - Every declared control ID is handled in
set_control()without panicking. render_into()returnsOkfor a zerodelta_secsframe.
πHTML / TypeScript effects
SDK effects live under sdk/src/effects/<name>/. The build pipeline compiles them to self-contained HTML files that the daemon renders via Servoβs WebGL2 context.
hypercolor is published to npm (early 0.1.x release). External contributors can scaffold a standalone workspace with bun create hypercolor β see creating effects. Inside the monorepo, effects resolve the SDK through the Bun workspace instead.
πBuild an effect in the monorepo
# Build all SDK effects -> effects/hypercolor/*.html
just effects-build
# Build a single effect by name
just effect-build <name>
# SDK dev mode: package watchers with HMR (no preview server)
just sdk-deveffects/hypercolor/ is generated build output and is gitignored. Never hand-edit those files; make changes in sdk/src/effects/ and regenerate.
πFile layout
sdk/src/effects/
βββ your-effect/
βββ main.ts # canvas() or effect() declaration
βββ fragment.glsl # GLSL-only effects; omit for canvas effectsDisplay faces (face() declarations for HUDs, clocks, and sensor readouts) live in their own tree under sdk/src/faces/<name>/ and build via just faces-build.
πCanvas effect starter
import { canvas, num, combo } from "hypercolor";
export default canvas(
"Your Effect",
{
speed: num("Speed", [1, 10], 5, { group: "Motion" }),
palette: combo("Palette", ["Aurora", "Fire", "Ocean"], { group: "Color" }),
brightness: num("Brightness", [0, 100], 80, { group: "Color" }),
},
(ctx, time, controls) => {
const { width, height } = ctx.canvas; // read every frame, never hardcode
const speed = controls.speed ?? 5;
const brightness = (controls.brightness ?? 80) / 100;
// draw to ctx here
},
{
description: "One-sentence description.",
author: "Your Name",
presets: [
{
name: "Default",
controls: { speed: 5, palette: "Aurora", brightness: 80 },
},
],
},
);πGLSL shader effect starter
GLSL effects run inside Servoβs WebGL2 renderer. Import the shader as a string via the .glsl text loader (configured in bunfig.toml):
import { effect, num } from "hypercolor";
import shader from "./fragment.glsl";
export default effect(
"Your Shader Effect",
shader,
{
speed: num("Speed", [1, 10], 5, { group: "Motion" }),
},
{ description: "Description." },
);#version 300 es
precision highp float;
out vec4 fragColor;
uniform float iTime;
uniform vec2 iResolution;
uniform float iSpeed; // maps to "speed" control
void main() {
vec2 uv = gl_FragCoord.xy / iResolution.xy;
fragColor = vec4(uv, sin(iTime * iSpeed) * 0.5 + 0.5, 1.0);
}Uniform naming: iTime, iResolution, iMouse, iAudioBass, iAudioMid, iAudioTreble, and iAudioBeatPulse are injected automatically. Per-control uniforms are the control ID prefixed with i and converted to camelCase (speed β iSpeed). See the GLSL effects guide for the full uniform catalog.
πReview checklist
The effect-reviewer agent (.agents/agents/effect-reviewer/) runs this checklist. Familiarize yourself before submitting, because the issues caught here are the ones that look fine on a monitor but fail on hardware.
πColor quality
- Saturation. Vivid areas should be 85-100% saturation. Hues in the 20-70% range look muddy on LEDs.
- Blowout. For vivid colors,
min(R,G,B) / max(R,G,B)must be below 0.3. White channels wash out LED color. - Yellow and orange. Pure yellow
(255,255,0)reads as white on most LEDs. Use gold(255,190,0)or amber(255,140,0)instead. Flag any hue in the 30-90 range for hardware testing. - HSL lightness cap. Above 60% lightness washes to white. Stay below it.
- Gradient interpolation. Interpolate in Oklab or Oklch, never raw sRGB. The SDKβs
samplePalette()does this for you. Raw sRGB midpoints desaturate visibly on hardware.
πAnimation quality
- Delta-time. Animations must use
deltaTimeorperformance.now()diffs, never fixed increments. Fixed increments produce hardware-dependent speeds. - Trail technique. Background alpha clears between 0.05 and 0.40 for motion trails. Below 0.05 ghosts; above 0.40 flickers.
- Minimum transition duration. No visible transition under 200ms. Below that threshold it reads as flicker on LEDs.
- Easing. Organic motion uses sinusoidal easing, not linear.
πAudio reactivity (if applicable)
- Beat flash anti-pattern. Beat energy should drive movement (zoom, rotation, acceleration), not raw brightness spikes.
- Exponential decay. Use exponential decay (multiplier ~0.85 per frame) for audio levels, not instant on/off.
- RMS over peak. Use
rms_levelfor overall loudness. Raw peak is too spiky on LED hardware. - Band grouping. Group frequency content into bass/mid/treble or mel bands. Raw FFT bins alias into noise.
πComposition
- Darkness is part of the design. 30-50% of LEDs should be off or dim. Fully lit rigs lose contrast and look flat.
- Color count. 1-3 coordinated colors. Rainbow-everything reads as noise on hardware even when it looks fine on a monitor.
- Spatial frequency. Patterns should span at least 10 LEDs. Below that threshold they alias to noise on most strip/matrix hardware.
πTechnical: HTML effects
- Canvas resolution. Effects must read
ctx.canvas.widthandctx.canvas.heightevery frame. Effects ported from the legacy 320x200 SDK grid must usescaleContext(ctx.canvas, { width: 320, height: 200 })fromhypercolor. The daemon renders at 640x480 by default. - Control meta tags. Every control must have
id,label,type,default, and appropriatemin/max. - Preset control IDs. JSON in
preset-controlsattributes must match actual declared control IDs. - No blocking. No synchronous operations or heavy allocations in the draw loop.
audio-reactivedeclared. Set<meta audio-reactive="true" />explicitly rather than relying on content heuristics.
πTechnical: native Rust effects
- Linear color math. Math in linear RGB or Oklab; output converted to sRGB for the canvas.
- Control dispatch.
set_control()must handle every control ID declared inmetadata(). - Canvas sizing. Use
Canvas::new(input.canvas_width, input.canvas_height)for any scratch canvas; never hardcode dimensions. Callprepare_target_canvasat the top ofrender_into. - Error handling.
render_intoreturnsanyhow::Result. Nounwrap()anywhere.
πSubmission
πFor native effects (Rust)
- Run
just verify. This covers fmt, clippy (-D warnings), and all workspace tests. The workspace convention forbids inline#[cfg(test)]blocks; usetests/<name>_tests.rs. - Run
just test-crate hypercolor-coreto confirm your tests pass in isolation. - Open a pull request against
main. Prefix the title:feat(builtin): <effect name>. Include a short description of the visual output and why it belongs as a compiled-in effect rather than an SDK HTML effect.
πFor HTML / TypeScript effects
- Build:
just effect-build your-effect. Confirm the file appears undereffects/hypercolor/your-effect.html. - Validate:
bunx hypercolor validate effects/hypercolor/your-effect.html. Fix any errors; address warnings before submitting. - Install locally and activate for a visual check:
bunx hypercolor install effects/hypercolor/your-effect.html --daemon, thenhypercolor effects activate your-effect. - Open a pull request against
main. Prefix the title:feat(effects): <effect name>. Include a GIF or screenshot of the effect running. If you used audio reactivity, note what input source you tested against.
πWhat reviewers look for
- The effect passes the full review checklist above.
- It demonstrates something not already covered by the existing catalog. Browse the effects section and the shipped SDK effects under
sdk/src/effects/before submitting to avoid near-duplicates. - Controls are meaningful and the defaults produce a visually coherent result at first launch.
- Presets are named descriptively, not just βDefault 1/2/3β.
- The effect name is title-cased and distinct from existing effects.
- Native effects include tests; HTML effects include at least one preset.
Run the effect-reviewer agent against your work before opening a PR. It catches the color science and animation issues that are hardest to see on a monitor but obvious on hardware.