@effect-tui/react 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/src/dev/Toast.d.ts.map +1 -1
  2. package/dist/src/dev/Toast.js +24 -15
  3. package/dist/src/dev/Toast.js.map +1 -1
  4. package/dist/src/dev.d.ts +20 -1
  5. package/dist/src/dev.d.ts.map +1 -1
  6. package/dist/src/dev.js +6 -3
  7. package/dist/src/dev.js.map +1 -1
  8. package/dist/src/hooks/index.d.ts +2 -0
  9. package/dist/src/hooks/index.d.ts.map +1 -1
  10. package/dist/src/hooks/index.js +1 -0
  11. package/dist/src/hooks/index.js.map +1 -1
  12. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  13. package/dist/src/hooks/use-scroll.js +4 -2
  14. package/dist/src/hooks/use-scroll.js.map +1 -1
  15. package/dist/src/hooks/use-timer.d.ts +57 -0
  16. package/dist/src/hooks/use-timer.d.ts.map +1 -0
  17. package/dist/src/hooks/use-timer.js +94 -0
  18. package/dist/src/hooks/use-timer.js.map +1 -0
  19. package/dist/src/hosts/base.d.ts +20 -0
  20. package/dist/src/hosts/base.d.ts.map +1 -1
  21. package/dist/src/hosts/base.js +104 -0
  22. package/dist/src/hosts/base.js.map +1 -1
  23. package/dist/src/hosts/box.d.ts +6 -3
  24. package/dist/src/hosts/box.d.ts.map +1 -1
  25. package/dist/src/hosts/box.js +10 -3
  26. package/dist/src/hosts/box.js.map +1 -1
  27. package/dist/src/hosts/scroll.d.ts +0 -2
  28. package/dist/src/hosts/scroll.d.ts.map +1 -1
  29. package/dist/src/hosts/scroll.js +3 -6
  30. package/dist/src/hosts/scroll.js.map +1 -1
  31. package/dist/src/hosts/text.d.ts +5 -2
  32. package/dist/src/hosts/text.d.ts.map +1 -1
  33. package/dist/src/hosts/text.js +7 -3
  34. package/dist/src/hosts/text.js.map +1 -1
  35. package/dist/src/index.d.ts +1 -1
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +1 -1
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/motion/brands.d.ts +9 -0
  40. package/dist/src/motion/brands.d.ts.map +1 -0
  41. package/dist/src/motion/brands.js +9 -0
  42. package/dist/src/motion/brands.js.map +1 -0
  43. package/dist/src/motion/color-motion-value.d.ts +4 -0
  44. package/dist/src/motion/color-motion-value.d.ts.map +1 -1
  45. package/dist/src/motion/color-motion-value.js +4 -0
  46. package/dist/src/motion/color-motion-value.js.map +1 -1
  47. package/dist/src/motion/hooks.d.ts +29 -3
  48. package/dist/src/motion/hooks.d.ts.map +1 -1
  49. package/dist/src/motion/hooks.js +40 -4
  50. package/dist/src/motion/hooks.js.map +1 -1
  51. package/dist/src/motion/index.d.ts +1 -1
  52. package/dist/src/motion/index.d.ts.map +1 -1
  53. package/dist/src/motion/index.js +1 -1
  54. package/dist/src/motion/index.js.map +1 -1
  55. package/dist/src/motion/motion-value.d.ts +6 -2
  56. package/dist/src/motion/motion-value.d.ts.map +1 -1
  57. package/dist/src/motion/motion-value.js +6 -2
  58. package/dist/src/motion/motion-value.js.map +1 -1
  59. package/dist/src/motion/use-sequence.d.ts +10 -2
  60. package/dist/src/motion/use-sequence.d.ts.map +1 -1
  61. package/dist/src/motion/use-sequence.js +101 -11
  62. package/dist/src/motion/use-sequence.js.map +1 -1
  63. package/dist/tsconfig.tsbuildinfo +1 -1
  64. package/package.json +2 -2
  65. package/src/dev/Toast.tsx +27 -17
  66. package/src/dev.tsx +19 -14
  67. package/src/hooks/index.ts +2 -0
  68. package/src/hooks/use-scroll.ts +4 -2
  69. package/src/hooks/use-timer.ts +155 -0
  70. package/src/hosts/base.ts +120 -0
  71. package/src/hosts/box.ts +17 -6
  72. package/src/hosts/scroll.ts +3 -6
  73. package/src/hosts/text.ts +13 -5
  74. package/src/index.ts +2 -0
  75. package/src/motion/brands.ts +10 -0
  76. package/src/motion/color-motion-value.ts +6 -0
  77. package/src/motion/hooks.ts +50 -4
  78. package/src/motion/index.ts +3 -0
  79. package/src/motion/motion-value.ts +8 -1
  80. package/src/motion/use-sequence.ts +113 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "React bindings for @effect-tui/core",
