@things-factory/board-ai 10.0.0-beta.108

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 (329) hide show
  1. package/PLAN-styling-tools.md +1253 -0
  2. package/client/components/board-ai-chat.test.ts +149 -0
  3. package/client/components/board-ai-chat.ts +2574 -0
  4. package/client/components/chat-echo-dedup.test.ts +116 -0
  5. package/client/components/chat-echo-dedup.ts +89 -0
  6. package/client/components/chat-input-builder.ts +47 -0
  7. package/client/components/default-catalog-entries.test.ts +59 -0
  8. package/client/components/default-catalog-entries.ts +100 -0
  9. package/client/components/default-slash-templates.test.ts +55 -0
  10. package/client/components/default-slash-templates.ts +129 -0
  11. package/client/components/markdown.test.ts +518 -0
  12. package/client/components/markdown.ts +508 -0
  13. package/client/components/mention-popup-helpers.ts +433 -0
  14. package/client/components/mention-popup.test.ts +588 -0
  15. package/client/components/mention-popup.ts +238 -0
  16. package/client/index.ts +11 -0
  17. package/client/tsconfig.json +13 -0
  18. package/client/utils/board-edit-patch.ts +374 -0
  19. package/config/config.development.js +43 -0
  20. package/config/config.production.js +15 -0
  21. package/dist-client/client/components/board-ai-chat.d.ts +277 -0
  22. package/dist-client/client/components/board-ai-chat.js +2444 -0
  23. package/dist-client/client/components/board-ai-chat.js.map +1 -0
  24. package/dist-client/client/components/board-ai-chat.test.d.ts +1 -0
  25. package/dist-client/client/components/board-ai-chat.test.js +138 -0
  26. package/dist-client/client/components/board-ai-chat.test.js.map +1 -0
  27. package/dist-client/client/components/chat-echo-dedup.d.ts +45 -0
  28. package/dist-client/client/components/chat-echo-dedup.js +58 -0
  29. package/dist-client/client/components/chat-echo-dedup.js.map +1 -0
  30. package/dist-client/client/components/chat-echo-dedup.test.d.ts +1 -0
  31. package/dist-client/client/components/chat-echo-dedup.test.js +101 -0
  32. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -0
  33. package/dist-client/client/components/chat-input-builder.d.ts +39 -0
  34. package/dist-client/client/components/chat-input-builder.js +26 -0
  35. package/dist-client/client/components/chat-input-builder.js.map +1 -0
  36. package/dist-client/client/components/default-catalog-entries.d.ts +15 -0
  37. package/dist-client/client/components/default-catalog-entries.js +86 -0
  38. package/dist-client/client/components/default-catalog-entries.js.map +1 -0
  39. package/dist-client/client/components/default-catalog-entries.test.d.ts +1 -0
  40. package/dist-client/client/components/default-catalog-entries.test.js +52 -0
  41. package/dist-client/client/components/default-catalog-entries.test.js.map +1 -0
  42. package/dist-client/client/components/default-slash-templates.d.ts +17 -0
  43. package/dist-client/client/components/default-slash-templates.js +113 -0
  44. package/dist-client/client/components/default-slash-templates.js.map +1 -0
  45. package/dist-client/client/components/default-slash-templates.test.d.ts +1 -0
  46. package/dist-client/client/components/default-slash-templates.test.js +51 -0
  47. package/dist-client/client/components/default-slash-templates.test.js.map +1 -0
  48. package/dist-client/client/components/markdown.d.ts +60 -0
  49. package/dist-client/client/components/markdown.js +422 -0
  50. package/dist-client/client/components/markdown.js.map +1 -0
  51. package/dist-client/client/components/markdown.test.d.ts +1 -0
  52. package/dist-client/client/components/markdown.test.js +418 -0
  53. package/dist-client/client/components/markdown.test.js.map +1 -0
  54. package/dist-client/client/components/mention-popup-helpers.d.ts +165 -0
  55. package/dist-client/client/components/mention-popup-helpers.js +330 -0
  56. package/dist-client/client/components/mention-popup-helpers.js.map +1 -0
  57. package/dist-client/client/components/mention-popup.d.ts +35 -0
  58. package/dist-client/client/components/mention-popup.js +223 -0
  59. package/dist-client/client/components/mention-popup.js.map +1 -0
  60. package/dist-client/client/components/mention-popup.test.d.ts +1 -0
  61. package/dist-client/client/components/mention-popup.test.js +474 -0
  62. package/dist-client/client/components/mention-popup.test.js.map +1 -0
  63. package/dist-client/client/index.d.ts +11 -0
  64. package/dist-client/client/index.js +12 -0
  65. package/dist-client/client/index.js.map +1 -0
  66. package/dist-client/client/utils/board-edit-patch.d.ts +157 -0
  67. package/dist-client/client/utils/board-edit-patch.js +284 -0
  68. package/dist-client/client/utils/board-edit-patch.js.map +1 -0
  69. package/dist-client/server/service/agentic-loop.d.ts +87 -0
  70. package/dist-client/server/service/agentic-loop.js +276 -0
  71. package/dist-client/server/service/agentic-loop.js.map +1 -0
  72. package/dist-client/server/service/assistant.d.ts +84 -0
  73. package/dist-client/server/service/assistant.js +2220 -0
  74. package/dist-client/server/service/assistant.js.map +1 -0
  75. package/dist-client/server/service/catalog-resolver.d.ts +18 -0
  76. package/dist-client/server/service/catalog-resolver.js +93 -0
  77. package/dist-client/server/service/catalog-resolver.js.map +1 -0
  78. package/dist-client/server/service/component-tree.d.ts +68 -0
  79. package/dist-client/server/service/component-tree.js +68 -0
  80. package/dist-client/server/service/component-tree.js.map +1 -0
  81. package/dist-client/server/service/mention-resolver.d.ts +96 -0
  82. package/dist-client/server/service/mention-resolver.js +172 -0
  83. package/dist-client/server/service/mention-resolver.js.map +1 -0
  84. package/dist-client/server/service/prefab-registry.d.ts +73 -0
  85. package/dist-client/server/service/prefab-registry.js +252 -0
  86. package/dist-client/server/service/prefab-registry.js.map +1 -0
  87. package/dist-client/server/service/styling/clear-tools.d.ts +15 -0
  88. package/dist-client/server/service/styling/clear-tools.js +50 -0
  89. package/dist-client/server/service/styling/clear-tools.js.map +1 -0
  90. package/dist-client/server/service/styling/color-utils.d.ts +43 -0
  91. package/dist-client/server/service/styling/color-utils.js +205 -0
  92. package/dist-client/server/service/styling/color-utils.js.map +1 -0
  93. package/dist-client/server/service/styling/copy-tools.d.ts +27 -0
  94. package/dist-client/server/service/styling/copy-tools.js +78 -0
  95. package/dist-client/server/service/styling/copy-tools.js.map +1 -0
  96. package/dist-client/server/service/styling/effect-tools.d.ts +45 -0
  97. package/dist-client/server/service/styling/effect-tools.js +85 -0
  98. package/dist-client/server/service/styling/effect-tools.js.map +1 -0
  99. package/dist-client/server/service/styling/fill-tools.d.ts +60 -0
  100. package/dist-client/server/service/styling/fill-tools.js +226 -0
  101. package/dist-client/server/service/styling/fill-tools.js.map +1 -0
  102. package/dist-client/server/service/styling/material-tools.d.ts +32 -0
  103. package/dist-client/server/service/styling/material-tools.js +88 -0
  104. package/dist-client/server/service/styling/material-tools.js.map +1 -0
  105. package/dist-client/server/service/styling/read-tools.d.ts +34 -0
  106. package/dist-client/server/service/styling/read-tools.js +42 -0
  107. package/dist-client/server/service/styling/read-tools.js.map +1 -0
  108. package/dist-client/server/service/styling/registry.d.ts +91 -0
  109. package/dist-client/server/service/styling/registry.js +345 -0
  110. package/dist-client/server/service/styling/registry.js.map +1 -0
  111. package/dist-client/server/service/styling/stroke-tools.d.ts +35 -0
  112. package/dist-client/server/service/styling/stroke-tools.js +94 -0
  113. package/dist-client/server/service/styling/stroke-tools.js.map +1 -0
  114. package/dist-client/server/service/styling/text-tools.d.ts +32 -0
  115. package/dist-client/server/service/styling/text-tools.js +101 -0
  116. package/dist-client/server/service/styling/text-tools.js.map +1 -0
  117. package/dist-client/server/service/types.d.ts +265 -0
  118. package/dist-client/server/service/types.js +2 -0
  119. package/dist-client/server/service/types.js.map +1 -0
  120. package/dist-client/server/service/validation/board-model-schema.d.ts +8311 -0
  121. package/dist-client/server/service/validation/board-model-schema.js +402 -0
  122. package/dist-client/server/service/validation/board-model-schema.js.map +1 -0
  123. package/dist-client/server/service/validation/tool-validation.d.ts +31 -0
  124. package/dist-client/server/service/validation/tool-validation.js +193 -0
  125. package/dist-client/server/service/validation/tool-validation.js.map +1 -0
  126. package/dist-client/tsconfig.tsbuildinfo +1 -0
  127. package/dist-server/index.d.ts +23 -0
  128. package/dist-server/index.js +27 -0
  129. package/dist-server/index.js.map +1 -0
  130. package/dist-server/service/agentic-loop.d.ts +87 -0
  131. package/dist-server/service/agentic-loop.js +280 -0
  132. package/dist-server/service/agentic-loop.js.map +1 -0
  133. package/dist-server/service/apply-patch.d.ts +47 -0
  134. package/dist-server/service/apply-patch.js +369 -0
  135. package/dist-server/service/apply-patch.js.map +1 -0
  136. package/dist-server/service/assistant.d.ts +84 -0
  137. package/dist-server/service/assistant.js +2237 -0
  138. package/dist-server/service/assistant.js.map +1 -0
  139. package/dist-server/service/board-ai-resolver.d.ts +54 -0
  140. package/dist-server/service/board-ai-resolver.js +409 -0
  141. package/dist-server/service/board-ai-resolver.js.map +1 -0
  142. package/dist-server/service/catalog-resolver.d.ts +18 -0
  143. package/dist-server/service/catalog-resolver.js +98 -0
  144. package/dist-server/service/catalog-resolver.js.map +1 -0
  145. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  146. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  147. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  148. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  149. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  150. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  151. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  152. package/dist-server/service/chat-message/chat-message-subscription.js +53 -0
  153. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  154. package/dist-server/service/chat-message/chat-message.d.ts +56 -0
  155. package/dist-server/service/chat-message/chat-message.js +149 -0
  156. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  157. package/dist-server/service/chat-message/index.d.ts +5 -0
  158. package/dist-server/service/chat-message/index.js +12 -0
  159. package/dist-server/service/chat-message/index.js.map +1 -0
  160. package/dist-server/service/chat-session/chat-session.d.ts +31 -0
  161. package/dist-server/service/chat-session/chat-session.js +131 -0
  162. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  163. package/dist-server/service/chat-session/index.d.ts +3 -0
  164. package/dist-server/service/chat-session/index.js +7 -0
  165. package/dist-server/service/chat-session/index.js.map +1 -0
  166. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +30 -0
  167. package/dist-server/service/chat-session-participant/chat-session-participant.js +112 -0
  168. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
  169. package/dist-server/service/chat-session-participant/index.d.ts +4 -0
  170. package/dist-server/service/chat-session-participant/index.js +10 -0
  171. package/dist-server/service/chat-session-participant/index.js.map +1 -0
  172. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  173. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  174. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  175. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  176. package/dist-server/service/chat-session-participant/presence.js +22 -0
  177. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  178. package/dist-server/service/chat-session-resolver.d.ts +20 -0
  179. package/dist-server/service/chat-session-resolver.js +356 -0
  180. package/dist-server/service/chat-session-resolver.js.map +1 -0
  181. package/dist-server/service/component-tree.d.ts +68 -0
  182. package/dist-server/service/component-tree.js +73 -0
  183. package/dist-server/service/component-tree.js.map +1 -0
  184. package/dist-server/service/index.d.ts +20 -0
  185. package/dist-server/service/index.js +34 -0
  186. package/dist-server/service/index.js.map +1 -0
  187. package/dist-server/service/mention-marker.d.ts +78 -0
  188. package/dist-server/service/mention-marker.js +162 -0
  189. package/dist-server/service/mention-marker.js.map +1 -0
  190. package/dist-server/service/mention-resolver.d.ts +96 -0
  191. package/dist-server/service/mention-resolver.js +177 -0
  192. package/dist-server/service/mention-resolver.js.map +1 -0
  193. package/dist-server/service/patch-entry/board-patch-broadcast.d.ts +13 -0
  194. package/dist-server/service/patch-entry/board-patch-broadcast.js +13 -0
  195. package/dist-server/service/patch-entry/board-patch-broadcast.js.map +1 -0
  196. package/dist-server/service/patch-entry/board-patch-subscription.d.ts +12 -0
  197. package/dist-server/service/patch-entry/board-patch-subscription.js +63 -0
  198. package/dist-server/service/patch-entry/board-patch-subscription.js.map +1 -0
  199. package/dist-server/service/patch-entry/index.d.ts +4 -0
  200. package/dist-server/service/patch-entry/index.js +12 -0
  201. package/dist-server/service/patch-entry/index.js.map +1 -0
  202. package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
  203. package/dist-server/service/patch-entry/patch-entry.js +96 -0
  204. package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
  205. package/dist-server/service/prefab-registry.d.ts +73 -0
  206. package/dist-server/service/prefab-registry.js +258 -0
  207. package/dist-server/service/prefab-registry.js.map +1 -0
  208. package/dist-server/service/styling/clear-tools.d.ts +15 -0
  209. package/dist-server/service/styling/clear-tools.js +53 -0
  210. package/dist-server/service/styling/clear-tools.js.map +1 -0
  211. package/dist-server/service/styling/color-utils.d.ts +43 -0
  212. package/dist-server/service/styling/color-utils.js +210 -0
  213. package/dist-server/service/styling/color-utils.js.map +1 -0
  214. package/dist-server/service/styling/copy-tools.d.ts +27 -0
  215. package/dist-server/service/styling/copy-tools.js +81 -0
  216. package/dist-server/service/styling/copy-tools.js.map +1 -0
  217. package/dist-server/service/styling/effect-tools.d.ts +45 -0
  218. package/dist-server/service/styling/effect-tools.js +90 -0
  219. package/dist-server/service/styling/effect-tools.js.map +1 -0
  220. package/dist-server/service/styling/fill-tools.d.ts +60 -0
  221. package/dist-server/service/styling/fill-tools.js +229 -0
  222. package/dist-server/service/styling/fill-tools.js.map +1 -0
  223. package/dist-server/service/styling/material-tools.d.ts +32 -0
  224. package/dist-server/service/styling/material-tools.js +92 -0
  225. package/dist-server/service/styling/material-tools.js.map +1 -0
  226. package/dist-server/service/styling/read-tools.d.ts +34 -0
  227. package/dist-server/service/styling/read-tools.js +45 -0
  228. package/dist-server/service/styling/read-tools.js.map +1 -0
  229. package/dist-server/service/styling/registry.d.ts +91 -0
  230. package/dist-server/service/styling/registry.js +352 -0
  231. package/dist-server/service/styling/registry.js.map +1 -0
  232. package/dist-server/service/styling/stroke-tools.d.ts +35 -0
  233. package/dist-server/service/styling/stroke-tools.js +97 -0
  234. package/dist-server/service/styling/stroke-tools.js.map +1 -0
  235. package/dist-server/service/styling/text-tools.d.ts +32 -0
  236. package/dist-server/service/styling/text-tools.js +105 -0
  237. package/dist-server/service/styling/text-tools.js.map +1 -0
  238. package/dist-server/service/subscribe-auth.d.ts +14 -0
  239. package/dist-server/service/subscribe-auth.js +33 -0
  240. package/dist-server/service/subscribe-auth.js.map +1 -0
  241. package/dist-server/service/types.d.ts +265 -0
  242. package/dist-server/service/types.js +3 -0
  243. package/dist-server/service/types.js.map +1 -0
  244. package/dist-server/service/validation/board-model-schema.d.ts +8311 -0
  245. package/dist-server/service/validation/board-model-schema.js +409 -0
  246. package/dist-server/service/validation/board-model-schema.js.map +1 -0
  247. package/dist-server/service/validation/tool-validation.d.ts +31 -0
  248. package/dist-server/service/validation/tool-validation.js +196 -0
  249. package/dist-server/service/validation/tool-validation.js.map +1 -0
  250. package/dist-server/tsconfig.tsbuildinfo +1 -0
  251. package/package.json +48 -0
  252. package/server/index.ts +23 -0
  253. package/server/service/agentic-loop.test.ts +705 -0
  254. package/server/service/agentic-loop.ts +431 -0
  255. package/server/service/apply-patch-drift.test.ts +829 -0
  256. package/server/service/apply-patch.test.ts +757 -0
  257. package/server/service/apply-patch.ts +411 -0
  258. package/server/service/assistant-integration.test.ts +922 -0
  259. package/server/service/assistant-llm-smoke.test.ts +296 -0
  260. package/server/service/assistant.test.ts +2034 -0
  261. package/server/service/assistant.ts +2378 -0
  262. package/server/service/board-ai-resolver.ts +407 -0
  263. package/server/service/catalog-resolver.test.ts +137 -0
  264. package/server/service/catalog-resolver.ts +102 -0
  265. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  266. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  267. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  268. package/server/service/chat-message/chat-message-publish.ts +22 -0
  269. package/server/service/chat-message/chat-message-subscription.ts +41 -0
  270. package/server/service/chat-message/chat-message.ts +169 -0
  271. package/server/service/chat-message/index.ts +12 -0
  272. package/server/service/chat-session/chat-session.ts +130 -0
  273. package/server/service/chat-session/index.ts +5 -0
  274. package/server/service/chat-session-participant/chat-session-participant.ts +110 -0
  275. package/server/service/chat-session-participant/index.ts +6 -0
  276. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  277. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  278. package/server/service/chat-session-participant/presence.test.ts +39 -0
  279. package/server/service/chat-session-participant/presence.ts +21 -0
  280. package/server/service/chat-session-resolver.ts +312 -0
  281. package/server/service/component-tree.test.ts +248 -0
  282. package/server/service/component-tree.ts +135 -0
  283. package/server/service/index.ts +32 -0
  284. package/server/service/mention-marker.extract.test.ts +61 -0
  285. package/server/service/mention-marker.test.ts +228 -0
  286. package/server/service/mention-marker.ts +184 -0
  287. package/server/service/mention-resolver.test.ts +273 -0
  288. package/server/service/mention-resolver.ts +264 -0
  289. package/server/service/patch-entry/board-patch-broadcast.ts +24 -0
  290. package/server/service/patch-entry/board-patch-subscription.test.ts +40 -0
  291. package/server/service/patch-entry/board-patch-subscription.ts +50 -0
  292. package/server/service/patch-entry/index.ts +12 -0
  293. package/server/service/patch-entry/patch-entry.ts +89 -0
  294. package/server/service/prefab-registry.test.ts +172 -0
  295. package/server/service/prefab-registry.ts +332 -0
  296. package/server/service/styling/clear-tools.test.ts +164 -0
  297. package/server/service/styling/clear-tools.ts +69 -0
  298. package/server/service/styling/color-utils.test.ts +130 -0
  299. package/server/service/styling/color-utils.ts +226 -0
  300. package/server/service/styling/copy-tools.test.ts +152 -0
  301. package/server/service/styling/copy-tools.ts +115 -0
  302. package/server/service/styling/effect-tools.test.ts +277 -0
  303. package/server/service/styling/effect-tools.ts +124 -0
  304. package/server/service/styling/fill-tools.test.ts +331 -0
  305. package/server/service/styling/fill-tools.ts +293 -0
  306. package/server/service/styling/material-tools.test.ts +294 -0
  307. package/server/service/styling/material-tools.ts +114 -0
  308. package/server/service/styling/read-tools.test.ts +78 -0
  309. package/server/service/styling/read-tools.ts +67 -0
  310. package/server/service/styling/registry.test.ts +203 -0
  311. package/server/service/styling/registry.ts +423 -0
  312. package/server/service/styling/stroke-tools.test.ts +245 -0
  313. package/server/service/styling/stroke-tools.ts +145 -0
  314. package/server/service/styling/text-tools.test.ts +253 -0
  315. package/server/service/styling/text-tools.ts +131 -0
  316. package/server/service/styling/tier1-tools.test.ts +300 -0
  317. package/server/service/subscribe-auth.test.ts +57 -0
  318. package/server/service/subscribe-auth.ts +34 -0
  319. package/server/service/types.ts +235 -0
  320. package/server/service/validation/board-model-schema.test.ts +778 -0
  321. package/server/service/validation/board-model-schema.ts +446 -0
  322. package/server/service/validation/tool-validation.ts +228 -0
  323. package/things-factory.config.js +1 -0
  324. package/translations/en.json +49 -0
  325. package/translations/ja.json +48 -0
  326. package/translations/ko.json +49 -0
  327. package/translations/ms.json +48 -0
  328. package/translations/zh.json +48 -0
  329. package/tsconfig.json +9 -0
