@effect-tui/react 0.1.0-alpha.1

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 (277) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/dist/jsx-dev-runtime.d.ts +3 -0
  4. package/dist/jsx-dev-runtime.d.ts.map +1 -0
  5. package/dist/jsx-dev-runtime.js +3 -0
  6. package/dist/jsx-dev-runtime.js.map +1 -0
  7. package/dist/jsx-runtime.d.ts +47 -0
  8. package/dist/jsx-runtime.d.ts.map +1 -0
  9. package/dist/jsx-runtime.js +6 -0
  10. package/dist/jsx-runtime.js.map +1 -0
  11. package/dist/src/codeblock.d.ts +9 -0
  12. package/dist/src/codeblock.d.ts.map +1 -0
  13. package/dist/src/codeblock.js +24 -0
  14. package/dist/src/codeblock.js.map +1 -0
  15. package/dist/src/constants.d.ts +3 -0
  16. package/dist/src/constants.d.ts.map +1 -0
  17. package/dist/src/constants.js +3 -0
  18. package/dist/src/constants.js.map +1 -0
  19. package/dist/src/debug/DiagnosticsPanel.d.ts +7 -0
  20. package/dist/src/debug/DiagnosticsPanel.d.ts.map +1 -0
  21. package/dist/src/debug/DiagnosticsPanel.js +13 -0
  22. package/dist/src/debug/DiagnosticsPanel.js.map +1 -0
  23. package/dist/src/highlight.d.ts +20 -0
  24. package/dist/src/highlight.d.ts.map +1 -0
  25. package/dist/src/highlight.js +51 -0
  26. package/dist/src/highlight.js.map +1 -0
  27. package/dist/src/hooks/index.d.ts +4 -0
  28. package/dist/src/hooks/index.d.ts.map +1 -0
  29. package/dist/src/hooks/index.js +3 -0
  30. package/dist/src/hooks/index.js.map +1 -0
  31. package/dist/src/hooks/use-keyboard.d.ts +18 -0
  32. package/dist/src/hooks/use-keyboard.d.ts.map +1 -0
  33. package/dist/src/hooks/use-keyboard.js +26 -0
  34. package/dist/src/hooks/use-keyboard.js.map +1 -0
  35. package/dist/src/hooks/use-paste.d.ts +5 -0
  36. package/dist/src/hooks/use-paste.d.ts.map +1 -0
  37. package/dist/src/hooks/use-paste.js +14 -0
  38. package/dist/src/hooks/use-paste.js.map +1 -0
  39. package/dist/src/hooks/useFrameStats.d.ts +7 -0
  40. package/dist/src/hooks/useFrameStats.d.ts.map +1 -0
  41. package/dist/src/hooks/useFrameStats.js +28 -0
  42. package/dist/src/hooks/useFrameStats.js.map +1 -0
  43. package/dist/src/hosts/base.d.ts +22 -0
  44. package/dist/src/hosts/base.d.ts.map +1 -0
  45. package/dist/src/hosts/base.js +53 -0
  46. package/dist/src/hosts/base.js.map +1 -0
  47. package/dist/src/hosts/box.d.ts +26 -0
  48. package/dist/src/hosts/box.d.ts.map +1 -0
  49. package/dist/src/hosts/box.js +84 -0
  50. package/dist/src/hosts/box.js.map +1 -0
  51. package/dist/src/hosts/canvas.d.ts +48 -0
  52. package/dist/src/hosts/canvas.d.ts.map +1 -0
  53. package/dist/src/hosts/canvas.js +109 -0
  54. package/dist/src/hosts/canvas.js.map +1 -0
  55. package/dist/src/hosts/codeblock.d.ts +32 -0
  56. package/dist/src/hosts/codeblock.d.ts.map +1 -0
  57. package/dist/src/hosts/codeblock.js +118 -0
  58. package/dist/src/hosts/codeblock.js.map +1 -0
  59. package/dist/src/hosts/hstack.d.ts +18 -0
  60. package/dist/src/hosts/hstack.d.ts.map +1 -0
  61. package/dist/src/hosts/hstack.js +45 -0
  62. package/dist/src/hosts/hstack.js.map +1 -0
  63. package/dist/src/hosts/index.d.ts +16 -0
  64. package/dist/src/hosts/index.d.ts.map +1 -0
  65. package/dist/src/hosts/index.js +40 -0
  66. package/dist/src/hosts/index.js.map +1 -0
  67. package/dist/src/hosts/spacer.d.ts +19 -0
  68. package/dist/src/hosts/spacer.d.ts.map +1 -0
  69. package/dist/src/hosts/spacer.js +28 -0
  70. package/dist/src/hosts/spacer.js.map +1 -0
  71. package/dist/src/hosts/text.d.ts +43 -0
  72. package/dist/src/hosts/text.d.ts.map +1 -0
  73. package/dist/src/hosts/text.js +148 -0
  74. package/dist/src/hosts/text.js.map +1 -0
  75. package/dist/src/hosts/vstack.d.ts +18 -0
  76. package/dist/src/hosts/vstack.d.ts.map +1 -0
  77. package/dist/src/hosts/vstack.js +45 -0
  78. package/dist/src/hosts/vstack.js.map +1 -0
  79. package/dist/src/hosts/zstack.d.ts +20 -0
  80. package/dist/src/hosts/zstack.d.ts.map +1 -0
  81. package/dist/src/hosts/zstack.js +65 -0
  82. package/dist/src/hosts/zstack.js.map +1 -0
  83. package/dist/src/index.d.ts +20 -0
  84. package/dist/src/index.d.ts.map +1 -0
  85. package/dist/src/index.js +20 -0
  86. package/dist/src/index.js.map +1 -0
  87. package/dist/src/inline/index.d.ts +32 -0
  88. package/dist/src/inline/index.d.ts.map +1 -0
  89. package/dist/src/inline/index.js +111 -0
  90. package/dist/src/inline/index.js.map +1 -0
  91. package/dist/src/jsx.d.ts +2 -0
  92. package/dist/src/jsx.d.ts.map +1 -0
  93. package/dist/src/jsx.js +4 -0
  94. package/dist/src/jsx.js.map +1 -0
  95. package/dist/src/motion/color-motion-value.d.ts +32 -0
  96. package/dist/src/motion/color-motion-value.d.ts.map +1 -0
  97. package/dist/src/motion/color-motion-value.js +80 -0
  98. package/dist/src/motion/color-motion-value.js.map +1 -0
  99. package/dist/src/motion/color.d.ts +30 -0
  100. package/dist/src/motion/color.d.ts.map +1 -0
  101. package/dist/src/motion/color.js +172 -0
  102. package/dist/src/motion/color.js.map +1 -0
  103. package/dist/src/motion/color.test.d.ts +2 -0
  104. package/dist/src/motion/color.test.d.ts.map +1 -0
  105. package/dist/src/motion/color.test.js +97 -0
  106. package/dist/src/motion/color.test.js.map +1 -0
  107. package/dist/src/motion/event-emitter.d.ts +18 -0
  108. package/dist/src/motion/event-emitter.d.ts.map +1 -0
  109. package/dist/src/motion/event-emitter.js +30 -0
  110. package/dist/src/motion/event-emitter.js.map +1 -0
  111. package/dist/src/motion/frame.d.ts +9 -0
  112. package/dist/src/motion/frame.d.ts.map +1 -0
  113. package/dist/src/motion/frame.js +51 -0
  114. package/dist/src/motion/frame.js.map +1 -0
  115. package/dist/src/motion/hooks.d.ts +75 -0
  116. package/dist/src/motion/hooks.d.ts.map +1 -0
  117. package/dist/src/motion/hooks.js +190 -0
  118. package/dist/src/motion/hooks.js.map +1 -0
  119. package/dist/src/motion/index.d.ts +4 -0
  120. package/dist/src/motion/index.d.ts.map +1 -0
  121. package/dist/src/motion/index.js +7 -0
  122. package/dist/src/motion/index.js.map +1 -0
  123. package/dist/src/motion/motion-value.d.ts +40 -0
  124. package/dist/src/motion/motion-value.d.ts.map +1 -0
  125. package/dist/src/motion/motion-value.js +109 -0
  126. package/dist/src/motion/motion-value.js.map +1 -0
  127. package/dist/src/motion/motion-value.test.d.ts +2 -0
  128. package/dist/src/motion/motion-value.test.d.ts.map +1 -0
  129. package/dist/src/motion/motion-value.test.js +177 -0
  130. package/dist/src/motion/motion-value.test.js.map +1 -0
  131. package/dist/src/motion/spring-math.d.ts +28 -0
  132. package/dist/src/motion/spring-math.d.ts.map +1 -0
  133. package/dist/src/motion/spring-math.js +81 -0
  134. package/dist/src/motion/spring-math.js.map +1 -0
  135. package/dist/src/motion/types.d.ts +25 -0
  136. package/dist/src/motion/types.d.ts.map +1 -0
  137. package/dist/src/motion/types.js +13 -0
  138. package/dist/src/motion/types.js.map +1 -0
  139. package/dist/src/output.d.ts +47 -0
  140. package/dist/src/output.d.ts.map +1 -0
  141. package/dist/src/output.js +125 -0
  142. package/dist/src/output.js.map +1 -0
  143. package/dist/src/profiler.d.ts +6 -0
  144. package/dist/src/profiler.d.ts.map +1 -0
  145. package/dist/src/profiler.js +73 -0
  146. package/dist/src/profiler.js.map +1 -0
  147. package/dist/src/reconciler/host-config.d.ts +16 -0
  148. package/dist/src/reconciler/host-config.d.ts.map +1 -0
  149. package/dist/src/reconciler/host-config.js +174 -0
  150. package/dist/src/reconciler/host-config.js.map +1 -0
  151. package/dist/src/reconciler/types.d.ts +52 -0
  152. package/dist/src/reconciler/types.d.ts.map +1 -0
  153. package/dist/src/reconciler/types.js +2 -0
  154. package/dist/src/reconciler/types.js.map +1 -0
  155. package/dist/src/renderer.d.ts +101 -0
  156. package/dist/src/renderer.d.ts.map +1 -0
  157. package/dist/src/renderer.js +509 -0
  158. package/dist/src/renderer.js.map +1 -0
  159. package/dist/src/terminal.d.ts +37 -0
  160. package/dist/src/terminal.d.ts.map +1 -0
  161. package/dist/src/terminal.js +65 -0
  162. package/dist/src/terminal.js.map +1 -0
  163. package/dist/src/test/index.d.ts +3 -0
  164. package/dist/src/test/index.d.ts.map +1 -0
  165. package/dist/src/test/index.js +3 -0
  166. package/dist/src/test/index.js.map +1 -0
  167. package/dist/src/test/mock-streams.d.ts +44 -0
  168. package/dist/src/test/mock-streams.d.ts.map +1 -0
  169. package/dist/src/test/mock-streams.js +136 -0
  170. package/dist/src/test/mock-streams.js.map +1 -0
  171. package/dist/src/test/render-tui.d.ts +47 -0
  172. package/dist/src/test/render-tui.d.ts.map +1 -0
  173. package/dist/src/test/render-tui.js +76 -0
  174. package/dist/src/test/render-tui.js.map +1 -0
  175. package/dist/src/trace/SpanTree.d.ts +10 -0
  176. package/dist/src/trace/SpanTree.d.ts.map +1 -0
  177. package/dist/src/trace/SpanTree.js +104 -0
  178. package/dist/src/trace/SpanTree.js.map +1 -0
  179. package/dist/src/trace/index.d.ts +30 -0
  180. package/dist/src/trace/index.d.ts.map +1 -0
  181. package/dist/src/trace/index.js +142 -0
  182. package/dist/src/trace/index.js.map +1 -0
  183. package/dist/src/trace/location.d.ts +9 -0
  184. package/dist/src/trace/location.d.ts.map +1 -0
  185. package/dist/src/trace/location.js +88 -0
  186. package/dist/src/trace/location.js.map +1 -0
  187. package/dist/src/trace/span-processor.d.ts +16 -0
  188. package/dist/src/trace/span-processor.d.ts.map +1 -0
  189. package/dist/src/trace/span-processor.js +54 -0
  190. package/dist/src/trace/span-processor.js.map +1 -0
  191. package/dist/src/trace/span-state.d.ts +79 -0
  192. package/dist/src/trace/span-state.d.ts.map +1 -0
  193. package/dist/src/trace/span-state.js +229 -0
  194. package/dist/src/trace/span-state.js.map +1 -0
  195. package/dist/src/trace/tui-logger.d.ts +8 -0
  196. package/dist/src/trace/tui-logger.d.ts.map +1 -0
  197. package/dist/src/trace/tui-logger.js +70 -0
  198. package/dist/src/trace/tui-logger.js.map +1 -0
  199. package/dist/src/utils/border.d.ts +31 -0
  200. package/dist/src/utils/border.d.ts.map +1 -0
  201. package/dist/src/utils/border.js +81 -0
  202. package/dist/src/utils/border.js.map +1 -0
  203. package/dist/src/utils/flex-layout.d.ts +20 -0
  204. package/dist/src/utils/flex-layout.d.ts.map +1 -0
  205. package/dist/src/utils/flex-layout.js +85 -0
  206. package/dist/src/utils/flex-layout.js.map +1 -0
  207. package/dist/src/utils/index.d.ts +5 -0
  208. package/dist/src/utils/index.d.ts.map +1 -0
  209. package/dist/src/utils/index.js +5 -0
  210. package/dist/src/utils/index.js.map +1 -0
  211. package/dist/src/utils/padding.d.ts +26 -0
  212. package/dist/src/utils/padding.d.ts.map +1 -0
  213. package/dist/src/utils/padding.js +34 -0
  214. package/dist/src/utils/padding.js.map +1 -0
  215. package/dist/src/utils/styles.d.ts +13 -0
  216. package/dist/src/utils/styles.d.ts.map +1 -0
  217. package/dist/src/utils/styles.js +5 -0
  218. package/dist/src/utils/styles.js.map +1 -0
  219. package/dist/src/visualize/index.d.ts +50 -0
  220. package/dist/src/visualize/index.d.ts.map +1 -0
  221. package/dist/src/visualize/index.js +194 -0
  222. package/dist/src/visualize/index.js.map +1 -0
  223. package/dist/tsconfig.tsbuildinfo +1 -0
  224. package/package.json +94 -0
  225. package/src/codeblock.tsx +47 -0
  226. package/src/constants.ts +2 -0
  227. package/src/debug/DiagnosticsPanel.tsx +38 -0
  228. package/src/highlight.ts +76 -0
  229. package/src/hooks/index.ts +3 -0
  230. package/src/hooks/use-keyboard.ts +37 -0
  231. package/src/hooks/use-paste.ts +14 -0
  232. package/src/hooks/useFrameStats.ts +32 -0
  233. package/src/hosts/base.ts +65 -0
  234. package/src/hosts/box.ts +105 -0
  235. package/src/hosts/canvas.ts +155 -0
  236. package/src/hosts/codeblock.ts +145 -0
  237. package/src/hosts/hstack.ts +64 -0
  238. package/src/hosts/index.ts +45 -0
  239. package/src/hosts/spacer.ts +40 -0
  240. package/src/hosts/text.ts +175 -0
  241. package/src/hosts/vstack.ts +64 -0
  242. package/src/hosts/zstack.ts +77 -0
  243. package/src/index.ts +62 -0
  244. package/src/inline/index.tsx +181 -0
  245. package/src/jsx.ts +3 -0
  246. package/src/motion/color-motion-value.ts +90 -0
  247. package/src/motion/color.test.ts +115 -0
  248. package/src/motion/color.ts +191 -0
  249. package/src/motion/event-emitter.ts +35 -0
  250. package/src/motion/frame.ts +59 -0
  251. package/src/motion/hooks.ts +237 -0
  252. package/src/motion/index.ts +17 -0
  253. package/src/motion/motion-value.test.ts +222 -0
  254. package/src/motion/motion-value.ts +140 -0
  255. package/src/motion/spring-math.ts +114 -0
  256. package/src/motion/types.ts +34 -0
  257. package/src/output.ts +156 -0
  258. package/src/profiler.ts +88 -0
  259. package/src/reconciler/host-config.ts +277 -0
  260. package/src/reconciler/types.ts +66 -0
  261. package/src/renderer.ts +661 -0
  262. package/src/terminal.ts +67 -0
  263. package/src/test/index.ts +8 -0
  264. package/src/test/mock-streams.ts +149 -0
  265. package/src/test/render-tui.ts +118 -0
  266. package/src/trace/SpanTree.tsx +195 -0
  267. package/src/trace/index.tsx +205 -0
  268. package/src/trace/location.ts +90 -0
  269. package/src/trace/span-processor.ts +65 -0
  270. package/src/trace/span-state.ts +286 -0
  271. package/src/trace/tui-logger.ts +72 -0
  272. package/src/utils/border.ts +108 -0
  273. package/src/utils/flex-layout.ts +125 -0
  274. package/src/utils/index.ts +4 -0
  275. package/src/utils/padding.ts +45 -0
  276. package/src/utils/styles.ts +14 -0
  277. package/src/visualize/index.tsx +305 -0