5
5
  "type": "module",
6
6
  "files": [
@@ -83,7 +83,7 @@
83
83
  "prepublishOnly": "bun run typecheck && bun run build"
84
84
  },
85
85
  "dependencies": {
86
- "@effect-tui/core": "^0.9.2",
86
+ "@effect-tui/core": "^0.10.0",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
package/src/dev/Toast.tsx CHANGED
@@ -84,38 +84,48 @@ export function ToastProvider({ children }: { children: ReactNode }) {
84
84
  // Screenshot Toast Animation
85
85
  // ─────────────────────────────────────────────────────────────
86
86
 
87
- // Bright flash color for initial state
88
- const FLASH_COLOR = { r: 255, g: 255, b: 220, a: 1 }
89
- // Final green success color
90
- const SUCCESS_COLOR = { r: 30, g: 70, b: 40, a: 1 }
87
+ // Flash: bright background, dark text
88
+ const FLASH_BG = { r: 220, g: 220, b: 220, a: 1 }
89
+ const FLASH_FG = { r: 40, g: 40, b: 40, a: 1 }
91
90
 
92
- function ScreenshotToast({ message }: { message: string }) {
91
+ // Settled: dark gray background, light text
92
+ const SETTLED_BG = { r: 50, g: 50, b: 55, a: 1 }
93
+ const SETTLED_FG = { r: 200, g: 200, b: 200, a: 1 }
94
+
95
+ function ScreenshotToast({ message, toastId }: { message: string; toastId: number }) {
93
96
  // Emoji sequence: 📷 → 📸 → 📷 (camera → flash → camera)
94
97
  const emoji = useSequence({
95
98
  keyframes: ["📷", "📸", "📷"],
96
99
  times: [0, 0.08, 0.2], // Flash at 8%, back at 20%
97
100
  duration: 2500,
101
+ playKey: toastId, // Replay when a new toast is created
98
102
  })
99
103
 
100
- // Background: bright flash → fade to green
101
- const [bg, setBg] = useColorSpring(FLASH_COLOR, { visualDuration: 1.2, bounce: 0 })
104
+ // Background: bright flash → fade to dark gray
105
+ // Pass MotionValue directly to box - hosts auto-subscribe!
106
+ const [bgMv, setBg] = useColorSpring(FLASH_BG, { visualDuration: 0.8, bounce: 0 })
107
+ // Foreground: dark → light (spring animated)
108
+ const [fgMv, setFg] = useColorSpring(FLASH_FG, { visualDuration: 0.8, bounce: 0 })
102
109
 
103
- // Trigger fade after flash phase
110
+ // Reset and trigger animation when toastId changes (new screenshot)
104
111
  useEffect(() => {
112
+ // Jump back to flash colors immediately (no animation)
113
+ bgMv.jump(FLASH_BG)
114
+ fgMv.jump(FLASH_FG)
115
+
116
+ // Then spring-animate to settled after flash phase
105
117
  const timer = setTimeout(() => {
106
- setBg(SUCCESS_COLOR)
107
- }, 200) // Start fade at 200ms
118
+ setBg(SETTLED_BG)
119
+ setFg(SETTLED_FG)
120
+ }, 200)
108
121
  return () => clearTimeout(timer)
109
- }, [setBg])
110
-
111
- const bgValue = bg.get()
112
- const isFlash = emoji === "📸"
122
+ }, [toastId, bgMv, fgMv, setBg, setFg])
113
123
 