@@ -0,0 +1,331 @@
1
+ /**
2
+ * fill-tools — setFill 입력 → BoardEditOp[] 변환 검증.
3
+ *
4
+ * 4 modes (none/solid/gradient/pattern) 별 동작 + 검증 + multi-target.
5
+ *
6
+ * 출력 정책: refid 당 1 op. mode 변경 시에도 fillStyle 통째 교체.
7
+ * (이전 2-op (null + new) 방식은 중간 null 상태가 3D 텍스처 크래시 유발 → 폐지.)
8
+ */
9
+ import { buildSetFillOps } from './fill-tools'
10
+
11
+ describe('buildSetFillOps — 입력 검증', () => {
12
+ test('refids 비어있으면 에러', () => {
13
+ const r = buildSetFillOps({ refids: [], mode: 'solid', color: 'red' })
14
+ expect(r.ops).toEqual([])
15
+ expect(r.errors[0]).toMatch(/non-empty array/)
16
+ })
17
+
18
+ test('비-숫자 refids 필터링', () => {
19
+ const r = buildSetFillOps({
20
+ refids: [1, 'abc' as any, NaN, 2],
21
+ mode: 'solid',
22
+ color: 'red'
23
+ })
24
+ // refid 당 1 op → 2 valid * 1 = 2
25
+ expect(r.ops).toHaveLength(2)
26
+ expect(r.ops[0]).toMatchObject({ op: 'modify', refid: 1, patch: { fillStyle: '#ff0000' } })
27
+ expect(r.ops[1]).toMatchObject({ op: 'modify', refid: 2, patch: { fillStyle: '#ff0000' } })
28
+ })
29
+
30
+ test('아무 인자도 없으면 에러', () => {
31
+ const r = buildSetFillOps({ refids: [1] } as any)
32
+ expect(r.ops).toEqual([])
33
+ expect(r.errors[0]).toMatch(/No fill changes/)
34
+ })
35
+
36
+ test('잘못된 색상 → 에러', () => {
37
+ const r = buildSetFillOps({ refids: [1], mode: 'solid', color: 'reddish' })
38
+ expect(r.ops).toEqual([])
39
+ expect(r.errors[0]).toMatch(/Invalid color/)
40
+ })
41
+
42
+ test('opacity 범위 밖 → 에러', () => {
43
+ const r = buildSetFillOps({ refids: [1], mode: 'solid', color: 'red', opacity: 1.5 })
44
+ expect(r.ops).toEqual([])
45
+ expect(r.errors[0]).toMatch(/Opacity out of/)
46
+ })
47
+ })
48
+
49
+ describe('buildSetFillOps — solid mode (string fillStyle)', () => {
50
+ test('solid → fillStyle 색상 string', () => {
51
+ const r = buildSetFillOps({ refids: [35], mode: 'solid', color: 'red' })
52
+ expect(r.errors).toEqual([])
53
+ expect(r.ops).toHaveLength(1)
54
+ expect(r.ops[0]).toEqual({ op: 'modify', refid: 35, patch: { fillStyle: '#ff0000' } })
55
+ })
56
+
57
+ test('opacity → rgba 문자열에 alpha 포함', () => {
58
+ const r = buildSetFillOps({ refids: [1], mode: 'solid', color: 'blue', opacity: 0.5 })
59
+ expect(r.ops[0].patch).toEqual({ fillStyle: 'rgba(0,0,255,0.5)' })
60
+ })
61
+
62
+ test('opacity 1 → alpha 안 포함 (그냥 hex)', () => {
63
+ const r = buildSetFillOps({ refids: [1], mode: 'solid', color: 'blue', opacity: 1 })
64
+ expect(r.ops[0].patch).toEqual({ fillStyle: '#0000ff' })
65
+ })
66
+
67
+ test('semantic 색상 (warm-red)', () => {
68
+ const r = buildSetFillOps({ refids: [1], mode: 'solid', color: 'warm-red' })
69
+ expect(r.ops[0].patch).toEqual({ fillStyle: '#e74c3c' })
70
+ })
71
+
72
+ test('multi-target — 3 refid * 1 op = 3', () => {
73
+ const r = buildSetFillOps({
74
+ refids: [10, 20, 30],
75
+ mode: 'solid',
76
+ color: 'green'
77
+ })
78
+ expect(r.ops).toHaveLength(3)
79
+ expect(r.ops[0]).toMatchObject({ refid: 10, patch: { fillStyle: '#008000' } })
80
+ expect(r.ops[2]).toMatchObject({ refid: 30, patch: { fillStyle: '#008000' } })
81
+ })
82
+
83
+ test('color 없이 mode=solid → null', () => {
84
+ const r = buildSetFillOps({ refids: [1], mode: 'solid' })
85
+ expect(r.ops).toEqual([])
86
+ })
87
+ })
88
+
89
+ describe('buildSetFillOps — gradient mode (things-scene colorStops 형식)', () => {
90
+ test('명시 stops + rotation degrees → 라디안 변환', () => {
91
+ const r = buildSetFillOps({
92
+ refids: [1],
93
+ mode: 'gradient',
94
+ colorStops: [
95
+ { color: 'red', position: 0 },
96
+ { color: 'blue', position: 1 }
97
+ ],
98
+ rotation: 90
99
+ })
100
+ expect(r.ops).toHaveLength(1)
101
+ const fs = (r.ops[0] as any).patch.fillStyle
102
+ expect(fs.type).toBe('gradient')
103
+ expect(fs.gradientType).toBe('linear')
104
+ expect(fs.colorStops).toEqual([
105
+ { position: 0, color: '#ff0000' },
106
+ { position: 1, color: '#0000ff' }
107
+ ])
108
+ expect(fs.rotation).toBeCloseTo(Math.PI / 2)
109
+ // 잘못된 stale 필드 (colors/stops) 없어야
110
+ expect(fs.colors).toBeUndefined()
111
+ expect(fs.stops).toBeUndefined()
112
+ })
113
+
114
+ test('color 만 → 자동 colorStops 생성 (어두운 변형)', () => {
115
+ const r = buildSetFillOps({ refids: [1], mode: 'gradient', color: 'red' })
116
+ expect(r.ops).toHaveLength(1)
117
+ const fs = (r.ops[0] as any).patch.fillStyle
118
+ expect(fs.type).toBe('gradient')
119
+ expect(fs.colorStops).toHaveLength(2)
120
+ expect(fs.colorStops[0]).toEqual({ position: 0, color: '#ff0000' })
121
+ expect(fs.colorStops[1].color).not.toBe('#ff0000') // 어두워야
122
+ expect(fs.colorStops[1].position).toBe(1)
123
+ // rotation 미지정 → 0 default (drawer 의 NaN 크래시 방지)
124
+ expect(fs.rotation).toBe(0)
125
+ })
126
+
127
+ test('rotation 미지정 시 0 default — createLinearGradient 크래시 방지', () => {
128
+ const r = buildSetFillOps({
129
+ refids: [1],
130
+ mode: 'gradient',
131
+ colorStops: [
132
+ { color: 'red', position: 0 },
133
+ { color: 'blue', position: 1 }
134
+ ]
135
+ })
136
+ const fs = (r.ops[0] as any).patch.fillStyle
137
+ expect(fs.rotation).toBe(0)
138
+ })
139
+
140
+ test('radial 명시', () => {
141
+ const r = buildSetFillOps({
142
+ refids: [1],
143
+ mode: 'gradient',
144
+ gradientType: 'radial',
145
+ colorStops: [
146
+ { color: 'white', position: 0 },
147
+ { color: 'black', position: 1 }
148
+ ]
149
+ })
150
+ expect((r.ops[0] as any).patch.fillStyle.gradientType).toBe('radial')
151
+ })
152
+
153
+ test('3 stops — 발리바다 시나리오', () => {
154
+ const r = buildSetFillOps({
155
+ refids: [14],
156
+ mode: 'gradient',
157
+ gradientType: 'linear',
158
+ rotation: 90,
159
+ colorStops: [
160
+ { color: '#00bfa5', position: 0 },
161
+ { color: '#00e5ff', position: 0.5 },
162
+ { color: '#0288d1', position: 1 }
163
+ ]
164
+ })
165
+ const fs = (r.ops[0] as any).patch.fillStyle
166
+ expect(fs.colorStops).toEqual([
167
+ { position: 0, color: '#00bfa5' },
168
+ { position: 0.5, color: '#00e5ff' },
169
+ { position: 1, color: '#0288d1' }
170
+ ])
171
+ })
172
+
173
+ test('stops position 범위 밖 → 에러', () => {
174
+ const r = buildSetFillOps({
175
+ refids: [1],
176
+ mode: 'gradient',
177
+ colorStops: [
178
+ { color: 'red', position: 0 },
179
+ { color: 'blue', position: 1.5 }
180
+ ]
181
+ })
182
+ expect(r.ops).toEqual([])
183
+ expect(r.errors[0]).toMatch(/position out of/)
184
+ })
185
+
186
+ test('stop 색상 잘못 → 에러', () => {
187
+ const r = buildSetFillOps({
188
+ refids: [1],
189
+ mode: 'gradient',
190
+ colorStops: [
191
+ { color: 'invalid', position: 0 },
192
+ { color: 'blue', position: 1 }
193
+ ]
194
+ })
195
+ expect(r.ops).toEqual([])
196
+ expect(r.errors[0]).toMatch(/Invalid gradient stop color/)
197
+ })
198
+ })
199
+
200
+ describe('buildSetFillOps — pattern mode (things-scene 실제 필드)', () => {
201
+ test('기본 — image 만', () => {
202
+ const r = buildSetFillOps({
203
+ refids: [1],
204
+ mode: 'pattern',
205
+ image: 'https://example.com/p.png'
206
+ })
207
+ expect(r.ops).toHaveLength(1)
208
+ expect(r.ops[0].patch).toEqual({
209
+ fillStyle: { type: 'pattern', image: 'https://example.com/p.png' }
210
+ })
211
+ })
212
+
213
+ test('image 없으면 에러', () => {
214
+ const r = buildSetFillOps({ refids: [1], mode: 'pattern' })
215
+ expect(r.ops).toEqual([])
216
+ expect(r.errors[0]).toMatch(/requires 'image'/)
217
+ })
218
+
219
+ test('fitPattern + align + offset 등 things-scene 필드 통과', () => {
220
+ const r = buildSetFillOps({
221
+ refids: [1],
222
+ mode: 'pattern',
223
+ image: 'logo.png',
224
+ fitPattern: false,
225
+ align: 'center',
226
+ offsetX: 10,
227
+ offsetY: 5,
228
+ noRepeat: true,
229
+ opacity: 0.7,
230
+ color: 'navy'
231
+ })
232
+ expect((r.ops[0] as any).patch.fillStyle).toEqual({
233
+ type: 'pattern',
234
+ image: 'logo.png',
235
+ color: '#000080',
236
+ fitPattern: false,
237
+ noRepeat: true,
238
+ align: 'center',
239
+ offsetX: 10,
240
+ offsetY: 5,
241
+ opacity: 0.7
242
+ })
243
+ })
244
+
245
+ test('patternWidth / patternHeight → width / height 매핑', () => {
246
+ const r = buildSetFillOps({
247
+ refids: [1],
248
+ mode: 'pattern',
249
+ image: 'p.png',
250
+ patternWidth: 100,
251
+ patternHeight: 50
252
+ })
253
+ expect((r.ops[0] as any).patch.fillStyle).toMatchObject({
254
+ width: 100,
255
+ height: 50
256
+ })
257
+ })
258
+ })
259
+
260
+ describe('buildSetFillOps — none mode', () => {
261
+ test('fillStyle 제거 의도 — null 시맨틱 (apply-patch 가 키 자체 제거)', () => {
262
+ const r = buildSetFillOps({ refids: [1], mode: 'none' })
263
+ expect(r.ops).toHaveLength(1)
264
+ expect(r.ops[0].patch).toEqual({ fillStyle: null })
265
+ })
266
+ })
267
+
268
+ describe('buildSetFillOps — 부분 update (mode 미지정 — string 출력)', () => {
269
+ test('color 만 변경 → fillStyle 색상 string', () => {
270
+ const r = buildSetFillOps({ refids: [1], color: 'green' })
271
+ expect(r.ops).toHaveLength(1)
272
+ expect(r.ops[0].patch).toEqual({ fillStyle: '#008000' })
273
+ })
274
+
275
+ test('opacity 만 (color 없음) → 의도 모호 → 무동작', () => {
276
+ const r = buildSetFillOps({ refids: [1], opacity: 0.3 })
277
+ expect(r.ops).toEqual([])
278
+ })
279
+
280
+ test('color + opacity → rgba 문자열', () => {
281
+ const r = buildSetFillOps({ refids: [1], color: 'navy', opacity: 0.8 })
282
+ expect(r.ops).toHaveLength(1)
283
+ expect(r.ops[0].patch).toEqual({ fillStyle: 'rgba(0,0,128,0.8)' })
284
+ })
285
+ })
286
+
287
+ describe('buildSetFillOps — 시나리오', () => {
288
+ test('대시보드 — 5개 게이지 동일 다크 배경', () => {
289
+ const r = buildSetFillOps({
290
+ refids: [10, 11, 12, 13, 14],
291
+ mode: 'solid',
292
+ color: '#0a1929',
293
+ opacity: 0.95
294
+ })
295
+ expect(r.ops).toHaveLength(5)
296
+ // opacity 0.95 → rgba 문자열
297
+ expect(r.ops.every(o => (o as any).patch.fillStyle === 'rgba(10,25,41,0.95)')).toBe(true)
298
+ })
299
+
300
+ test('점진 update — 빨강 단색 → 그라디언트 위→아래', () => {
301
+ const r = buildSetFillOps({
302
+ refids: [35],
303
+ mode: 'gradient',
304
+ gradientType: 'linear',
305
+ rotation: 90,
306
+ colorStops: [
307
+ { color: 'red', position: 0 },
308
+ { color: '#660000', position: 1 }
309
+ ]
310
+ })
311
+ expect(r.ops).toHaveLength(1)
312
+ const fs = (r.ops[0] as any).patch.fillStyle
313
+ expect(fs.type).toBe('gradient')
314
+ expect(fs.rotation).toBeCloseTo(Math.PI / 2)
315
+ expect(fs.colorStops).toEqual([
316
+ { position: 0, color: '#ff0000' },
317
+ { position: 1, color: '#660000' }
318
+ ])
319
+ })
320
+
321
+ test('alpha 채널 — solid + 50% opacity', () => {
322
+ const r = buildSetFillOps({
323
+ refids: [1],
324
+ mode: 'solid',
325
+ color: 'rgba(255, 0, 0, 0.5)'
326
+ // color 의 alpha 는 normalizeColor 가 무시 (hex 만 추출). opacity 별 인자로 줘야 적용.
327
+ })
328
+ expect(r.ops).toHaveLength(1)
329
+ expect((r.ops[0] as any).patch.fillStyle).toBe('#ff0000')
330
+ })
331
+ })
@@ -0,0 +1,293 @@
1
+ /**
2
+ * setFill — 컴포넌트 배경 (state.fillStyle) 변경.
3
+ *
4
+ * 4 modes 지원: none / solid / gradient / pattern.
5
+ *
6
+ * 출력 정책 — refid 당 1 op:
7
+ * - solid: fillStyle = '#rrggbb' or 'rgba(...)' (string)
8
+ * - gradient: fillStyle = { type:'gradient', gradientType, colorStops, rotation, ... }
9
+ * - pattern: fillStyle = { type:'pattern', image, ... }
10
+ * - none: fillStyle = { type:'none' }
11
+ * - mode 미지정 + color 만: 부분 update (string 또는 object 병합)
12
+ *
13
+ * things-scene 실제 데이터 형식 (drawer/fill.ts 검증):
14
+ * - gradient: { type, gradientType, colorStops: [{position, color}], rotation (라디안) }
15
+ * - rotation 은 라디안 — `Math.cos(fRotation)` 직접 사용
16
+ *
17
+ * 이전 2-op (null + new) 방식은 중간 null 상태가 onchange / 3D texture
18
+ * 크래시 유발 → 제거. 정확한 colorStops 출력으로 stale 필드 문제는 자체 해결.
19
+ */
20
+ import type { BoardEditOp } from '../types'
21
+ import { parseColor, normalizeColor, validateColor } from './color-utils'
22
+
23
+ export interface SetFillInput {
24
+ refids: number[]
25
+ mode?: 'none' | 'solid' | 'gradient' | 'pattern'
26
+ // solid
27
+ color?: string
28
+ // gradient
29
+ gradientType?: 'linear' | 'radial'
30
+ /** colorStops — things-scene 의 canonical 필드명 그대로. position 0..1, color hex/rgb/named. */
31
+ colorStops?: Array<{ color: string; position: number }>
32
+ /** 회전 — 입력은 degrees (사용자/AI 친화), 내부 변환 후 things-scene 의 라디안으로 저장. */
33
+ rotation?: number
34
+ // pattern (drawer/fill.ts:95~ 검증)
35
+ image?: string
36
+ /** pattern: 영역에 맞춰 비율 조정 (true) vs 원본 크기 사용 (false). */
37
+ fitPattern?: boolean
38
+ /** pattern: 반복 안 함 (true) — 1회만 그림. */
39
+ noRepeat?: boolean
40
+ /** pattern: 정렬. fitPattern=false 일 때만 적용. */
41
+ align?:
42
+ | 'center'
43
+ | 'left-top'
44
+ | 'left-bottom'
45
+ | 'right-top'
46
+ | 'right-bottom'
47
+ | 'left'
48
+ | 'right'
49
+ | 'top'
50
+ | 'bottom'
51
+ /** pattern: 영역 내 오프셋 (fitPattern=false 일 때). */
52
+ offsetX?: number
53
+ offsetY?: number
54
+ /** pattern: 패턴 크기 (기본은 이미지 원본 크기). */
55
+ patternWidth?: number
56
+ patternHeight?: number
57
+ // 공통
58
+ opacity?: number // 0..1
59
+ }
60
+
61
+ export interface SetFillResult {
62
+ ops: BoardEditOp[]
63
+ errors: string[] // refid 별 에러 (성공한 것은 op 만 발사)
64
+ }
65
+
66
+ /**
67
+ * setFill 입력 검증 + BoardEditOp[] 생성.
68
+ *
69
+ * 기본 정책:
70
+ * - refids 비어있거나 mode 도 색도 없으면 에러
71
+ * - 색상 입력은 normalizeColor 통과해야 적용
72
+ * - mode='gradient' 인데 stops 없으면 자동 생성 (color → 어두운 변형)
73
+ */
74
+ export function buildSetFillOps(input: SetFillInput): SetFillResult {
75
+ const errors: string[] = []
76
+ const ops: BoardEditOp[] = []
77
+
78
+ if (!Array.isArray(input.refids) || input.refids.length === 0) {
79
+ return { ops: [], errors: ['refids must be a non-empty array of numbers'] }
80
+ }
81
+ const refids = input.refids.filter(n => typeof n === 'number' && Number.isFinite(n))
82
+ if (refids.length === 0) {
83
+ return { ops: [], errors: ['No valid numeric refids in input'] }
84
+ }
85
+
86
+ // mode 미지정 + 어떤 styling 인자도 없으면 무의미
87
+ if (
88
+ !input.mode &&
89
+ !input.color &&
90
+ !input.colorStops &&
91
+ !input.image &&
92
+ typeof input.opacity !== 'number'
93
+ ) {
94
+ return {
95
+ ops: [],
96
+ errors: ['No fill changes specified. Provide mode and/or color/opacity/etc.']
97
+ }
98
+ }
99
+
100
+ // 색상 검증
101
+ if (input.color !== undefined) {
102
+ const v = validateColor(input.color)
103
+ if (!v.valid) {
104
+ errors.push(v.error ?? `Invalid color: ${input.color}`)
105
+ return { ops: [], errors }
106
+ }
107
+ }
108
+
109
+ // gradient stops 검증
110
+ if (input.colorStops) {
111
+ for (const s of input.colorStops) {
112
+ if (typeof s.position !== 'number' || s.position < 0 || s.position > 1) {
113
+ errors.push(`Gradient stop position out of [0,1]: ${s.position}`)
114
+ return { ops: [], errors }
115
+ }
116
+ const v = validateColor(s.color)
117
+ if (!v.valid) {
118
+ errors.push(`Invalid gradient stop color: ${s.color}`)
119
+ return { ops: [], errors }
120
+ }
121
+ }
122
+ }
123
+
124
+ // opacity 검증
125
+ if (typeof input.opacity === 'number') {
126
+ if (input.opacity < 0 || input.opacity > 1) {
127
+ errors.push(`Opacity out of [0,1]: ${input.opacity}`)
128
+ return { ops: [], errors }
129
+ }
130
+ }
131
+
132
+ // pattern — image 필수
133
+ if (input.mode === 'pattern' && !input.image) {
134
+ errors.push(`Mode 'pattern' requires 'image' (URL or asset ref)`)
135
+ return { ops: [], errors }
136
+ }
137
+
138
+ // 각 refid 별 patch 생성
139
+ const patch = buildFillPatch(input)
140
+ if (!patch) {
141
+ return { ops: [], errors: ['Failed to build fill patch'] }
142
+ }
143
+
144
+ // 단일 op — apply-patch 의 deep merge 가 자연스럽게 처리:
145
+ // - 새 fillStyle 이 string (solid) → 기존 object 통째 replace (typeof 매칭 X)
146
+ // - 새 fillStyle 이 object → 같은 key (colorStops 배열 등) 는 array replace 됨
147
+ // - 기존 부가 필드 (rotation 등) 가 새 patch 에 없으면 유지 — partial update 의도
148
+ // 이전 2-op (null + new) 방식은 중간 null 상태가 onchange / 3D texture trigger 해
149
+ // 크래시 유발 → 제거. 정확한 colorStops 출력으로 stale 필드 문제 자체가 해결됐음.
150
+ for (const refid of refids) {
151
+ ops.push({ op: 'modify', refid, patch })
152
+ }
153
+ return { ops, errors }
154
+ }
155
+
156
+ /**
157
+ * input → fillStyle patch 객체 생성.
158
+ *
159
+ * Mode 명시 시: fillStyle 통째 교체 → patch = { fillStyle: {...새 객체} }
160
+ * Mode 미지정 시: 기존 fillStyle 위에 색/opacity 만 병합 → patch = { fillStyle: { ...덮어쓸 필드 } }
161
+ * (apply-patch 의 deep-merge 가 처리. fillStyle 이 객체라면.)
162
+ *
163
+ * mode='none' 의 경우 fillStyle 을 falsy 로 — 빈 객체 또는 type:'none' 명시.
164
+ */
165
+ function buildFillPatch(input: SetFillInput): { fillStyle: any } | null {
166
+ const { mode } = input
167
+
168
+ if (mode === 'none') {
169
+ // fillStyle 제거 의도 — null 시맨틱 활용 (apply-patch 가 키 자체를 delete).
170
+ // drawer/fill.ts: fillStyle 없으면 falsy → 채우지 않음.
171
+ return { fillStyle: null as any }
172
+ }
173
+
174
+ if (mode === 'solid') {
175
+ // things-scene drawer/fill.ts:17 — fillStyle 이 string 이면 그대로 색상.
176
+ // 객체 형식 {type:'solid', color} 은 인터페이스에만 있는 stale.
177
+ // opacity 는 색상에 alpha 채널로 embedding (rgba).
178
+ const color = input.color ? normalizeColor(input.color) : undefined
179
+ if (!color) return null
180
+ let fillStyle: string = color
181
+ if (typeof input.opacity === 'number' && input.opacity < 1) {
182
+ fillStyle = withAlpha(color, input.opacity)
183
+ }
184
+ return { fillStyle: fillStyle as any }
185
+ }
186
+
187
+ if (mode === 'gradient') {
188
+ let stops = input.colorStops
189
+ // stops 미지정 시 — color 와 자동 생성 어두운 변형 (간단한 fallback)
190
+ if ((!stops || stops.length === 0) && input.color) {
191
+ const hex = normalizeColor(input.color)
192
+ if (hex) {
193
+ stops = [
194
+ { color: hex, position: 0 },
195
+ { color: darken(hex, 0.4), position: 1 }
196
+ ]
197
+ }
198
+ }
199
+ if (!stops || stops.length < 2) {
200
+ // gradient 인데 stops 부족 — 호출자가 명시 안 하면 만들어 주지 않음
201
+ return null
202
+ }
203
+ // things-scene 의 실제 필드: colorStops [{position, color}]
204
+ const colorStops = stops
205
+ .map(s => ({ position: s.position, color: normalizeColor(s.color)! }))
206
+ .filter(s => !!s.color)
207
+ if (colorStops.length < 2) return null
208
+ // rotation: 항상 명시 — drawer/fill.ts 가 default 없이 fillStyle.rotation 을 직접
209
+ // Math.cos / Math.sin 에 넘기므로 undefined 면 NaN → createLinearGradient 크래시.
210
+ // AI/사용자는 degrees 로 표현, 내부에서 라디안 변환. 미지정 시 0 default.
211
+ const rotationRad =
212
+ typeof input.rotation === 'number' ? (input.rotation * Math.PI) / 180 : 0
213
+ const fs: any = {
214
+ type: 'gradient',
215
+ gradientType: input.gradientType ?? 'linear',
216
+ colorStops,
217
+ rotation: rotationRad
218
+ }
219
+ if (typeof input.opacity === 'number') fs.opacity = input.opacity
220
+ return { fillStyle: fs }
221
+ }
222
+
223
+ if (mode === 'pattern') {
224
+ if (!input.image) return null
225
+ // things-scene 의 실제 pattern fillStyle (drawer/fill.ts:95~)
226
+ const fs: any = { type: 'pattern', image: input.image }
227
+ if (input.color) {
228
+ const c = normalizeColor(input.color)
229
+ if (c) fs.color = c // 패턴 뒤 배경색 (drawer 가 사용)
230
+ }
231
+ if (input.fitPattern !== undefined) fs.fitPattern = input.fitPattern
232
+ if (input.noRepeat !== undefined) fs.noRepeat = input.noRepeat
233
+ if (input.align) fs.align = input.align
234
+ if (typeof input.offsetX === 'number') fs.offsetX = input.offsetX
235
+ if (typeof input.offsetY === 'number') fs.offsetY = input.offsetY
236
+ if (typeof input.patternWidth === 'number') fs.width = input.patternWidth
237
+ if (typeof input.patternHeight === 'number') fs.height = input.patternHeight
238
+ if (typeof input.opacity === 'number') fs.opacity = input.opacity
239
+ return { fillStyle: fs }
240
+ }
241
+
242
+ // mode 미지정 — 부분 update.
243
+ // things-scene 의 fillStyle 은 string (solid) 또는 object (gradient/pattern).
244
+ // 부분 update 에서 색만 들어오면 solid string 으로 통째 set (가장 흔한 케이스).
245
+ // (gradient 의 부분 update 는 mode='gradient' 명시 사용 — 모드 명시 시 full replace.)
246
+ if (input.color) {
247
+ const c = normalizeColor(input.color)
248
+ if (!c) return null
249
+ const fillStyle =
250
+ typeof input.opacity === 'number' && input.opacity < 1 ? withAlpha(c, input.opacity) : c
251
+ return { fillStyle: fillStyle as any }
252
+ }
253
+ // color 없이 opacity 만 — 부분 update 어려움 (string 인지 object 인지 모름).
254
+ // 명확한 의도가 있으면 mode 명시 권장. 일단 무동작 (null).
255
+ return null
256
+ }
257
+
258
+ /**
259
+ * hex / 명명 색 + alpha (0..1) → rgba(r,g,b,a) 문자열.
260
+ * Canvas API 의 fillStyle 은 rgba string 그대로 사용 가능.
261
+ */
262
+ function withAlpha(hexOrName: string, alpha: number): string {
263
+ // transparent
264
+ if (hexOrName === 'transparent') return 'rgba(0,0,0,0)'
265
+ // hex 예상 (#rrggbb)
266
+ if (hexOrName.startsWith('#') && hexOrName.length === 7) {
267
+ const r = parseInt(hexOrName.slice(1, 3), 16)
268
+ const g = parseInt(hexOrName.slice(3, 5), 16)
269
+ const b = parseInt(hexOrName.slice(5, 7), 16)
270
+ return `rgba(${r},${g},${b},${alpha})`
271
+ }
272
+ // 다른 형식이면 색을 그대로 두고 alpha 무시 (nullable 안전)
273
+ return hexOrName
274
+ }
275
+
276
+ /**
277
+ * 단순 hex 색상 어둡게 — gradient 자동 생성 시 fallback 두 번째 stop 용.
278
+ * lightness 기반 정확도 < HSL 변환이 정확하지만 의존성 줄이려 RGB linear blend 로 대체.
279
+ */
280
+ function darken(hex: string, amount: number): string {
281
+ const p = parseColor(hex)
282
+ if (!p || p.hex === 'transparent') return hex
283
+ const h = p.hex.slice(1) // remove #
284
+ const r = parseInt(h.slice(0, 2), 16)
285
+ const g = parseInt(h.slice(2, 4), 16)
286
+ const b = parseInt(h.slice(4, 6), 16)
287
+ const factor = 1 - Math.max(0, Math.min(1, amount))
288
+ const r2 = Math.round(r * factor)
289
+ const g2 = Math.round(g * factor)
290
+ const b2 = Math.round(b * factor)
291
+ const toHex = (n: number) => n.toString(16).padStart(2, '0')
292
+ return `#${toHex(r2)}${toHex(g2)}${toHex(b2)}`
293
+ }