@@ -0,0 +1,181 @@
1
+ // Inline task visualization with Layer-based dependency injection
2
+ // Usage:
3
+ // import { task, InlineRendererLive } from "@effect-tui/react/inline"
4
+ // const program = Effect.gen(function* () {
5
+ // yield* task("Fetch user", fetchUser)
6
+ // yield* task("Process data", processData)
7
+ // }).pipe(Effect.provide(InlineRendererLive))
8
+ // Effect.runPromise(program)
9
+
10
+ import { Context, Effect, Either, Layer } from "effect"
11
+ import { Colors } from "@effect-tui/core"
12
+ import { createRenderer, createRoot } from "../renderer.js"
13
+
14
+ // Spinner frames
15
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
16
+
17
+ type TaskStatus = "pending" | "running" | "success" | "failure"
18
+
19
+ interface TaskEntry {
20
+ label: string
21
+ status: TaskStatus
22
+ }
23
+
24
+ interface TaskListProps {
25
+ tasks: TaskEntry[]
26
+ spinnerIndex: number
27
+ }
28
+
29
+ function TaskList({ tasks, spinnerIndex }: TaskListProps) {
30
+ return (
31
+ <vstack>
32
+ {tasks.map((task, i) => {
33
+ const icon =
34
+ task.status === "running"
35
+ ? SPINNER[spinnerIndex % SPINNER.length]
36
+ : task.status === "success"
37
+ ? "✓"
38
+ : task.status === "failure"
39
+ ? "✗"
40
+ : "○"
41
+
42
+ const color =
43
+ task.status === "running"
44
+ ? Colors.brightYellow
45
+ : task.status === "success"
46
+ ? Colors.brightGreen
47
+ : task.status === "failure"
48
+ ? Colors.brightRed
49
+ : Colors.gray(10)
50
+
51
+ return (
52
+ <hstack key={i} spacing={1}>
53
+ <text bold fg={color}>
54
+ {icon}
55
+ </text>
56
+ <text fg={task.status === "pending" ? Colors.gray(10) : undefined}>{task.label}</text>
57
+ </hstack>
58
+ )
59
+ })}
60
+ </vstack>
61
+ )
62
+ }
63
+
64
+ export interface TaskOptions<A> {
65
+ /** Format the result to append to the label on success */
66
+ formatResult?: (result: A) => string
67
+ }
68
+
69
+ // Service definition
70
+ export interface InlineRenderer {
71
+ /**
72
+ * Wrap an effect with inline progress visualization.
73
+ * Shows a spinner while running, checkmark on success, X on failure.
74
+ */
75
+ readonly task: <A, E, R>(
76
+ label: string,
77
+ effect: Effect.Effect<A, E, R>,
78
+ options?: TaskOptions<A>,
79
+ ) => Effect.Effect<A, E, R>
80
+ }
81
+
82
+ export const InlineRenderer = Context.GenericTag<InlineRenderer>("@effect-tui/react/InlineRenderer")
83
+
84
+ // Create the live layer
85
+ export const InlineRendererLive: Layer.Layer<InlineRenderer> = Layer.scoped(
86
+ InlineRenderer,
87
+ Effect.gen(function* () {
88
+ // Create renderer in inline mode
89
+ const renderer = createRenderer({ mode: "inline" })
90
+ const root = createRoot(renderer)
91
+
92
+ // Session state - accumulate all tasks
93
+ const tasks: TaskEntry[] = []
94
+ let spinnerIndex = 0
95
+ let intervalId: ReturnType<typeof setInterval> | null = null
96
+
97
+ const render = () => {
98
+ root.render(<TaskList tasks={[...tasks]} spinnerIndex={spinnerIndex} />)
99
+ }
100
+
101
+ const startSpinner = (label: string) => {
102
+ tasks.push({ label, status: "running" })
103
+ spinnerIndex = 0
104
+ render()
105
+
106
+ intervalId = setInterval(() => {
107
+ spinnerIndex = (spinnerIndex + 1) % SPINNER.length
108
+ render()
109
+ }, 80)
110
+ }
111
+
112
+ const stopSpinner = (status: "success" | "failure", labelSuffix?: string) => {
113
+ if (intervalId) {
114
+ clearInterval(intervalId)
115
+ intervalId = null
116
+ }
117
+ // Update last task status
118
+ if (tasks.length > 0) {
119
+ tasks[tasks.length - 1].status = status
120
+ if (labelSuffix) {
121
+ tasks[tasks.length - 1].label += labelSuffix
122
+ }
123
+ render()
124
+ }
125
+ }
126
+
127
+ // Cleanup on scope close
128
+ yield* Effect.addFinalizer(() =>
129
+ Effect.sync(() => {
130
+ if (intervalId) clearInterval(intervalId)
131
+ root.unmount()
132
+ }),
133
+ )
134
+
135
+ return InlineRenderer.of({
136
+ task: <A, E, R>(
137
+ label: string,
138
+ effect: Effect.Effect<A, E, R>,
139
+ options?: TaskOptions<A>,
140
+ ): Effect.Effect<A, E, R> =>
141
+ Effect.gen(function* () {
142
+ startSpinner(label)
143
+
144
+ const result = yield* effect.pipe(Effect.either)
145
+
146
+ if (Either.isRight(result)) {
147
+ const suffix = options?.formatResult ? ` → ${options.formatResult(result.right)}` : ""
148
+ stopSpinner("success", suffix)
149
+ yield* Effect.sleep(50)
150
+ return result.right
151
+ } else {
152
+ stopSpinner("failure")
153
+ yield* Effect.sleep(50)
154
+ return yield* Effect.fail(result.left)
155
+ }
156
+ }),
157
+ })
158
+ }),
159
+ )
160
+
161
+ /**
162
+ * Wrap an effect with inline progress visualization.
163
+ * Requires InlineRendererLive layer to be provided.
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * const program = Effect.gen(function* () {
168
+ * const user = yield* task("Fetch user", fetchUser, {
169
+ * formatResult: (u) => u.name
170
+ * })
171
+ * yield* task("Send email", sendEmail(user))
172
+ * }).pipe(Effect.provide(InlineRendererLive))
173
+ *
174
+ * Effect.runPromise(program)
175
+ * ```
176
+ */
177
+ export const task = <A, E, R>(
178
+ label: string,
179
+ effect: Effect.Effect<A, E, R>,
180
+ options?: TaskOptions<A>,
181
+ ): Effect.Effect<A, E, R | InlineRenderer> => Effect.flatMap(InlineRenderer, (r) => r.task(label, effect, options))
package/src/jsx.ts ADDED
@@ -0,0 +1,3 @@
1
+ // JSX types are provided via jsxImportSource: "@effect-tui/react"
2
+ // See jsx-runtime.ts at package root for the JSX namespace definition
3
+ export {}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ColorMotionValue - animates RGBA colors using 4 internal numeric springs.
3
+ */
4
+
5
+ import { parseColor, type RGBA, type ColorInput } from "./color.js"
6
+ import { EventEmitter } from "./event-emitter.js"
7
+ import { MotionValue } from "./motion-value.js"
8
+ import type { SpringOptions } from "./types.js"
9
+
10
+ /**
11
+ * ColorMotionValue animates RGBA colors using 4 internal numeric springs.
12
+ * Each channel (r, g, b, a) is animated independently with the same spring config.
13
+ */
14
+ export class ColorMotionValue extends EventEmitter<RGBA> {
15
+ private rMv: MotionValue<number>
16
+ private gMv: MotionValue<number>
17
+ private bMv: MotionValue<number>
18
+ private aMv: MotionValue<number>
19
+
20
+ constructor(initial: ColorInput) {
21
+ super()
22
+ const rgba = parseColor(initial)
23
+ this.rMv = new MotionValue(rgba.r)
24
+ this.gMv = new MotionValue(rgba.g)
25
+ this.bMv = new MotionValue(rgba.b)
26
+ this.aMv = new MotionValue(rgba.a)
27
+ }
28
+
29
+ /** Get current RGBA value */
30
+ get(): RGBA {
31
+ return {
32
+ r: Math.round(Math.max(0, Math.min(255, this.rMv.get()))),
33
+ g: Math.round(Math.max(0, Math.min(255, this.gMv.get()))),
34
+ b: Math.round(Math.max(0, Math.min(255, this.bMv.get()))),
35
+ a: Math.max(0, Math.min(1, this.aMv.get())),
36
+ }
37
+ }
38
+
39
+ /** Set color immediately (no animation) */
40
+ jump(color: ColorInput) {
41
+ const rgba = parseColor(color)
42
+ this.rMv.jump(rgba.r)
43
+ this.gMv.jump(rgba.g)
44
+ this.bMv.jump(rgba.b)
45
+ this.aMv.jump(rgba.a)
46
+ this.notify("change", this.get())
47
+ }
48
+
49
+ /** Set target color and animate with spring */
50
+ set(color: ColorInput, options?: SpringOptions) {
51
+ const rgba = parseColor(color)
52
+ this.rMv.set(rgba.r, options)
53
+ this.gMv.set(rgba.g, options)
54
+ this.bMv.set(rgba.b, options)
55
+ this.aMv.set(rgba.a, options)
56
+ }
57
+
58
+ /** Check if any channel is animating */
59
+ isAnimating(): boolean {
60
+ return this.rMv.isAnimating() || this.gMv.isAnimating() || this.bMv.isAnimating() || this.aMv.isAnimating()
61
+ }
62
+
63
+ /** Stop all channel animations */
64
+ stop() {
65
+ this.rMv.stop()
66
+ this.gMv.stop()
67
+ this.bMv.stop()
68
+ this.aMv.stop()
69
+ }
70
+
71
+ /** Destroy and clean up */
72
+ destroy() {
73
+ this.rMv.destroy()
74
+ this.gMv.destroy()
75
+ this.bMv.destroy()
76
+ this.aMv.destroy()
77
+ this.clearSubscribers()
78
+ }
79
+
80
+ /** Subscribe internal motion values to a callback (used by hooks) */
81
+ _subscribeChannels(callback: () => void): () => void {
82
+ const unsubs = [
83
+ this.rMv.on("change", callback),
84
+ this.gMv.on("change", callback),
85
+ this.bMv.on("change", callback),
86
+ this.aMv.on("change", callback),
87
+ ]
88
+ return () => unsubs.forEach((u) => u())
89
+ }
90
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { parseColor, isColorInput } from "./color.js"
3
+
4
+ describe("parseColor", () => {
5
+ describe("hex", () => {
6
+ it("parses 6-digit hex", () => {
7
+ expect(parseColor("#ff0000")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
8
+ expect(parseColor("#00ff00")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
9
+ expect(parseColor("#0000ff")).toEqual({ r: 0, g: 0, b: 255, a: 1 })
10
+ })
11
+
12
+ it("parses 3-digit hex shorthand", () => {
13
+ expect(parseColor("#f00")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
14
+ expect(parseColor("#0f0")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
15
+ expect(parseColor("#00f")).toEqual({ r: 0, g: 0, b: 255, a: 1 })
16
+ })
17
+
18
+ it("parses 8-digit hex with alpha", () => {
19
+ expect(parseColor("#ff000080")).toEqual({ r: 255, g: 0, b: 0, a: 128 / 255 })
20
+ expect(parseColor("#00ff00ff")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
21
+ })
22
+
23
+ it("handles case insensitivity", () => {
24
+ expect(parseColor("#FF0000")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
25
+ expect(parseColor("#AbCdEf")).toEqual({ r: 171, g: 205, b: 239, a: 1 })
26
+ })
27
+ })
28
+
29
+ describe("rgb/rgba", () => {
30
+ it("parses rgb()", () => {
31
+ expect(parseColor("rgb(255, 0, 0)")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
32
+ expect(parseColor("rgb(0, 255, 0)")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
33
+ })
34
+
35
+ it("parses rgba()", () => {
36
+ expect(parseColor("rgba(255, 0, 0, 0.5)")).toEqual({ r: 255, g: 0, b: 0, a: 0.5 })
37
+ expect(parseColor("rgba(0, 255, 0, 1)")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
38
+ })
39
+
40
+ it("handles spaces", () => {
41
+ expect(parseColor("rgb( 255 , 0 , 0 )")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
42
+ })
43
+ })
44
+
45
+ describe("hsl/hsla", () => {
46
+ it("parses hsl()", () => {
47
+ // Red: hsl(0, 100%, 50%)
48
+ const red = parseColor("hsl(0, 100%, 50%)")
49
+ expect(red.r).toBe(255)
50
+ expect(red.g).toBe(0)
51
+ expect(red.b).toBe(0)
52
+
53
+ // Green: hsl(120, 100%, 50%)
54
+ const green = parseColor("hsl(120, 100%, 50%)")
55
+ expect(green.r).toBe(0)
56
+ expect(green.g).toBe(255)
57
+ expect(green.b).toBe(0)
58
+
59
+ // Blue: hsl(240, 100%, 50%)
60
+ const blue = parseColor("hsl(240, 100%, 50%)")
61
+ expect(blue.r).toBe(0)
62
+ expect(blue.g).toBe(0)
63
+ expect(blue.b).toBe(255)
64
+ })
65
+
66
+ it("parses hsla() with alpha", () => {
67
+ const result = parseColor("hsla(0, 100%, 50%, 0.5)")
68
+ expect(result.r).toBe(255)
69
+ expect(result.a).toBe(0.5)
70
+ })
71
+ })
72
+
73
+ describe("object", () => {
74
+ it("passes through RGB object", () => {
75
+ expect(parseColor({ r: 100, g: 150, b: 200 })).toEqual({ r: 100, g: 150, b: 200, a: 1 })
76
+ })
77
+
78
+ it("passes through RGBA object", () => {
79
+ expect(parseColor({ r: 100, g: 150, b: 200, a: 0.5 })).toEqual({ r: 100, g: 150, b: 200, a: 0.5 })
80
+ })
81
+ })
82
+
83
+ describe("invalid input", () => {
84
+ it("returns black for unknown format", () => {
85
+ expect(parseColor("invalid")).toEqual({ r: 0, g: 0, b: 0, a: 1 })
86
+ expect(parseColor("red")).toEqual({ r: 0, g: 0, b: 0, a: 1 }) // Named colors not supported
87
+ })
88
+ })
89
+ })
90
+
91
+ describe("isColorInput", () => {
92
+ it("returns true for hex strings", () => {
93
+ expect(isColorInput("#ff0000")).toBe(true)
94
+ expect(isColorInput("#f00")).toBe(true)
95
+ })
96
+
97
+ it("returns true for rgb/hsl strings", () => {
98
+ expect(isColorInput("rgb(255, 0, 0)")).toBe(true)
99
+ expect(isColorInput("hsl(0, 100%, 50%)")).toBe(true)
100
+ })
101
+
102
+ it("returns true for RGB objects", () => {
103
+ expect(isColorInput({ r: 255, g: 0, b: 0 })).toBe(true)
104
+ expect(isColorInput({ r: 255, g: 0, b: 0, a: 1 })).toBe(true)
105
+ })
106
+
107
+ it("returns false for numbers", () => {
108
+ expect(isColorInput(123)).toBe(false)
109
+ })
110
+
111
+ it("returns false for invalid strings", () => {
112
+ expect(isColorInput("hello")).toBe(false)
113
+ expect(isColorInput("red")).toBe(false)
114
+ })
115
+ })
@@ -0,0 +1,191 @@
1
+ // Color parsing for spring animations
2
+ // Motion-compatible: hex, rgb(), rgba(), hsl(), hsla(), RGB object
3
+
4
+ export type RGBA = { r: number; g: number; b: number; a: number }
5
+ export type ColorInput = string | RGBA | { r: number; g: number; b: number }
6
+
7
+ /**
8
+ * Parse any supported color format to RGBA.
9
+ * Supports:
10
+ * - Hex: "#ff0000", "#f00", "#ff0000ff"
11
+ * - RGB: "rgb(255, 0, 0)", "rgba(255, 0, 0, 0.5)"
12
+ * - HSL: "hsl(0, 100%, 50%)", "hsla(0, 100%, 50%, 0.5)"
13
+ * - Object: { r: 255, g: 0, b: 0 } or { r: 255, g: 0, b: 0, a: 1 }
14
+ */
15
+ export function parseColor(input: ColorInput): RGBA {
16
+ if (typeof input === "object") {
17
+ return {
18
+ r: input.r,
19
+ g: input.g,
20
+ b: input.b,
21
+ a: "a" in input ? input.a : 1,
22
+ }
23
+ }
24
+
25
+ const str = input.trim().toLowerCase()
26
+
27
+ // Hex: #rgb, #rrggbb, #rrggbbaa
28
+ if (str.startsWith("#")) {
29
+ return parseHex(str)
30
+ }
31
+
32
+ // rgb(r, g, b) or rgba(r, g, b, a)
33
+ if (str.startsWith("rgb")) {
34
+ return parseRgb(str)
35
+ }
36
+
37
+ // hsl(h, s%, l%) or hsla(h, s%, l%, a)
38
+ if (str.startsWith("hsl")) {
39
+ return parseHsl(str)
40
+ }
41
+
42
+ // Unknown format, return black
43
+ return { r: 0, g: 0, b: 0, a: 1 }
44
+ }
45
+
46
+ function parseHex(hex: string): RGBA {
47
+ const h = hex.slice(1)
48
+
49
+ if (h.length === 3) {
50
+ // #rgb -> #rrggbb
51
+ return {
52
+ r: parseInt(h[0] + h[0], 16),
53
+ g: parseInt(h[1] + h[1], 16),
54
+ b: parseInt(h[2] + h[2], 16),
55
+ a: 1,
56
+ }
57
+ }
58
+
59
+ if (h.length === 4) {
60
+ // #rgba -> #rrggbbaa
61
+ return {
62
+ r: parseInt(h[0] + h[0], 16),
63
+ g: parseInt(h[1] + h[1], 16),
64
+ b: parseInt(h[2] + h[2], 16),
65
+ a: parseInt(h[3] + h[3], 16) / 255,
66
+ }
67
+ }
68
+
69
+ if (h.length === 6) {
70
+ return {
71
+ r: parseInt(h.slice(0, 2), 16),
72
+ g: parseInt(h.slice(2, 4), 16),
73
+ b: parseInt(h.slice(4, 6), 16),
74
+ a: 1,
75
+ }
76
+ }
77
+
78
+ if (h.length === 8) {
79
+ return {
80
+ r: parseInt(h.slice(0, 2), 16),
81
+ g: parseInt(h.slice(2, 4), 16),
82
+ b: parseInt(h.slice(4, 6), 16),
83
+ a: parseInt(h.slice(6, 8), 16) / 255,
84
+ }
85
+ }
86
+
87
+ return { r: 0, g: 0, b: 0, a: 1 }
88
+ }
89
+
90
+ function parseRgb(str: string): RGBA {
91
+ // rgb(255, 0, 0) or rgba(255, 0, 0, 0.5)
92
+ // Also supports spaces: rgb(255 0 0) and rgb(255 0 0 / 0.5)
93
+ const match = str.match(/rgba?\(\s*(\d+)\s*[,\s]\s*(\d+)\s*[,\s]\s*(\d+)\s*(?:[,/]\s*([\d.]+))?\s*\)/)
94
+ if (match) {
95
+ return {
96
+ r: clamp255(parseInt(match[1], 10)),
97
+ g: clamp255(parseInt(match[2], 10)),
98
+ b: clamp255(parseInt(match[3], 10)),
99
+ a: match[4] ? clamp1(parseFloat(match[4])) : 1,
100
+ }
101
+ }
102
+ return { r: 0, g: 0, b: 0, a: 1 }
103
+ }
104
+
105
+ function parseHsl(str: string): RGBA {
106
+ // hsl(0, 100%, 50%) or hsla(0, 100%, 50%, 0.5)
107
+ const match = str.match(/hsla?\(\s*(\d+)\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?\s*(?:[,/]\s*([\d.]+))?\s*\)/)
108
+ if (match) {
109
+ const h = parseFloat(match[1])
110
+ const s = parseFloat(match[2])
111
+ const l = parseFloat(match[3])
112
+ const a = match[4] ? clamp1(parseFloat(match[4])) : 1
113
+ return hslToRgb(h, s, l, a)
114
+ }
115
+ return { r: 0, g: 0, b: 0, a: 1 }
116
+ }
117
+
118
+ /**
119
+ * Convert HSL to RGBA.
120
+ * h: 0-360, s: 0-100, l: 0-100, a: 0-1
121
+ */
122
+ export function hslToRgb(h: number, s: number, l: number, a: number): RGBA {
123
+ // Normalize
124
+ h = ((h % 360) + 360) % 360
125
+ s = clamp1(s / 100)
126
+ l = clamp1(l / 100)
127
+
128
+ const c = (1 - Math.abs(2 * l - 1)) * s
129
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
130
+ const m = l - c / 2
131
+
132
+ let r = 0
133
+ let g = 0
134
+ let b = 0
135
+
136
+ if (h < 60) {
137
+ r = c
138
+ g = x
139
+ b = 0
140
+ } else if (h < 120) {
141
+ r = x
142
+ g = c
143
+ b = 0
144
+ } else if (h < 180) {
145
+ r = 0
146
+ g = c
147
+ b = x
148
+ } else if (h < 240) {
149
+ r = 0
150
+ g = x
151
+ b = c
152
+ } else if (h < 300) {
153
+ r = x
154
+ g = 0
155
+ b = c
156
+ } else {
157
+ r = c
158
+ g = 0
159
+ b = x
160
+ }
161
+
162
+ return {
163
+ r: Math.round((r + m) * 255),
164
+ g: Math.round((g + m) * 255),
165
+ b: Math.round((b + m) * 255),
166
+ a,
167
+ }
168
+ }
169
+
170
+ function clamp255(n: number): number {
171
+ return Math.max(0, Math.min(255, Math.round(n)))
172
+ }
173
+
174
+ function clamp1(n: number): number {
175
+ return Math.max(0, Math.min(1, n))
176
+ }
177
+
178
+ /**
179
+ * Check if a value looks like a color input.
180
+ */
181
+ export function isColorInput(value: unknown): value is ColorInput {
182
+ if (typeof value === "string") {
183
+ const s = value.trim().toLowerCase()
184
+ return s.startsWith("#") || s.startsWith("rgb") || s.startsWith("hsl")
185
+ }
186
+ if (typeof value === "object" && value !== null) {
187
+ const obj = value as Record<string, unknown>
188
+ return typeof obj.r === "number" && typeof obj.g === "number" && typeof obj.b === "number"
189
+ }
190
+ return false
191
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Simple event emitter base class for MotionValue and ColorMotionValue.
3
+ */
4
+
5
+ export type EventName = "change" | "animationStart" | "animationComplete"
6
+ export type Subscriber<T> = (value: T) => void
7
+
8
+ /**
9
+ * Base class providing event subscription functionality.
10
+ */
11
+ export abstract class EventEmitter<T = unknown> {
12
+ protected subscribers = new Map<EventName, Set<Subscriber<any>>>()
13
+
14
+ /** Subscribe to events */
15
+ on<E extends EventName>(event: E, callback: Subscriber<E extends "change" ? T : void>): () => void {
16
+ if (!this.subscribers.has(event)) {
17
+ this.subscribers.set(event, new Set())
18
+ }
19
+ this.subscribers.get(event)?.add(callback)
20
+ return () => this.subscribers.get(event)?.delete(callback)
21
+ }
22
+
23
+ /** Notify all subscribers of an event */
24
+ protected notify(event: EventName, value?: unknown) {
25
+ const subs = this.subscribers.get(event)
26
+ if (subs) {
27
+ for (const cb of subs) cb(value)
28
+ }
29
+ }
30
+
31
+ /** Clear all subscriptions */
32
+ protected clearSubscribers() {
33
+ this.subscribers.clear()
34
+ }
35
+ }
@@ -0,0 +1,59 @@
1
+ // setTimeout-based frame loop for Node/TUI (no requestAnimationFrame)
2
+
3
+ import { useEffect, useRef } from "react"
4
+ import { DEFAULT_FPS } from "../constants.js"
5
+
6
+ type FrameCallback = (time: number, delta: number) => void
7
+
8
+ const subscribers = new Set<FrameCallback>()
9
+ let timer: ReturnType<typeof setTimeout> | null = null
10
+ let lastTime = 0
11
+ const FRAME_MS = 1000 / DEFAULT_FPS
12
+
13
+ function tick() {
14
+ const now = Date.now()
15
+ const delta = lastTime ? now - lastTime : FRAME_MS
16
+ lastTime = now
17
+
18
+ for (const cb of subscribers) {
19
+ cb(now, delta)
20
+ }
21
+
22
+ if (subscribers.size > 0) {
23
+ timer = setTimeout(tick, FRAME_MS)
24
+ } else {
25
+ timer = null
26
+ lastTime = 0
27
+ }
28
+ }
29
+
30
+ export function subscribeFrame(callback: FrameCallback): () => void {
31
+ subscribers.add(callback)
32
+ if (!timer) {
33
+ lastTime = Date.now()
34
+ timer = setTimeout(tick, FRAME_MS)
35
+ }
36
+ return () => {
37
+ subscribers.delete(callback)
38
+ if (subscribers.size === 0 && timer) {
39
+ clearTimeout(timer)
40
+ timer = null
41
+ lastTime = 0
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Hook that calls callback on every animation frame.
48
+ * Similar to Motion's useAnimationFrame.
49
+ */
50
+ export function useAnimationFrame(callback: FrameCallback) {
51
+ const callbackRef = useRef(callback)
52
+ callbackRef.current = callback
53
+
54
+ useEffect(() => {
55
+ return subscribeFrame((time, delta) => {
56
+ callbackRef.current(time, delta)
57
+ })
58
+ }, [])
59
+ }