114
124
  return (
115
125
  <hstack>
116
126
  <spacer />
117
- <box bg={Colors.rgb(bgValue.r, bgValue.g, bgValue.b)} padding={{ x: 1 }}>
118
- <text fg={isFlash ? Colors.rgb(40, 40, 20) : Colors.rgb(140, 230, 140)}>
127
+ <box bg={bgMv} padding={{ x: 1 }}>
128
+ <text fg={fgMv}>
119
129
  {` ${emoji} ${message} `}
120
130
  </text>
121
131
  </box>
@@ -137,7 +147,7 @@ export function ToastContainer() {
137
147
 
138
148
  // Special animated toast for screenshots
139
149
  if (toast.type === "screenshot") {
140
- return <ScreenshotToast message={toast.message} />
150
+ return <ScreenshotToast message={toast.message} toastId={toast.id} />
141
151
  }
142
152
 
143
153
  const style = TOAST_STYLES[toast.type]
package/src/dev.tsx CHANGED
@@ -49,8 +49,8 @@ import type { RendererOptions, TuiRenderer } from "./renderer-types.js"
49
49
  * }
50
50
  * ```
51
51
  */
52
- export function hmr<A>(key: string): (self: A) => A {
53
- return (self: A): A => {
52
+ export function hmr(key: string): <A,>(self: A) => A {
53
+ return <A,>(self: A): A => {
54
54
  return globalValue(Symbol.for(`hmr/${key}`), () => {
55
55
  // Apply keepAlive if it's an atom (just sets keepAlive: true)
56
56
  if (typeof self === "object" && self !== null && "keepAlive" in self) {
@@ -169,7 +169,7 @@ export function autoHmr<A>(self: A): A {
169
169
  if (!location) {
170
170
  // Fallback: use a hash of the stack trace
171
171
  const fallbackKey = `unknown:${stack.slice(0, 100)}`
172
- return hmr<A>(fallbackKey)(self)
172
+ return hmr(fallbackKey)(self)
173
173
  }
174
174
 
175
175
  const cacheKey = `${location.file}:${location.line}:${location.col}`
@@ -189,7 +189,7 @@ export function autoHmr<A>(self: A): A {
189
189
  keyCache.set(cacheKey, key)
190
190
  }
191
191
 
192
- return hmr<A>(key)(self)
192
+ return hmr(key)(self)
193
193
  }
194
194
 
195
195
  export interface DevRenderOptions extends RendererOptions {
@@ -309,7 +309,7 @@ function ScreenshotHandler() {
309
309
  const tmpPath = `/tmp/tui-screenshot-${Date.now()}.txt`
310
310
  Bun.write(tmpPath, ansiOutput)
311
311
 
312
- show("Screenshot copied!", "success", 1500)
312
+ show("Screenshot copied!", "screenshot", 2500)
313
313
  key.preventDefault?.()
314
314
  }
315
315
  })
@@ -336,26 +336,31 @@ function StatsOverlay({ sampleMs, title }: { sampleMs?: number; title?: string }
336
336
  )
337
337
  }
338
338
 
339
+ export interface DevWrapperProps {
340
+ children?: React.ReactNode
341
+ showStats?: boolean
342
+ statsSampleMs?: number
343
+ statsTitle?: string
344
+ mode?: "fullscreen" | "inline"
345
+ }
346
+
339
347
  /**
340
- * Internal wrapper component that provides dev mode features:
348
+ * Wrapper component that provides dev mode features:
341
349
  * - Debug console panel (` backtick to toggle)
342
350
  * - Screenshot support (~ tilde) with toast notification
343
351
  * - Auto-show console on errors
344
352
  * - Optional renderer stats overlay (showStats)
353
+ *
354
+ * Use this to wrap your app when you need dev features but can't use devRender
355
+ * (e.g., when you need to pass props to your App component).
345
356
  */
