diff --git a/examples/marks.tsx b/examples/marks.tsx
new file mode 100644
index 00000000..d9341752
--- /dev/null
+++ b/examples/marks.tsx
@@ -0,0 +1,58 @@
+import { createRoot, CanvasPathCommand } from "@gpuix/react"
+import { OrganicBody, VectorEyes, DecorativeStroke, VectorPath } from "@gpuix/react/src/components/marks.js"
+import { GpuixRenderer } from "@gpuix/native"
+
+const root = createRoot(new GpuixRenderer())
+
+const bodyPath: CanvasPathCommand[] = [
+ { type: "move", x: 100, y: 50 },
+ { type: "curve", x1: 150, y1: 50, x2: 150, y2: 150, x: 100, y: 150 },
+ { type: "curve", x1: 50, y1: 150, x2: 50, y2: 50, x: 100, y: 50 },
+ { type: "close" }
+]
+
+const eyePath: CanvasPathCommand[] = [
+ { type: "move", x: 80, y: 90 },
+ { type: "line", x: 90, y: 90 },
+ { type: "move", x: 110, y: 90 },
+ { type: "line", x: 120, y: 90 }
+]
+
+const accentPath: CanvasPathCommand[] = [
+ { type: "move", x: 60, y: 60 },
+ { type: "curve", x1: 70, y1: 40, x2: 130, y2: 40, x: 140, y: 60 }
+]
+
+root.render(
+
+
Living Vector Marks
+
+
+
+)
diff --git a/packages/native/src/custom_elements/canvas.rs b/packages/native/src/custom_elements/canvas.rs
new file mode 100644
index 00000000..b00c359f
--- /dev/null
+++ b/packages/native/src/custom_elements/canvas.rs
@@ -0,0 +1,228 @@
+use super::{CustomElement, CustomElementFactory, CustomRenderContext};
+use serde::Deserialize;
+use web_time::Instant;
+
+pub struct CanvasFactory;
+
+impl CustomElementFactory for CanvasFactory {
+ fn element_type(&self) -> &str {
+ "canvas"
+ }
+
+ fn create(&self, _id: u64) -> Box {
+ Box::new(CanvasElement {
+ shapes: Vec::new(),
+ start_time: Instant::now(),
+ })
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct PathCommand {
+ #[serde(rename = "type")]
+ pub cmd_type: String,
+ pub x: Option,
+ pub y: Option,
+ pub x1: Option,
+ pub y1: Option,
+ pub x2: Option,
+ pub y2: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct CanvasShape {
+ pub shape_type: Option,
+ pub path: Option>,
+ pub fill: Option,
+ pub stroke: Option,
+ pub stroke_width: Option,
+ pub squash: Option,
+ pub breathe_loop: Option,
+ pub glance_x: Option,
+ pub glance_y: Option,
+ pub blink: Option,
+ pub blink_loop: Option,
+ pub wiggle: Option,
+ pub wiggle_loop: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct CanvasElement {
+ shapes: Vec,
+ start_time: Instant,
+}
+
+impl CustomElement for CanvasElement {
+ fn render(
+ &mut self,
+ ctx: CustomRenderContext,
+ window: &mut gpui::Window,
+ _cx: &mut gpui::Context,
+ ) -> gpui::AnyElement {
+ use gpui::prelude::*;
+
+ let shapes = self.shapes.clone();
+ let mut has_animation = false;
+ for shape in &shapes {
+ if shape.breathe_loop.is_some() || shape.blink_loop.is_some() || shape.wiggle_loop.is_some() {
+ has_animation = true;
+ }
+ }
+
+ if has_animation {
+ window.request_animation_frame();
+ }
+
+ let element_id = gpui::SharedString::from(format!("__gpuix_canvas_{}", ctx.id));
+ let start_time = self.start_time;
+
+ let el = gpui::canvas(move |bounds, window| {
+ let now = window.cx().now();
+ let elapsed = now.duration_since(start_time).as_secs_f64();
+
+ for shape in &shapes {
+ let Some(cmds) = &shape.path else { continue };
+
+ let mut fill = None;
+ if let Some(f) = &shape.fill {
+ if let Some(c) = crate::color::parse_color_rgba(f) {
+ fill = Some(c);
+ }
+ }
+
+ let mut stroke = None;
+ if let Some(s) = &shape.stroke {
+ if let Some(c) = crate::color::parse_color_rgba(s) {
+ stroke = Some(c);
+ }
+ }
+
+ let stroke_width = shape.stroke_width.unwrap_or(1.0);
+
+ let mut bounds_min_x = f32::MAX;
+ let mut bounds_max_x = f32::MIN;
+ let mut bounds_min_y = f32::MAX;
+ let mut bounds_max_y = f32::MIN;
+
+ for cmd in cmds {
+ if let Some(x) = cmd.x {
+ bounds_min_x = bounds_min_x.min(x);
+ bounds_max_x = bounds_max_x.max(x);
+ }
+ if let Some(y) = cmd.y {
+ bounds_min_y = bounds_min_y.min(y);
+ bounds_max_y = bounds_max_y.max(y);
+ }
+ }
+
+ let cx = (bounds_min_x + bounds_max_x) / 2.0;
+ let cy = (bounds_min_y + bounds_max_y) / 2.0;
+
+ let mut scale_x = 1.0;
+ let mut scale_y = 1.0;
+ let mut trans_x = bounds.origin.x.0;
+ let mut trans_y = bounds.origin.y.0;
+
+ if let Some(squash) = shape.squash {
+ let mut s = squash;
+ if let Some(speed) = shape.breathe_loop {
+ s *= (elapsed * speed as f64).sin() as f32;
+ }
+ scale_x += s;
+ scale_y -= s;
+ }
+
+ if let Some(blink) = shape.blink {
+ let mut b = blink;
+ if let Some(speed) = shape.blink_loop {
+ b *= ((elapsed * speed as f64).sin() as f32).max(0.0);
+ }
+ scale_y *= (1.0 - b).max(0.1);
+ }
+
+ if let Some(glance_x) = shape.glance_x {
+ trans_x += glance_x;
+ }
+ if let Some(glance_y) = shape.glance_y {
+ trans_y += glance_y;
+ }
+
+ let wiggle_amp = shape.wiggle.unwrap_or(0.0);
+ let wiggle_speed = shape.wiggle_loop.unwrap_or(0.0) as f64;
+
+ let tx = |x: f32, y: f32| -> gpui::Point {
+ let mut nx = (x - cx) * scale_x + cx;
+ let mut ny = (y - cy) * scale_y + cy;
+ if wiggle_amp > 0.0 && wiggle_speed > 0.0 {
+ nx += ((elapsed * wiggle_speed + y as f64 * 0.1).sin() as f32) * wiggle_amp;
+ ny += ((elapsed * wiggle_speed + x as f64 * 0.1).cos() as f32) * wiggle_amp;
+ }
+ gpui::point(gpui::px(nx + trans_x), gpui::px(ny + trans_y))
+ };
+
+ let mut draw_path = |is_stroke: bool| {
+ let mut builder = if is_stroke {
+ gpui::PathBuilder::stroke(gpui::px(stroke_width))
+ } else {
+ gpui::PathBuilder::fill()
+ };
+
+ for cmd in cmds {
+ match cmd.cmd_type.as_str() {
+ "move" => builder.move_to(tx(cmd.x.unwrap_or(0.0), cmd.y.unwrap_or(0.0))),
+ "line" => builder.line_to(tx(cmd.x.unwrap_or(0.0), cmd.y.unwrap_or(0.0))),
+ "curve" => {
+ let ctrl1 = tx(cmd.x1.unwrap_or(0.0), cmd.y1.unwrap_or(0.0));
+ let ctrl2 = tx(cmd.x2.unwrap_or(0.0), cmd.y2.unwrap_or(0.0));
+ let to = tx(cmd.x.unwrap_or(0.0), cmd.y.unwrap_or(0.0));
+ builder.cubic_bezier_to(to, ctrl1, ctrl2);
+ }
+ "close" => builder.close(),
+ _ => {}
+ }
+ }
+
+ builder.build()
+ };
+
+ if let Some(f) = fill {
+ if let Ok(path) = draw_path(false) {
+ window.paint_path(path, f);
+ }
+ }
+ if let Some(s) = stroke {
+ if let Ok(path) = draw_path(true) {
+ window.paint_path(path, s);
+ }
+ }
+ }
+ }).size_full();
+
+ let mut div = gpui::div().id(element_id).size_full().child(el);
+ if let Some(style) = ctx.style {
+ div = crate::renderer::apply_interactive_styles(div, style);
+ }
+ let div = super::wire_standard_events(div, &ctx);
+ crate::automation::track_own_bounds(div, ctx.id).into_any_element()
+ }
+
+ fn set_prop(&mut self, key: &str, value: serde_json::Value) {
+ if key == "shapes" {
+ if let Ok(shapes) = serde_json::from_value::>(value) {
+ self.shapes = shapes;
+ }
+ }
+ }
+
+ fn supported_props(&self) -> &'static [&'static str] {
+ &["shapes"]
+ }
+
+ fn supported_events(&self) -> &'static [&'static str] {
+ &["click", "mouseEnter", "mouseLeave"]
+ }
+
+ fn destroy(&mut self) {}
+}
diff --git a/packages/native/src/custom_elements/mod.rs b/packages/native/src/custom_elements/mod.rs
index b3811c10..f51a5548 100644
--- a/packages/native/src/custom_elements/mod.rs
+++ b/packages/native/src/custom_elements/mod.rs
@@ -19,6 +19,7 @@ pub mod diff;
pub mod img;
pub mod input;
pub mod markdown;
+pub mod canvas;
// ── Render context ───────────────────────────────────────────────────
@@ -285,6 +286,7 @@ impl CustomElementRegistry {
registry.register(Box::new(code::CodeFactory));
registry.register(Box::new(diff::DiffFactory));
registry.register(Box::new(markdown::MarkdownFactory));
+ registry.register(Box::new(canvas::CanvasFactory));
registry
}
diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs
index eb719b80..ec93b2bb 100644
--- a/packages/native/src/motion.rs
+++ b/packages/native/src/motion.rs
@@ -1,4 +1,5 @@
//! Native motion tracks resolved during GPUI rendering, outside React.
+//! Tween (duration/ease) plus spring (stiffness/damping/mass/velocity) integrators.
use std::time::Duration;
@@ -38,6 +39,33 @@ impl MotionStyle {
}
}
+ fn channels(self) -> [( &'static str, Option); 8] {
+ [
+ ("width", self.width),
+ ("height", self.height),
+ ("opacity", self.opacity),
+ ("top", self.top),
+ ("right", self.right),
+ ("bottom", self.bottom),
+ ("left", self.left),
+ ("borderRadius", self.border_radius),
+ ]
+ }
+
+ fn set(&mut self, name: &str, value: f64) {
+ match name {
+ "width" => self.width = Some(value),
+ "height" => self.height = Some(value),
+ "opacity" => self.opacity = Some(value),
+ "top" => self.top = Some(value),
+ "right" => self.right = Some(value),
+ "bottom" => self.bottom = Some(value),
+ "left" => self.left = Some(value),
+ "borderRadius" => self.border_radius = Some(value),
+ _ => {}
+ }
+ }
+
pub(crate) fn apply_to(self, style: &mut StyleDesc) {
if let Some(value) = self.width {
style.width = Some(DimensionValue::Pixels(value));
@@ -82,7 +110,7 @@ enum MotionEase {
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
-struct MotionTransition {
+struct TweenTransition {
#[serde(default = "default_duration")]
duration: f64,
#[serde(default)]
@@ -91,13 +119,37 @@ struct MotionTransition {
ease: MotionEase,
}
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct SpringTransition {
+ #[serde(rename = "type")]
+ kind: String,
+ #[serde(default = "default_stiffness")]
+ stiffness: f64,
+ #[serde(default = "default_damping")]
+ damping: f64,
+ #[serde(default = "default_mass")]
+ mass: f64,
+ #[serde(default)]
+ velocity: f64,
+ #[serde(default)]
+ delay: f64,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(untagged)]
+enum MotionTransition {
+ Spring(SpringTransition),
+ Tween(TweenTransition),
+}
+
impl Default for MotionTransition {
fn default() -> Self {
- Self {
+ Self::Tween(TweenTransition {
duration: default_duration(),
delay: 0.0,
ease: default_ease(),
- }
+ })
}
}
@@ -109,6 +161,18 @@ fn default_ease() -> MotionEase {
MotionEase::Name("easeOut".to_string())
}
+fn default_stiffness() -> f64 {
+ 36.0
+}
+
+fn default_damping() -> f64 {
+ 8.0
+}
+
+fn default_mass() -> f64 {
+ 1.2
+}
+
#[derive(Clone, Debug, Deserialize, PartialEq)]
struct MotionDescription {
#[serde(default)]
@@ -124,15 +188,35 @@ pub(crate) struct MotionFrame {
pub active: bool,
}
+#[derive(Clone, Copy, Debug, Default)]
+struct SpringTrack {
+ pos: f64,
+ vel: f64,
+}
+
pub(crate) struct MotionState {
source: serde_json::Value,
from: MotionStyle,
target: MotionStyle,
+ current: MotionStyle,
transition: MotionTransition,
started: Instant,
+ last: Instant,
+ springs: [SpringTrack; 8],
valid: bool,
}
+const CHANNELS: [&str; 8] = [
+ "width",
+ "height",
+ "opacity",
+ "top",
+ "right",
+ "bottom",
+ "left",
+ "borderRadius",
+];
+
impl MotionState {
pub(crate) fn new(source: &serde_json::Value, now: Instant) -> Result {
let description = parse_description(source)?;
@@ -141,13 +225,16 @@ impl MotionState {
Some(MotionInitial::Disabled(false)) | None => description.animate,
Some(MotionInitial::Disabled(true)) => unreachable!("validated above"),
};
-
+ let kick = spring_kick(&description.transition);
Ok(Self {
source: source.clone(),
from,
target: description.animate,
+ current: from,
transition: description.transition,
started: now,
+ last: now,
+ springs: seed_springs(from, description.animate, kick),
valid: true,
})
}
@@ -157,8 +244,11 @@ impl MotionState {
source: source.clone(),
from: MotionStyle::default(),
target: MotionStyle::default(),
+ current: MotionStyle::default(),
transition: MotionTransition::default(),
started: now,
+ last: now,
+ springs: [SpringTrack::default(); 8],
valid: false,
}
}
@@ -180,7 +270,7 @@ impl MotionState {
return Err(error);
}
};
- self.from = if self.valid {
+ let visible = if self.valid {
self.frame(now).style
} else {
match description.initial {
@@ -189,17 +279,40 @@ impl MotionState {
Some(MotionInitial::Disabled(true)) => unreachable!("validated above"),
}
};
+ self.from = visible;
+ self.current = visible;
self.target = description.animate;
+ let kick = spring_kick(&description.transition);
+ if matches!(description.transition, MotionTransition::Spring(_)) {
+ // Keep velocity; retarget in place so overshoot carries.
+ for (index, name) in CHANNELS.iter().enumerate() {
+ let pos = channel(visible, name).unwrap_or_else(|| channel(description.animate, name).unwrap_or(0.0));
+ self.springs[index].pos = pos;
+ if self.springs[index].vel.abs() < f64::EPSILON {
+ self.springs[index].vel = kick;
+ }
+ }
+ } else {
+ self.springs = seed_springs(visible, description.animate, kick);
+ }
self.transition = description.transition;
self.started = now;
+ self.last = now;
self.source = source.clone();
self.valid = true;
Ok(())
}
- pub(crate) fn frame(&self, now: Instant) -> MotionFrame {
- let delay = seconds(self.transition.delay);
- let duration = seconds(self.transition.duration);
+ pub(crate) fn frame(&mut self, now: Instant) -> MotionFrame {
+ match &self.transition {
+ MotionTransition::Spring(spring) => self.spring_frame(now, spring.clone()),
+ MotionTransition::Tween(tween) => self.tween_frame(now, tween.clone()),
+ }
+ }
+
+ fn tween_frame(&self, now: Instant, tween: TweenTransition) -> MotionFrame {
+ let delay = seconds(tween.delay);
+ let duration = seconds(tween.duration);
let elapsed = now.saturating_duration_since(self.started);
let raw = if elapsed <= delay {
0.0
@@ -209,13 +322,103 @@ impl MotionState {
elapsed.saturating_sub(delay).as_secs_f64() / duration.as_secs_f64()
};
let active = self.from != self.target && raw < 1.0;
- let progress = ease(raw.clamp(0.0, 1.0), &self.transition.ease);
-
+ let progress = ease(raw.clamp(0.0, 1.0), &tween.ease);
MotionFrame {
style: self.from.interpolate(self.target, progress),
active,
}
}
+
+ fn spring_frame(&mut self, now: Instant, spring: SpringTransition) -> MotionFrame {
+ let delay = seconds(spring.delay);
+ if now.saturating_duration_since(self.started) < delay {
+ self.last = now;
+ return MotionFrame {
+ style: self.current,
+ active: self.from != self.target,
+ };
+ }
+ let mut dt = now.saturating_duration_since(self.last).as_secs_f64();
+ self.last = now;
+ if dt <= 0.0 {
+ return MotionFrame {
+ style: self.current,
+ active: !settled(&self.springs, self.target),
+ };
+ }
+ dt = dt.min(0.032);
+ let mut style = self.current;
+ let mut active = false;
+ for (index, name) in CHANNELS.iter().enumerate() {
+ let Some(target) = channel(self.target, name) else {
+ continue;
+ };
+ let rest = if *name == "opacity" { 0.002 } else { 0.05 };
+ let next = step_spring(self.springs[index], target, dt, spring.stiffness, spring.damping, spring.mass, rest);
+ self.springs[index] = next;
+ style.set(name, next.pos);
+ if (next.pos - target).abs() > rest || next.vel.abs() > rest {
+ active = true;
+ }
+ }
+ self.current = style;
+ MotionFrame { style, active }
+ }
+}
+
+fn channel(style: MotionStyle, name: &str) -> Option {
+ match name {
+ "width" => style.width,
+ "height" => style.height,
+ "opacity" => style.opacity,
+ "top" => style.top,
+ "right" => style.right,
+ "bottom" => style.bottom,
+ "left" => style.left,
+ "borderRadius" => style.border_radius,
+ _ => None,
+ }
+}
+
+fn spring_kick(transition: &MotionTransition) -> f64 {
+ match transition {
+ MotionTransition::Spring(spring) => spring.velocity,
+ MotionTransition::Tween(_) => 0.0,
+ }
+}
+
+fn seed_springs(from: MotionStyle, target: MotionStyle, kick: f64) -> [SpringTrack; 8] {
+ let mut tracks = [SpringTrack::default(); 8];
+ for (index, name) in CHANNELS.iter().enumerate() {
+ let pos = channel(from, name).or_else(|| channel(target, name)).unwrap_or(0.0);
+ tracks[index] = SpringTrack { pos, vel: kick };
+ }
+ tracks
+}
+
+fn settled(tracks: &[SpringTrack; 8], target: MotionStyle) -> bool {
+ for (index, name) in CHANNELS.iter().enumerate() {
+ let Some(to) = channel(target, name) else {
+ continue;
+ };
+ if (tracks[index].pos - to).abs() > 0.05 || tracks[index].vel.abs() > 0.05 {
+ return false;
+ }
+ }
+ true
+}
+
+fn step_spring(track: SpringTrack, target: f64, dt: f64, stiffness: f64, damping: f64, mass: f64, rest: f64) -> SpringTrack {
+ let mass = mass.max(0.001);
+ let x = track.pos - target;
+ let accel = (-stiffness * x - damping * track.vel) / mass;
+ let vel = track.vel + accel * dt;
+ let pos = track.pos + vel * dt;
+ if (pos - target).abs() < rest && vel.abs() < rest {
+ SpringTrack { pos: target, vel: 0.0 }
+ } else {
+ SpringTrack { pos, vel }
+ }
}
fn parse_description(source: &serde_json::Value) -> Result {
@@ -229,23 +432,30 @@ fn parse_description(source: &serde_json::Value) -> Result {
+ validate_seconds(tween.duration, "duration")?;
+ validate_seconds(tween.delay, "delay")?;
+ validate_ease(&tween.ease)?;
+ }
+ MotionTransition::Spring(spring) => {
+ if spring.kind != "spring" {
+ return Err(format!("unknown motion type: {}", spring.kind));
+ }
+ validate_positive(spring.stiffness, "stiffness")?;
+ validate_positive(spring.damping, "damping")?;
+ validate_positive(spring.mass, "mass")?;
+ validate_seconds(spring.delay, "delay")?;
+ if !spring.velocity.is_finite() {
+ return Err("motion velocity must be finite".to_string());
+ }
+ }
+ }
Ok(description)
}
fn validate_style(style: &MotionStyle) -> Result<(), String> {
- for (name, value) in [
- ("width", style.width),
- ("height", style.height),
- ("opacity", style.opacity),
- ("top", style.top),
- ("right", style.right),
- ("bottom", style.bottom),
- ("left", style.left),
- ("borderRadius", style.border_radius),
- ] {
+ for (name, value) in style.channels() {
if value.is_some_and(|value| !value.is_finite() || value.abs() > f32::MAX as f64) {
return Err(format!("motion {name} must fit a finite 32-bit float"));
}
@@ -274,6 +484,13 @@ fn validate_seconds(value: f64, name: &str) -> Result<(), String> {
Ok(())
}
+fn validate_positive(value: f64, name: &str) -> Result<(), String> {
+ if !value.is_finite() || value <= 0.0 {
+ return Err(format!("motion {name} must be a finite number greater than 0"));
+ }
+ Ok(())
+}
+
fn validate_ease(ease: &MotionEase) -> Result<(), String> {
match ease {
MotionEase::Name(name)
@@ -406,10 +623,32 @@ mod tests {
"animate": { "width": 100.0 },
"transition": { "duration": 0.2, "ease": "linear" }
});
- let state = MotionState::new(&description, started).unwrap();
+ let mut state = MotionState::new(&description, started).unwrap();
let frame = state.frame(started + Duration::from_millis(200));
assert_eq!(frame.style.width, Some(100.0));
assert!(!frame.active);
}
+
+ #[test]
+ fn spring_overshoots_then_settles() {
+ let started = Instant::now();
+ let description = serde_json::json!({
+ "initial": { "width": 0.0 },
+ "animate": { "width": 100.0 },
+ "transition": { "type": "spring", "stiffness": 40.0, "damping": 6.0, "mass": 1.0 }
+ });
+ let mut state = MotionState::new(&description, started).unwrap();
+ let mut max_width = 0.0;
+ let mut now = started;
+ for _ in 0..120 {
+ now += Duration::from_millis(8);
+ let frame = state.frame(now);
+ max_width = max_width.max(frame.style.width.unwrap_or(0.0));
+ }
+ assert!(max_width > 100.0, "gelatinous spring must overshoot, got {max_width}");
+ let settled = state.frame(now);
+ assert!((settled.style.width.unwrap_or(0.0) - 100.0).abs() < 1.0);
+ assert!(!settled.active);
+ }
}
diff --git a/packages/react/src/__tests__/marks.test.tsx b/packages/react/src/__tests__/marks.test.tsx
new file mode 100644
index 00000000..f5d64630
--- /dev/null
+++ b/packages/react/src/__tests__/marks.test.tsx
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, test } from "vitest"
+import { createTestRoot } from "../testing.js"
+import { OrganicBody, VectorEyes, DecorativeStroke, VectorPath, Canvas } from "../components/marks.js"
+import { CanvasPathCommand } from "../components/marks.js"
+
+describe("Living Vector Marks", () => {
+ const root = createTestRoot()
+ afterEach(() => root.unmount())
+
+ test("renders VectorPath and OrganicBody to the native tree", () => {
+ const path: CanvasPathCommand[] = [
+ { type: "move", x: 0, y: 0 },
+ { type: "line", x: 10, y: 10 }
+ ]
+
+ root.render(
+
+
+
+
+ )
+
+ const canvasElements = root.findType("canvas")
+ expect(canvasElements.length).toBe(2)
+
+ // First canvas
+ const canvas1 = canvasElements[0]
+ expect(canvas1.props.shapes).toBeDefined()
+ expect(canvas1.props.shapes[0].type).toBe("path")
+ expect(canvas1.props.shapes[0].fill).toBe("#ff0000")
+
+ // Second canvas
+ const canvas2 = canvasElements[1]
+ expect(canvas2.props.shapes[0].type).toBe("body")
+ expect(canvas2.props.shapes[0].squash).toBe(0.5)
+ expect(canvas2.props.shapes[0].breatheLoop).toBe(1.0)
+ })
+
+ test("renders Eyes and Stroke marks", () => {
+ const path: CanvasPathCommand[] = [{ type: "move", x: 0, y: 0 }]
+
+ root.render(
+
+
+
+
+ )
+
+ const canvasElements = root.findType("canvas")
+ expect(canvasElements.length).toBe(2)
+
+ expect(canvasElements[0].props.shapes[0].type).toBe("eyes")
+ expect(canvasElements[0].props.shapes[0].glanceX).toBe(5)
+ expect(canvasElements[0].props.shapes[0].blinkLoop).toBe(2.0)
+
+ expect(canvasElements[1].props.shapes[0].type).toBe("stroke")
+ expect(canvasElements[1].props.shapes[0].wiggle).toBe(3)
+ expect(canvasElements[1].props.shapes[0].wiggleLoop).toBe(1.5)
+ })
+})
diff --git a/packages/react/src/__tests__/motion-spring.test.ts b/packages/react/src/__tests__/motion-spring.test.ts
new file mode 100644
index 00000000..70497f42
--- /dev/null
+++ b/packages/react/src/__tests__/motion-spring.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest"
+import { GELATIN, stepSpring, type SpringTrack } from "../motion-spring.js"
+
+describe("stepSpring", () => {
+ it("snaps to rest when inside the rest window", () => {
+ const next = stepSpring({ pos: 100.02, vel: 0.01 }, 100, 1 / 60, 28, 8, 1.25)
+ expect(next).toEqual({ pos: 100, vel: 0 })
+ })
+
+ it("overshoots with GELATIN then settles on the target", () => {
+ let track: SpringTrack = { pos: 0, vel: 0 }
+ let max = 0
+ for (let i = 0; i < 240; i++) {
+ track = stepSpring(
+ track,
+ 100,
+ 1 / 60,
+ GELATIN.stiffness,
+ GELATIN.damping,
+ GELATIN.mass
+ )
+ max = Math.max(max, track.pos)
+ }
+ expect(max).toBeGreaterThan(100)
+ expect(track.pos).toBe(100)
+ expect(track.vel).toBe(0)
+ })
+})
diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts
index 548e7ea5..513e3b25 100644
--- a/packages/react/src/components/index.ts
+++ b/packages/react/src/components/index.ts
@@ -1,8 +1,16 @@
// GPUIX component definitions and native motion wrappers.
-import { createElement, forwardRef } from "react"
+import { createElement, forwardRef, useEffect, useRef, useState } from "react"
import type { ReactElement, ReactNode } from "react"
-import type { MotionProps, Props, PublicInstance, StyleDesc } from "../types/host.js"
+import type {
+ MotionProps,
+ MotionSpringTransition,
+ MotionStyle,
+ Props,
+ PublicInstance,
+ StyleDesc,
+} from "../types/host.js"
+import { GELATIN, onFrame, stepSpring, type SpringTrack } from "../motion-spring.js"
export const gpuixComponents = {
div: "div",
@@ -36,13 +44,99 @@ export interface MotionDivProps extends MotionProps {
autoFocus?: boolean
}
+const SPRING_KEYS = [
+ "width",
+ "height",
+ "opacity",
+ "top",
+ "right",
+ "bottom",
+ "left",
+ "borderRadius",
+] as const
+
+type SpringKey = (typeof SPRING_KEYS)[number]
+
+function isSpringTransition(
+ transition: MotionProps["transition"]
+): transition is MotionSpringTransition {
+ return transition != null && transition.type === "spring"
+}
+
+function readStyle(style: MotionStyle | false | undefined, key: SpringKey): number | undefined {
+ if (style == null || style === false) return undefined
+ return style[key]
+}
+
const MotionDiv = forwardRef(function MotionDiv(
- { initial, animate, transition, ...props },
+ { initial, animate, transition, style, ...props },
ref
): ReactElement {
+ const spring = isSpringTransition(transition)
+ const animateRef = useRef(animate)
+ animateRef.current = animate
+ const [current, setCurrent] = useState(() => {
+ const seed: MotionStyle = {}
+ for (const key of SPRING_KEYS) {
+ const value = readStyle(initial, key) ?? animate[key]
+ if (value != null) seed[key] = value
+ }
+ return seed
+ })
+ const tracks = useRef>>({})
+ const transitionRef = useRef(transition)
+ transitionRef.current = transition
+
+ useEffect(() => {
+ if (!spring) return
+ return onFrame((dt) => {
+ const spec = transitionRef.current
+ if (!isSpringTransition(spec)) return
+ const stiffness = spec.stiffness ?? GELATIN.stiffness
+ const damping = spec.damping ?? GELATIN.damping
+ const mass = spec.mass ?? GELATIN.mass
+ const kick = spec.velocity ?? 0
+ const target = animateRef.current
+ let changed = false
+ const next: MotionStyle = {}
+ for (const key of SPRING_KEYS) {
+ const to = target[key]
+ if (to == null) continue
+ const rest = key === "opacity" ? 0.002 : 0.05
+ let track = tracks.current[key]
+ if (!track) track = { pos: to, vel: kick }
+ const stepped = stepSpring(track, to, dt, stiffness, damping, mass, rest)
+ if (stepped.pos !== track.pos || stepped.vel !== track.vel) changed = true
+ tracks.current[key] = stepped
+ next[key] = stepped.pos
+ }
+ if (changed) setCurrent(next)
+ })
+ }, [spring])
+
+ if (spring) {
+ const hostProps: Props = {
+ ...props,
+ ref,
+ style: {
+ ...(style ?? {}),
+ ...(current.width != null ? { width: current.width } : {}),
+ ...(current.height != null ? { height: current.height } : {}),
+ ...(current.opacity != null ? { opacity: current.opacity } : {}),
+ ...(current.top != null ? { top: current.top } : {}),
+ ...(current.right != null ? { right: current.right } : {}),
+ ...(current.bottom != null ? { bottom: current.bottom } : {}),
+ ...(current.left != null ? { left: current.left } : {}),
+ ...(current.borderRadius != null ? { borderRadius: current.borderRadius } : {}),
+ },
+ }
+ return createElement("div", hostProps)
+ }
+
const hostProps: Props = {
...props,
ref,
+ style,
motion: {
...(initial === undefined ? {} : { initial }),
animate,
@@ -56,9 +150,3 @@ const MotionDiv = forwardRef(function MotionDiv(
export const motion = {
div: MotionDiv,
} as const
-
-// There is no `VirtualList` React wrapper. Windowing on the React side is the
-// app's job: pass `itemCount`, `estimatedItemHeight` and `windowStart` to the
-// host `` and render only that slice. A generic wrapper cannot
-// know when to widen its own window, so it silently dropped rows whenever
-// `itemCount` grew without a scroll.
diff --git a/packages/react/src/components/marks.tsx b/packages/react/src/components/marks.tsx
new file mode 100644
index 00000000..c42d474b
--- /dev/null
+++ b/packages/react/src/components/marks.tsx
@@ -0,0 +1,63 @@
+import { createElement, forwardRef } from "react"
+import type { PublicInstance, Props } from "../types/host.js"
+import { motion } from "./index.js"
+
+export interface CanvasPathCommand {
+ type: "move" | "line" | "curve" | "close"
+ x?: number
+ y?: number
+ x1?: number
+ y1?: number
+ x2?: number
+ y2?: number
+}
+
+export interface CanvasShape {
+ type?: "path" | "body" | "eyes" | "stroke"
+ path?: CanvasPathCommand[]
+ fill?: string
+ stroke?: string
+ strokeWidth?: number
+
+ // Body props
+ squash?: number
+ breatheLoop?: number // speed multiplier
+
+ // Eyes props
+ glanceX?: number
+ glanceY?: number
+ blink?: number
+ blinkLoop?: number
+
+ // Stroke props
+ wiggle?: number
+ wiggleLoop?: number
+}
+
+export interface CanvasProps extends Props {
+ shapes?: CanvasShape[]
+}
+
+export const Canvas = forwardRef(function Canvas(
+ props,
+ ref
+) {
+ return createElement("canvas", { ...props, ref })
+})
+
+
+export function VectorPath({ path, fill, stroke, strokeWidth }: { path: CanvasPathCommand[], fill?: string, stroke?: string, strokeWidth?: number }) {
+ return
+}
+
+export function OrganicBody({ path, fill, squash = 0, breatheLoop, stroke, strokeWidth }: { path: CanvasPathCommand[], fill?: string, stroke?: string, strokeWidth?: number, squash?: number, breatheLoop?: number }) {
+ return
+}
+
+export function VectorEyes({ path, stroke, strokeWidth, glanceX = 0, glanceY = 0, blink = 0, blinkLoop }: { path: CanvasPathCommand[], stroke: string, strokeWidth?: number, glanceX?: number, glanceY?: number, blink?: number, blinkLoop?: number }) {
+ return
+}
+
+export function DecorativeStroke({ path, stroke, strokeWidth, wiggle = 0, wiggleLoop }: { path: CanvasPathCommand[], stroke: string, strokeWidth?: number, wiggle?: number, wiggleLoop?: number }) {
+ return
+}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index b8e68ff5..1ad6aca0 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -65,7 +65,10 @@ export type {
TooltipProviderProps,
TooltipTriggerProps,
} from "./components/tooltip.js"
+export { Canvas, VectorPath, OrganicBody, VectorEyes, DecorativeStroke } from "./components/marks.js"
+export type { CanvasPathCommand, CanvasShape, CanvasProps } from "./components/marks.js"
export { motion } from "./components/index.js"
+export { onFrame, stepSpring, GELATIN } from "./motion-spring.js"
export type { Root, FrameLoop, RenderOptions } from "./reconciler/renderer.js"
export type {
WindowInsets,
@@ -89,6 +92,8 @@ export type {
MotionProps,
MotionStyle,
MotionTransition,
+ MotionSpringTransition,
+ MotionTweenTransition,
NativeRenderer,
NativeWindowInsets,
PublicInstance,
diff --git a/packages/react/src/motion-spring.ts b/packages/react/src/motion-spring.ts
new file mode 100644
index 00000000..0cb10c16
--- /dev/null
+++ b/packages/react/src/motion-spring.ts
@@ -0,0 +1,41 @@
+/** Semi-implicit Euler spring. Runs on the GPUIX frame loop, not CSS tweens. */
+
+export type SpringTrack = { pos: number; vel: number }
+
+export function stepSpring(
+ track: SpringTrack,
+ target: number,
+ dt: number,
+ stiffness: number,
+ damping: number,
+ mass: number,
+ rest = 0.05
+): SpringTrack {
+ const clamped = Math.min(Math.max(dt, 0), 0.032)
+ const m = Math.max(mass, 0.001)
+ const x = track.pos - target
+ const accel = (-stiffness * x - damping * track.vel) / m
+ const vel = track.vel + accel * clamped
+ const pos = track.pos + vel * clamped
+ if (Math.abs(pos - target) < rest && Math.abs(vel) < rest) {
+ return { pos: target, vel: 0 }
+ }
+ return { pos, vel }
+}
+
+export type FrameListener = (dt: number, now: number) => void
+
+const listeners = new Set()
+
+export function onFrame(listener: FrameListener): () => void {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+export function pumpFrames(dt: number, now: number): void {
+ for (const listener of listeners) listener(dt, now)
+}
+
+export const GELATIN = { stiffness: 28, damping: 8, mass: 1.25, velocity: 0 }
diff --git a/packages/react/src/reconciler/renderer.ts b/packages/react/src/reconciler/renderer.ts
index e46224b4..a4317905 100644
--- a/packages/react/src/reconciler/renderer.ts
+++ b/packages/react/src/reconciler/renderer.ts
@@ -3,6 +3,7 @@ import { GpuixRenderer } from "@gpuix/native"
import type { EventPayload, WindowOptions } from "@gpuix/native"
import { createRoot, flushSync, type Root } from "./reconciler.js"
import type { DebugFrameOverlayMode, NativeRenderer } from "../types/host.js"
+import { pumpFrames } from "../motion-spring.js"
import { handleGpuixEvent } from "./event-registry.js"
import {
App as AutomationApp,
@@ -96,9 +97,12 @@ export function startFrameLoop(
timer = null
}
+ let lastFrame = performance.now()
const loop = (): void => {
if (stopped) return
const started = performance.now()
+ pumpFrames(Math.min((started - lastFrame) / 1000, 0.032), started)
+ lastFrame = started
const running = renderer.tick()
if (running === false) {
stop()
diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts
index e16fe2b9..43073351 100644
--- a/packages/react/src/types/host.ts
+++ b/packages/react/src/types/host.ts
@@ -21,7 +21,8 @@ export type MotionEase =
| "easeInOut"
| [number, number, number, number]
-export interface MotionTransition {
+export interface MotionTweenTransition {
+ type?: "tween"
/** Duration in seconds. */
duration?: number
/** Delay in seconds. */
@@ -29,6 +30,18 @@ export interface MotionTransition {
ease?: MotionEase
}
+/** Critically-underdamped integrator. Defaults are gelatinous, not snappy. */
+export interface MotionSpringTransition {
+ type: "spring"
+ stiffness?: number
+ damping?: number
+ mass?: number
+ velocity?: number
+ delay?: number
+}
+
+export type MotionTransition = MotionTweenTransition | MotionSpringTransition
+
export interface MotionProps {
initial?: MotionStyle | false
animate: MotionStyle