346
- function DevWrapper({
357
+ export function DevWrapper({
347
358
  children,
348
359
  showStats,
349
360
  statsSampleMs,
350
361
  statsTitle,
351
362
  mode,
352
- }: {
353
- children?: React.ReactNode
354
- showStats?: boolean
355
- statsSampleMs?: number
356
- statsTitle?: string
357
- mode?: "fullscreen" | "inline"
358
- }) {
363
+ }: DevWrapperProps) {
359
364
  const { visible } = useConsole({ autoShowOnError: true, initiallyVisible: false })
360
365
 
361
366
  // Inline mode: content flows naturally in vstack
@@ -6,3 +6,5 @@ export { usePaste } from "./use-paste.js"
6
6
  export { useQuit } from "./use-quit.js"
7
7
  export type { ScrollState, UseScrollOptions, UseScrollReturn } from "./use-scroll.js"
8
8
  export { useScroll } from "./use-scroll.js"
9
+ export type { TimerStatus, TimerType, UseTimerConfig, UseTimerReturn } from "./use-timer.js"
10
+ export { useTimer } from "./use-timer.js"
@@ -362,13 +362,15 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
362
362
  const itemEnd = itemStart + itemSize
363
363
  // Use provided totalSize if available (more accurate than potentially stale contentSize)
364
364
  const effectiveContentSize = totalSize ?? contentSize
365
+ const isAbove = itemStart < currentOffset + padding
366
+ const isBelow = itemEnd > currentOffset + currentViewportSize - padding
365
367
 
366
368
  // If item is above viewport, scroll up to show it
367
- if (itemStart < currentOffset + padding) {
369
+ if (isAbove) {
368
370
  setOffset(Math.max(0, itemStart - padding))
369
371
  }
370
372
  // If item is below viewport, scroll down to show it
371
- else if (itemEnd > currentOffset + currentViewportSize - padding) {
373
+ else if (isBelow) {
372
374
  const currentMaxOffset = Math.max(0, effectiveContentSize - currentViewportSize)
373
375
  setOffset(Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding))
374
376
  }
@@ -0,0 +1,155 @@
1
+ // use-timer.ts — Hook for countdown/stopwatch timers
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react"
4
+
5
+ // ============================================================================
6
+ // Types
7
+ // ============================================================================
8
+
9
+ export type TimerStatus = "RUNNING" | "PAUSED" | "STOPPED"
10
+ export type TimerType = "INCREMENTAL" | "DECREMENTAL"
11
+
12
+ export interface UseTimerConfig {
13
+ /** Starting time value in seconds (required) */
14
+ initialTime: number
15
+ /** Timer direction: 'INCREMENTAL' (stopwatch) or 'DECREMENTAL' (countdown). Default: DECREMENTAL */
16
+ timerType?: TimerType
17
+ /** When to stop. Default: 0 for decremental, null (never) for incremental */
18
+ endTime?: number | null
19
+ /** Auto-start on mount. Default: false */
20
+ autostart?: boolean
21
+ /** Milliseconds between ticks. Default: 1000 */
22
+ interval?: number
23
+ /** Called when timer reaches endTime */
24
+ onTimeOver?: () => void
25
+ /** Called on each tick with current time */
26
+ onTimeUpdate?: (time: number) => void
27
+ }
28
+
29
+ export interface UseTimerReturn {
30
+ /** Current time value in seconds */
31
+ time: number
32
+ /** Start or resume the timer */
33
+ start: () => void
34
+ /** Pause the timer */
35
+ pause: () => void
36
+ /** Reset timer to initialTime and stop */
37
+ reset: () => void
38
+ /** Current timer status */
39
+ status: TimerStatus
40
+ }
41
+
42
+ // ============================================================================
43
+ // Hook
44
+ // ============================================================================
45
+
46
+ /**
47
+ * Hook for countdown or stopwatch timers.
48
+ *
49
+ * @example Countdown timer (rest between sets)
50
+ * ```tsx
51
+ * const { time, start, status } = useTimer({
52
+ * initialTime: 90,
53
+ * timerType: 'DECREMENTAL',
54
+ * onTimeOver: () => playSound()
55
+ * })
56
+ *
57
+ * return <text>Rest: {time}s</text>
58
+ * ```
59
+ *
60
+ * @example Stopwatch (session duration)
61
+ * ```tsx
62
+ * const { time, start, pause } = useTimer({
63
+ * initialTime: 0,
64
+ * timerType: 'INCREMENTAL',
65
+ * autostart: true
66
+ * })
67
+ *
68
+ * return <text>Duration: {Math.floor(time / 60)}:{(time % 60).toString().padStart(2, '0')}</text>
69
+ * ```
70
+ */
71
+ export function useTimer(config: UseTimerConfig): UseTimerReturn {
72
+ const {
73
+ initialTime,
74
+ timerType = "DECREMENTAL",
75
+ endTime: endTimeConfig,
76
+ autostart = false,
77
+ interval = 1000,
78
+ onTimeOver,
79
+ onTimeUpdate,
80
+ } = config
81
+
82
+ // Determine endTime based on timer type if not explicitly set
83
+ const endTime = endTimeConfig ?? (timerType === "DECREMENTAL" ? 0 : null)
84
+
85
+ // State
86
+ const [time, setTime] = useState(initialTime)
87
+ const [status, setStatus] = useState<TimerStatus>(autostart ? "RUNNING" : "STOPPED")
88
+
89
+ // Refs for callbacks to avoid stale closures
90
+ const onTimeOverRef = useRef(onTimeOver)
91
+ const onTimeUpdateRef = useRef(onTimeUpdate)
92
+ onTimeOverRef.current = onTimeOver
93
+ onTimeUpdateRef.current = onTimeUpdate
94
+
95
+ // Control functions
96
+ const start = useCallback(() => {
97
+ setStatus("RUNNING")
98
+ }, [])
99
+
100
+ const pause = useCallback(() => {
101
+ setStatus((prev) => (prev === "RUNNING" ? "PAUSED" : prev))
102
+ }, [])
103
+
104
+ const reset = useCallback(() => {
105
+ setTime(initialTime)
106
+ setStatus("STOPPED")
107
+ }, [initialTime])
108
+
109
+ // Timer effect
110
+ useEffect(() => {
111
+ if (status !== "RUNNING") return
112
+
113
+ const tick = () => {
114
+ setTime((prevTime) => {
115
+ const step = timerType === "DECREMENTAL" ? -1 : 1
116
+ const nextTime = prevTime + step
117
+
118
+ // Check if we've reached the end
119
+ if (endTime !== null) {
120
+ const reachedEnd =
121
+ timerType === "DECREMENTAL" ? nextTime <= endTime : nextTime >= endTime
122
+
123
+ if (reachedEnd) {
124
+ // Schedule callback and status change for next tick to avoid state updates during render
125
+ setTimeout(() => {
126
+ setStatus("STOPPED")
127
+ onTimeOverRef.current?.()
128
+ }, 0)
129
+ return endTime
130
+ }
131
+ }
132
+
133
+ // Call onTimeUpdate
134
+ onTimeUpdateRef.current?.(nextTime)
135
+ return nextTime
136
+ })
137
+ }
138
+
139
+ const intervalId = setInterval(tick, interval)
140
+ return () => clearInterval(intervalId)
141
+ }, [status, timerType, endTime, interval])
142
+
143
+ // Reset time when initialTime changes (useful for restarting with different duration)
144
+ useEffect(() => {
145
+ setTime(initialTime)
146
+ }, [initialTime])
147
+
148
+ return {
149
+ time,
150
+ start,
151
+ pause,
152
+ reset,
153
+ status,
154
+ }
155
+ }
package/src/hosts/base.ts CHANGED
@@ -1,6 +1,42 @@
1
1
  import type { CellBuffer, Color, Palette } from "@effect-tui/core"
2
+ import { COLOR_MOTION_VALUE_BRAND, MOTION_VALUE_BRAND } from "../motion/brands.js"
3
+ import type { ColorMotionValue } from "../motion/color-motion-value.js"
4
+ import type { MotionValue } from "../motion/motion-value.js"
2
5
  import type { CommonProps, HostContext, HostInstance, Rect, Size } from "../reconciler/types.js"
3
6
 
7
+ /**
8
+ * Check if a value is a MotionValue.
9
+ * Uses brand Symbol for safety, with duck-typing fallback for compatibility.
10
+ */
11
+ function isMotionValue(value: unknown): value is MotionValue<unknown> {
12
+ if (typeof value !== "object" || value === null) return false
13
+ // Prefer brand detection (safe, fast)
14
+ if (MOTION_VALUE_BRAND in value) return true
15
+ // Duck-typing fallback for compatibility
16
+ return (
17
+ "get" in value &&
18
+ typeof (value as MotionValue<unknown>).get === "function" &&
19
+ "on" in value &&
20
+ typeof (value as MotionValue<unknown>).on === "function"
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Check if a value is a ColorMotionValue.
26
+ * Uses brand Symbol for safety, with duck-typing fallback for compatibility.
27
+ */
28
+ function isColorMotionValue(value: unknown): value is ColorMotionValue {
29
+ if (typeof value !== "object" || value === null) return false
30
+ // Prefer brand detection (safe, fast)
31
+ if (COLOR_MOTION_VALUE_BRAND in value) return true
32
+ // Duck-typing fallback for compatibility
33
+ return (
34
+ isMotionValue(value) &&
35
+ "_subscribeChannels" in value &&
36
+ typeof (value as { _subscribeChannels: unknown })._subscribeChannels === "function"
37
+ )
38
+ }
39
+
4
40
  /** Host that may have a background color (e.g., BoxHost) */
5
41
  export interface HostWithBg extends HostInstance {
6
42
  bg?: Color
@@ -54,6 +90,13 @@ export abstract class BaseHost implements HostInstance {
54
90
 
55
91
  protected ctx: HostContext
56
92
 
93
+ // ─────────────────────────────────────────────────────────────
94
+ // MotionValue subscriptions - for spring-animated props
95
+ // Lazy-initialized to avoid overhead for non-animated hosts
96
+ // Tracks both value identity and unsub function to avoid churn
97
+ // ─────────────────────────────────────────────────────────────
98
+ private _springSubscriptions?: Map<string, { value: unknown; unsub: () => void }>
99
+
57
100
  constructor(type: string, _props: CommonProps, ctx: HostContext) {
58
101
  this.id = `${type}-${idCounter++}`
59
102
  this.type = type
@@ -63,6 +106,82 @@ export abstract class BaseHost implements HostInstance {
63
106
  // which would overwrite any values set here.
64
107
  }
65
108
 
109
+ /**
110
+ * Resolve a prop that may be a MotionValue/ColorMotionValue.
111
+ * If it's a spring, subscribes to changes and returns current value.
112
+ * Automatically cleans up old subscriptions when prop changes.
113
+ * Avoids subscription churn by tracking value identity.
114
+ *
115
+ * @param key - Unique key for this prop (used for subscription tracking)
116
+ * @param value - The prop value (may be a MotionValue or regular value)
117
+ * @param onUpdate - Called with new value when spring animates (update the host property!)
118
+ * @returns The resolved value (current spring value or the value itself)
119
+ */
120
+ protected resolveSpringProp<T>(
121
+ key: string,
122
+ value: T | MotionValue<T> | ColorMotionValue,
123
+ onUpdate?: (value: T) => void,
124
+ ): T | undefined {
125
+ const existing = this._springSubscriptions?.get(key)
126
+
127
+ // Not a motion value - clean up any existing subscription and return as-is
128
+ if (!isMotionValue(value)) {
129
+ if (existing) {
130
+ existing.unsub()
131
+ this._springSubscriptions!.delete(key)
132
+ }
133
+ return value as T | undefined
134
+ }
135
+
136
+ // Same MotionValue as before - no need to resubscribe, just return current value
137
+ if (existing && existing.value === value) {
138
+ return (value as MotionValue<T>).get()
139
+ }
140
+
141
+ // Different MotionValue (or first time) - clean up old and subscribe to new
142
+ if (existing) {
143
+ existing.unsub()
144
+ this._springSubscriptions!.delete(key)
145
+ }
146
+
147
+ // Lazy-init the subscriptions map only when we actually have a spring
148
+ if (!this._springSubscriptions) {
149
+ this._springSubscriptions = new Map()
150
+ }
151
+
152
+ // ColorMotionValue - subscribe to all channels
153
+ if (isColorMotionValue(value)) {
154
+ const mv = value
155
+ const unsub = mv._subscribeChannels(() => {
156
+ onUpdate?.(mv.get() as T) // Update the host's property with new value!
157
+ this.ctx.requestRender()
158
+ })
159
+ this._springSubscriptions.set(key, { value: mv, unsub })
160
+ return mv.get() as T
161
+ }
162
+
163
+ // Regular MotionValue - subscribe to changes
164
+ const mv = value as MotionValue<T>
165
+ const unsub = mv.on("change", (newValue: T) => {
166
+ onUpdate?.(newValue) // Update the host's property with new value!
167
+ this.ctx.requestRender()
168
+ })
169
+ this._springSubscriptions.set(key, { value: mv, unsub })
170
+ return mv.get()
171
+ }
172
+
173
+ /**
174
+ * Clean up all spring subscriptions.
175
+ * Called automatically in destroy().
176
+ */
177
+ protected clearSpringSubscriptions(): void {
178
+ if (!this._springSubscriptions) return
179
+ for (const { unsub } of this._springSubscriptions.values()) {
180
+ unsub()
181
+ }
182
+ this._springSubscriptions.clear()
183
+ }
184
+
66
185
  abstract measure(maxW: number, maxH: number): Size
67
186
  abstract render(buffer: CellBuffer, palette: Palette): void
68
187
 
@@ -187,6 +306,7 @@ export abstract class BaseHost implements HostInstance {
187
306
  }
188
307
 
189
308
  destroy(): void {
309
+ this.clearSpringSubscriptions()
190
310
  // Override in subclasses if cleanup needed
191
311
  }
192
312
 
package/src/hosts/box.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CellBuffer, Color, Palette } from "@effect-tui/core"
2
2
  import { Colors } from "@effect-tui/core"
3
+ import type { ColorMotionValue } from "../motion/color-motion-value.js"
3
4
  import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.js"
4
5
  import {
5
6
  type BorderKind,
@@ -15,15 +16,18 @@ import { SingleChildHost } from "./single-child.js"
15
16
 
16
17
  export type { BorderKind }
17
18
 
19
+ /** Color prop that can be a static Color or a spring-animated ColorMotionValue */
20
+ export type ColorProp = Color | ColorMotionValue
21
+
18
22
  export interface BoxProps extends CommonProps {
19
23
  padding?: PaddingInput
20
24
  border?: BorderKind
21
- borderColor?: Color
22
- bg?: Color
25
+ borderColor?: ColorProp
26
+ bg?: ColorProp
23
27
  /** Title displayed on the top border */
24
28
  title?: string
25
29
  /** Title text color (defaults to borderColor) */
26
- titleColor?: Color
30
+ titleColor?: ColorProp
27
31
  }
28
32
 
29
33
  export class BoxHost extends SingleChildHost {
@@ -137,9 +141,16 @@ export class BoxHost extends SingleChildHost {
137
141
  super.updateProps(props)
138
142
  this.padding = resolvePadding(props.padding as BoxProps["padding"])
139
143
  this.border = (props.border as BorderKind | undefined) ?? "none"
140
- this.borderColor = props.borderColor as Color | undefined
141
- this.bg = props.bg as Color | undefined
144
+ // Color props support MotionValue/ColorMotionValue - auto-subscribe and animate
145
+ this.borderColor = this.resolveSpringProp("borderColor", props.borderColor, (v) => {
146
+ this.borderColor = v as Color
147
+ }) as Color | undefined
148
+ this.bg = this.resolveSpringProp("bg", props.bg, (v) => {
149
+ this.bg = v as Color
150
+ }) as Color | undefined
142
151
  this.title = props.title as string | undefined
143
- this.titleColor = props.titleColor as Color | undefined
152
+ this.titleColor = this.resolveSpringProp("titleColor", props.titleColor, (v) => {
153
+ this.titleColor = v as Color
154
+ }) as Color | undefined
144
155
  }
145
156
  }
@@ -52,8 +52,6 @@ export class ScrollHost extends SingleChildHost {
52
52
  private contentWidth = 0
53
53
  private contentHeight = 0
54
54
  // Track last reported sizes to avoid redundant callbacks
55
- private lastViewportW = 0
56
- private lastViewportH = 0
57
55
  private lastReportedContentW = 0
58
56
  private lastReportedContentH = 0
59
57
  private lastRectX = -1
@@ -112,10 +110,9 @@ export class ScrollHost extends SingleChildHost {
112
110
  }
113
111
 
114
112
  // Report viewport size if changed (for useScroll hook)
115
- if (rect.w !== this.lastViewportW || rect.h !== this.lastViewportH) {
116
- this.lastViewportW = rect.w
117
- this.lastViewportH = rect.h
118
- this.onViewportSize?.(rect.w, rect.h)
113
+ // Always report on every layout - React may have recreated the hook with stale refs
114
+ if (this.onViewportSize) {
115
+ this.onViewportSize(rect.w, rect.h)
119
116
  }
120
117
 
121
118
  // Report rect if position changed (for hit testing)
package/src/hosts/text.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { type CellBuffer, type Color, displayWidth, type Palette } from "@effect-tui/core"
2
+ import type { ColorMotionValue } from "../motion/color-motion-value.js"
2
3
  import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.js"
3
4
  import { resolveInheritedBgStyle, styleIdFromProps } from "../utils/index.js"
4
5
  import { BaseHost, getInheritedBg } from "./base.js"
5
6
 
7
+ /** Color prop that can be a static Color or a spring-animated ColorMotionValue */
8
+ export type ColorProp = Color | ColorMotionValue
9
+
6
10
  export interface TextProps extends CommonProps {
7
- fg?: Color
8
- bg?: Color
11
+ fg?: ColorProp
12
+ bg?: ColorProp
9
13
  bold?: boolean
10
14
  italic?: boolean
11
15
  underline?: boolean
@@ -354,9 +358,13 @@ export class TextHost extends BaseHost {
354
358
 
355
359
  override updateProps(props: Record<string, unknown>): void {
356
360
  super.updateProps(props)
357
- // Always assign; props may be undefined when attribute is removed
358
- this.fg = props.fg as Color | undefined
359
- this.bg = props.bg as Color | undefined
361
+ // Color props support MotionValue/ColorMotionValue - auto-subscribe and animate
362
+ this.fg = this.resolveSpringProp("fg", props.fg, (v) => {
363
+ this.fg = v as Color
364
+ }) as Color | undefined
365
+ this.bg = this.resolveSpringProp("bg", props.bg, (v) => {
366
+ this.bg = v as Color
367
+ }) as Color | undefined
360
368
  this.bold = Boolean(props.bold)
361
369
  this.italic = Boolean(props.italic)
362
370
  this.underline = Boolean(props.underline)
package/src/index.ts CHANGED
@@ -33,6 +33,8 @@ export {
33
33
  type DevRenderResult,
34
34
  devMain,
35
35
  devRender,
36
+ DevWrapper,
37
+ type DevWrapperProps,
36
38
  hmr,
37
39
  hmrState,
38
40
  } from "./dev.js"
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Brand Symbols for MotionValue type detection.
3
+ * Separated to avoid circular dependency issues.
4
+ */
5
+
6
+ /** Brand Symbol for safer MotionValue detection */
7
+ export const MOTION_VALUE_BRAND = Symbol.for("effect-tui/MotionValue")
8
+
9
+ /** Brand Symbol for safer ColorMotionValue detection */
10
+ export const COLOR_MOTION_VALUE_BRAND = Symbol.for("effect-tui/ColorMotionValue")
@@ -2,16 +2,22 @@
2
2
  * ColorMotionValue - animates RGBA colors using 4 internal numeric springs.
3
3
  */
4
4
 
5
+ import { COLOR_MOTION_VALUE_BRAND } from "./brands.js"
5
6
  import { type ColorInput, parseColor, type RGBA } from "./color.js"
6
7
  import { EventEmitter } from "./event-emitter.js"
7
8
  import { MotionValue } from "./motion-value.js"
8
9
  import type { SpringOptions } from "./types.js"
9
10
 
11
+ export { COLOR_MOTION_VALUE_BRAND } from "./brands.js"
12
+
10
13
  /**
11
14
  * ColorMotionValue animates RGBA colors using 4 internal numeric springs.
12
15
  * Each channel (r, g, b, a) is animated independently with the same spring config.
13
16
  */
14
17
  export class ColorMotionValue extends EventEmitter<RGBA> {
18
+ /** @internal Brand for safe type detection */
19
+ readonly [COLOR_MOTION_VALUE_BRAND] = true
20
+
15
21
  private rMv: MotionValue<number>
16
22
  private gMv: MotionValue<number>
17
23
  private bMv: MotionValue<number>