@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,152 @@
1
+ /**
2
+ * copy-tools — copyStyle source → targets 복사.
3
+ */
4
+ import { buildCopyStyleOps } from './copy-tools'
5
+
6
+ const COMPONENTS = [
7
+ {
8
+ refid: 35,
9
+ type: 'rect',
10
+ fillStyle: { type: 'solid', color: '#ff0000' },
11
+ strokeStyle: { color: '#000', width: 2 },
12
+ shadow: { blur: 4 },
13
+ material3d: { preset: 'metal' },
14
+ fontFamily: 'Inter',
15
+ fontSize: 14,
16
+ alpha: 0.9,
17
+ hidden: false
18
+ },
19
+ { refid: 36, type: 'rect' }, // styling 없음
20
+ { refid: 37, type: 'rect' },
21
+ { refid: 38, type: 'circle' }
22
+ ]
23
+
24
+ describe('buildCopyStyleOps — 검증', () => {
25
+ test('sourceRefid 비-숫자', () => {
26
+ const r = buildCopyStyleOps({} as any, COMPONENTS)
27
+ expect(r.ops).toEqual([])
28
+ expect(r.errors[0]).toMatch(/sourceRefid/)
29
+ })
30
+
31
+ test('targetRefids 빈 배열', () => {
32
+ const r = buildCopyStyleOps(
33
+ { sourceRefid: 35, targetRefids: [] },
34
+ COMPONENTS
35
+ )
36
+ expect(r.ops).toEqual([])
37
+ })
38
+
39
+ test('source 없음', () => {
40
+ const r = buildCopyStyleOps(
41
+ { sourceRefid: 999, targetRefids: [36] },
42
+ COMPONENTS
43
+ )
44
+ expect(r.ops).toEqual([])
45
+ expect(r.errors[0]).toMatch(/Source.*not found/)
46
+ })
47
+
48
+ test('알 수 없는 aspect', () => {
49
+ const r = buildCopyStyleOps(
50
+ { sourceRefid: 35, targetRefids: [36], aspects: ['xyz' as any] },
51
+ COMPONENTS
52
+ )
53
+ expect(r.ops).toEqual([])
54
+ expect(r.errors[0]).toMatch(/Unknown aspect/)
55
+ })
56
+
57
+ test('source 가 target 에 포함되면 자동 제외', () => {
58
+ const r = buildCopyStyleOps(
59
+ { sourceRefid: 35, targetRefids: [35, 36, 37] },
60
+ COMPONENTS
61
+ )
62
+ expect(r.ops).toHaveLength(2) // 35 제외
63
+ expect(r.ops.map(o => (o as any).refid).sort()).toEqual([36, 37])
64
+ })
65
+ })
66
+
67
+ describe('buildCopyStyleOps — 정상 동작', () => {
68
+ test('default aspects (visibility 제외) — 모든 styling 복사', () => {
69
+ const r = buildCopyStyleOps(
70
+ { sourceRefid: 35, targetRefids: [36, 37] },
71
+ COMPONENTS
72
+ )
73
+ expect(r.ops).toHaveLength(2)
74
+ const patch = (r.ops[0] as any).patch
75
+ expect(patch.fillStyle).toEqual({ type: 'solid', color: '#ff0000' })
76
+ expect(patch.strokeStyle).toEqual({ color: '#000', width: 2 })
77
+ expect(patch.shadow).toEqual({ blur: 4 })
78
+ expect(patch.material3d).toEqual({ preset: 'metal' })
79
+ expect(patch.fontFamily).toBe('Inter')
80
+ expect(patch.fontSize).toBe(14)
81
+ expect(patch.alpha).toBe(0.9)
82
+ // visibility default 제외
83
+ expect(patch.hidden).toBeUndefined()
84
+ })
85
+
86
+ test('aspects 부분 — fill 만', () => {
87
+ const r = buildCopyStyleOps(
88
+ { sourceRefid: 35, targetRefids: [36], aspects: ['fill'] },
89
+ COMPONENTS
90
+ )
91
+ const patch = (r.ops[0] as any).patch
92
+ expect(patch.fillStyle).toBeDefined()
93
+ expect(patch.strokeStyle).toBeUndefined()
94
+ expect(patch.shadow).toBeUndefined()
95
+ })
96
+
97
+ test('aspects 다중 — fill + stroke', () => {
98
+ const r = buildCopyStyleOps(
99
+ {
100
+ sourceRefid: 35,
101
+ targetRefids: [36],
102
+ aspects: ['fill', 'stroke']
103
+ },
104
+ COMPONENTS
105
+ )
106
+ const patch = (r.ops[0] as any).patch
107
+ expect(patch.fillStyle).toBeDefined()
108
+ expect(patch.strokeStyle).toBeDefined()
109
+ expect(patch.shadow).toBeUndefined()
110
+ expect(patch.fontFamily).toBeUndefined()
111
+ })
112
+
113
+ test('source 가 styling 없는 aspect — patch 에 누락', () => {
114
+ const sparse = [
115
+ { refid: 100, type: 'rect', fillStyle: { color: 'red' } },
116
+ { refid: 101, type: 'rect' }
117
+ ]
118
+ const r = buildCopyStyleOps(
119
+ { sourceRefid: 100, targetRefids: [101] },
120
+ sparse
121
+ )
122
+ const patch = (r.ops[0] as any).patch
123
+ expect(patch.fillStyle).toBeDefined()
124
+ expect(patch.strokeStyle).toBeUndefined()
125
+ })
126
+
127
+ test('source 가 모든 aspect 비어있으면 — 에러', () => {
128
+ const empty = [
129
+ { refid: 200, type: 'rect' },
130
+ { refid: 201, type: 'rect' }
131
+ ]
132
+ const r = buildCopyStyleOps(
133
+ { sourceRefid: 200, targetRefids: [201] },
134
+ empty
135
+ )
136
+ expect(r.ops).toEqual([])
137
+ expect(r.errors[0]).toMatch(/no styling/)
138
+ })
139
+
140
+ test('visibility aspect 명시 → hidden 복사', () => {
141
+ const c = [
142
+ { refid: 1, type: 'rect', hidden: true },
143
+ { refid: 2, type: 'rect' }
144
+ ]
145
+ const r = buildCopyStyleOps(
146
+ { sourceRefid: 1, targetRefids: [2], aspects: ['visibility'] },
147
+ c
148
+ )
149
+ const patch = (r.ops[0] as any).patch
150
+ expect(patch.hidden).toBe(true)
151
+ })
152
+ })
@@ -0,0 +1,115 @@
1
+ /**
2
+ * copyStyle — 한 컴포넌트의 styling 을 다른 컴포넌트들에 복사.
3
+ *
4
+ * 자주 쓰는 패턴: "MOTOR1 처럼 다른 모터들도 같게 만들어줘"
5
+ * AI: getComponentByRefid(MOTOR1) 또는 getComponentStyle(MOTOR1) → copyStyle(...)
6
+ *
7
+ * aspects 인자로 어느 styling 영역만 복사할지 선택 가능 — 부분 통일 (예: 색만 통일).
8
+ *
9
+ * 입력 — sourceRefid 의 실제 styling 값을 미리 알 필요 없음. server 가 components
10
+ * 에서 source 찾아 styling 추출 후 target 들에 patch 생성.
11
+ */
12
+ import type { BoardEditOp } from '../types'
13
+
14
+ export interface CopyStyleInput {
15
+ sourceRefid: number
16
+ targetRefids: number[]
17
+ aspects?: Array<'fill' | 'stroke' | 'shadow' | 'material' | 'font' | 'opacity' | 'visibility'>
18
+ }
19
+
20
+ export interface OpsResult {
21
+ ops: BoardEditOp[]
22
+ errors: string[]
23
+ }
24
+
25
+ const ASPECT_FIELDS: Record<string, string[]> = {
26
+ fill: ['fillStyle'],
27
+ stroke: ['strokeStyle'],
28
+ shadow: ['shadow'],
29
+ material: ['material3d'],
30
+ font: [
31
+ 'fontFamily',
32
+ 'fontSize',
33
+ 'fontWeight',
34
+ 'fontStyle',
35
+ 'fontColor',
36
+ 'textAlign',
37
+ 'textBaseline',
38
+ 'lineHeight'
39
+ ],
40
+ opacity: ['alpha'],
41
+ visibility: ['hidden']
42
+ }
43
+
44
+ const DEFAULT_ASPECTS: Array<keyof typeof ASPECT_FIELDS> = [
45
+ 'fill',
46
+ 'stroke',
47
+ 'shadow',
48
+ 'material',
49
+ 'font',
50
+ 'opacity'
51
+ // visibility 는 default 에서 제외 — 의도 없이 hidden 복사 위험
52
+ ]
53
+
54
+ /**
55
+ * input + components 배열 → BoardEditOp[].
56
+ *
57
+ * components 는 board 의 모든 컴포넌트 (caller 가 전달). source 찾아 styling 추출.
58
+ */
59
+ export function buildCopyStyleOps(
60
+ input: CopyStyleInput,
61
+ components: ReadonlyArray<any>
62
+ ): OpsResult {
63
+ if (typeof input.sourceRefid !== 'number') {
64
+ return { ops: [], errors: ['sourceRefid (number) required'] }
65
+ }
66
+ if (!Array.isArray(input.targetRefids) || input.targetRefids.length === 0) {
67
+ return { ops: [], errors: ['targetRefids must be a non-empty array'] }
68
+ }
69
+ const targets = input.targetRefids.filter(
70
+ n => typeof n === 'number' && Number.isFinite(n) && n !== input.sourceRefid
71
+ )
72
+ if (targets.length === 0) {
73
+ return { ops: [], errors: ['No valid target refids (excluding source)'] }
74
+ }
75
+
76
+ const source = components.find(
77
+ c => c && typeof c.refid === 'number' && c.refid === input.sourceRefid
78
+ )
79
+ if (!source) {
80
+ return {
81
+ ops: [],
82
+ errors: [`Source component refid=${input.sourceRefid} not found`]
83
+ }
84
+ }
85
+
86
+ const aspects = input.aspects && input.aspects.length > 0 ? input.aspects : DEFAULT_ASPECTS
87
+ const validAspects = Object.keys(ASPECT_FIELDS)
88
+ const unknown = aspects.filter(a => !validAspects.includes(a))
89
+ if (unknown.length > 0) {
90
+ return {
91
+ ops: [],
92
+ errors: [`Unknown aspect(s): ${unknown.join(', ')}. Valid: ${validAspects.join('/')}`]
93
+ }
94
+ }
95
+
96
+ // source 에서 aspect 별 필드 추출 → patch 합치기
97
+ const patch: any = {}
98
+ for (const aspect of aspects) {
99
+ const fields = ASPECT_FIELDS[aspect]
100
+ for (const field of fields) {
101
+ if (source[field] !== undefined) {
102
+ patch[field] = source[field]
103
+ }
104
+ }
105
+ }
106
+
107
+ if (Object.keys(patch).length === 0) {
108
+ return { ops: [], errors: ['Source has no styling for the requested aspects'] }
109
+ }
110
+
111
+ return {
112
+ ops: targets.map(refid => ({ op: 'modify', refid, patch })),
113
+ errors: []
114
+ }
115
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * effect-tools — setShadow / setOpacity / setVisible 테스트.
3
+ *
4
+ * shadow 의 canonical fields (CSS box-shadow / Canvas API 표준):
5
+ * { color, offsetX, offsetY, blur }
6
+ * things-scene drawer 가 옛 이름 (left/top/blurSize) 도 backwards-compat 으로
7
+ * 읽지만, 새 코드는 canonical 만 사용.
8
+ *
9
+ * setOpacity → state.alpha (canonical), setVisible → state.hidden (visible 의 inverse).
10
+ */
11
+ import { buildSetShadowOps, buildSetOpacityOps, buildSetVisibleOps } from './effect-tools'
12
+
13
+ describe('buildSetShadowOps — 입력 검증', () => {
14
+ test('refids 비어있으면 에러', () => {
15
+ const r = buildSetShadowOps({ refids: [], color: 'black' })
16
+ expect(r.ops).toEqual([])
17
+ expect(r.errors[0]).toMatch(/non-empty array/)
18
+ })
19
+
20
+ test('비-숫자 refids 필터링', () => {
21
+ const r = buildSetShadowOps({
22
+ refids: [1, NaN, 'x' as any, 2],
23
+ color: 'black'
24
+ })
25
+ expect(r.ops).toHaveLength(2)
26
+ })
27
+
28
+ test('아무 인자도 없으면 에러', () => {
29
+ const r = buildSetShadowOps({ refids: [1] })
30
+ expect(r.ops).toEqual([])
31
+ expect(r.errors[0]).toMatch(/No shadow changes/)
32
+ })
33
+
34
+ test('잘못된 색상 → 에러', () => {
35
+ const r = buildSetShadowOps({ refids: [1], color: 'invalid-color' })
36
+ expect(r.ops).toEqual([])
37
+ expect(r.errors[0]).toMatch(/Invalid/)
38
+ })
39
+
40
+ test('blur 음수 → 에러', () => {
41
+ const r = buildSetShadowOps({ refids: [1], blur: -1 })
42
+ expect(r.ops).toEqual([])
43
+ expect(r.errors[0]).toMatch(/Invalid blur/)
44
+ })
45
+
46
+ test('blur Infinity → 에러', () => {
47
+ const r = buildSetShadowOps({ refids: [1], blur: Infinity })
48
+ expect(r.ops).toEqual([])
49
+ expect(r.errors[0]).toMatch(/Invalid blur/)
50
+ })
51
+
52
+ test('blur 0 허용', () => {
53
+ const r = buildSetShadowOps({ refids: [1], blur: 0, color: 'black' })
54
+ expect(r.ops).toHaveLength(1)
55
+ expect(r.ops[0].patch).toEqual({ shadow: { color: '#000000', blur: 0 } })
56
+ })
57
+
58
+ test('offsetX NaN → 에러', () => {
59
+ const r = buildSetShadowOps({ refids: [1], offsetX: NaN })
60
+ expect(r.ops).toEqual([])
61
+ expect(r.errors[0]).toMatch(/Invalid offsetX/)
62
+ })
63
+
64
+ test('offsetY Infinity → 에러', () => {
65
+ const r = buildSetShadowOps({ refids: [1], offsetY: Infinity })
66
+ expect(r.ops).toEqual([])
67
+ expect(r.errors[0]).toMatch(/Invalid offsetY/)
68
+ })
69
+
70
+ test('offsetX 음수 허용 (CSS box-shadow 정상 사용)', () => {
71
+ const r = buildSetShadowOps({ refids: [1], offsetX: -3 })
72
+ expect(r.ops).toHaveLength(1)
73
+ expect(r.ops[0].patch).toEqual({ shadow: { offsetX: -3 } })
74
+ })
75
+ })
76
+
77
+ describe('buildSetShadowOps — canonical fields (CSS / Canvas 표준)', () => {
78
+ test('blur 검증', () => {
79
+ const r = buildSetShadowOps({ refids: [1], blur: 8 })
80
+ expect(r.ops[0].patch).toEqual({ shadow: { blur: 8 } })
81
+ })
82
+
83
+ test('offsetX 만', () => {
84
+ const r = buildSetShadowOps({ refids: [1], offsetX: 5 })
85
+ expect(r.ops[0].patch).toEqual({ shadow: { offsetX: 5 } })
86
+ })
87
+
88
+ test('offsetY 만', () => {
89
+ const r = buildSetShadowOps({ refids: [1], offsetY: 10 })
90
+ expect(r.ops[0].patch).toEqual({ shadow: { offsetY: 10 } })
91
+ })
92
+
93
+ test('color 정규화', () => {
94
+ const r = buildSetShadowOps({ refids: [1], color: 'red' })
95
+ expect(r.ops[0].patch).toEqual({ shadow: { color: '#ff0000' } })
96
+ })
97
+
98
+ test('전체 필드 — color/blur/offsetX/offsetY', () => {
99
+ const r = buildSetShadowOps({
100
+ refids: [1],
101
+ color: 'navy',
102
+ blur: 4,
103
+ offsetX: 3,
104
+ offsetY: 2
105
+ })
106
+ expect(r.ops[0].patch).toEqual({
107
+ shadow: {
108
+ color: '#000080',
109
+ blur: 4,
110
+ offsetX: 3,
111
+ offsetY: 2
112
+ }
113
+ })
114
+ })
115
+
116
+ test('offsetX/Y 0 명시 — 실제 0 으로 저장', () => {
117
+ const r = buildSetShadowOps({ refids: [1], offsetX: 0, offsetY: 0 })
118
+ expect(r.ops[0].patch).toEqual({ shadow: { offsetX: 0, offsetY: 0 } })
119
+ })
120
+ })
121
+
122
+ describe('buildSetShadowOps — enabled=false (그림자 제거)', () => {
123
+ test('enabled=false → blur 0 + transparent (drawer 가 안 그림)', () => {
124
+ const r = buildSetShadowOps({ refids: [1], enabled: false })
125
+ expect(r.ops).toHaveLength(1)
126
+ expect(r.ops[0].patch).toEqual({
127
+ shadow: { blur: 0, color: 'transparent' }
128
+ })
129
+ })
130
+
131
+ test('enabled=false 가 다른 인자보다 우선 — color/blur 무시하고 제거', () => {
132
+ const r = buildSetShadowOps({
133
+ refids: [1],
134
+ enabled: false,
135
+ color: 'red',
136
+ blur: 8
137
+ })
138
+ expect(r.ops[0].patch).toEqual({
139
+ shadow: { blur: 0, color: 'transparent' }
140
+ })
141
+ })
142
+ })
143
+
144
+ describe('buildSetShadowOps — multi-target', () => {
145
+ test('동일 shadow 가 여러 refid 에 분배', () => {
146
+ const r = buildSetShadowOps({
147
+ refids: [10, 20, 30],
148
+ color: 'black',
149
+ blur: 6
150
+ })
151
+ expect(r.ops).toHaveLength(3)
152
+ expect(r.ops[0].patch).toEqual({
153
+ shadow: { color: '#000000', blur: 6 }
154
+ })
155
+ expect(r.ops[2]).toMatchObject({
156
+ refid: 30,
157
+ patch: { shadow: { color: '#000000', blur: 6 } }
158
+ })
159
+ })
160
+ })
161
+
162
+ describe('buildSetOpacityOps — 입력 검증', () => {
163
+ test('refids 비어있으면 에러', () => {
164
+ const r = buildSetOpacityOps({ refids: [], alpha: 0.5 })
165
+ expect(r.ops).toEqual([])
166
+ expect(r.errors[0]).toMatch(/non-empty array/)
167
+ })
168
+
169
+ test('opacity 가 number 가 아니면 에러', () => {
170
+ const r = buildSetOpacityOps({ refids: [1], alpha: 'half' as any })
171
+ expect(r.ops).toEqual([])
172
+ expect(r.errors[0]).toMatch(/alpha must be 0..1/)
173
+ })
174
+
175
+ test('opacity > 1 → 에러', () => {
176
+ const r = buildSetOpacityOps({ refids: [1], alpha: 1.5 })
177
+ expect(r.ops).toEqual([])
178
+ expect(r.errors[0]).toMatch(/alpha must be 0..1/)
179
+ })
180
+
181
+ test('opacity < 0 → 에러', () => {
182
+ const r = buildSetOpacityOps({ refids: [1], alpha: -0.1 })
183
+ expect(r.ops).toEqual([])
184
+ expect(r.errors[0]).toMatch(/alpha must be 0..1/)
185
+ })
186
+
187
+ test('opacity 0 (완전 투명) 허용', () => {
188
+ const r = buildSetOpacityOps({ refids: [1], alpha: 0 })
189
+ expect(r.ops).toHaveLength(1)
190
+ expect(r.ops[0].patch).toEqual({ alpha: 0 })
191
+ })
192
+
193
+ test('opacity 1 (완전 불투명) 허용', () => {
194
+ const r = buildSetOpacityOps({ refids: [1], alpha: 1 })
195
+ expect(r.ops[0].patch).toEqual({ alpha: 1 })
196
+ })
197
+ })
198
+
199
+ describe('buildSetOpacityOps — alpha 매핑', () => {
200
+ test('opacity 0.5 → state.alpha 0.5', () => {
201
+ const r = buildSetOpacityOps({ refids: [1], alpha: 0.5 })
202
+ expect(r.ops[0].patch).toEqual({ alpha: 0.5 })
203
+ })
204
+
205
+ test('multi-target 동일 alpha', () => {
206
+ const r = buildSetOpacityOps({ refids: [1, 2, 3], alpha: 0.7 })
207
+ expect(r.ops).toHaveLength(3)
208
+ expect(r.ops.every(o => (o as any).patch.alpha === 0.7)).toBe(true)
209
+ })
210
+ })
211
+
212
+ describe('buildSetVisibleOps — 입력 검증', () => {
213
+ test('refids 비어있으면 에러', () => {
214
+ const r = buildSetVisibleOps({ refids: [], visible: true })
215
+ expect(r.ops).toEqual([])
216
+ expect(r.errors[0]).toMatch(/non-empty array/)
217
+ })
218
+
219
+ test('visible 이 boolean 이 아니면 에러', () => {
220
+ const r = buildSetVisibleOps({ refids: [1], visible: 'yes' as any })
221
+ expect(r.ops).toEqual([])
222
+ expect(r.errors[0]).toMatch(/visible must be a boolean/)
223
+ })
224
+
225
+ test('visible=undefined → 에러', () => {
226
+ const r = buildSetVisibleOps({ refids: [1], visible: undefined as any })
227
+ expect(r.ops).toEqual([])
228
+ })
229
+ })
230
+
231
+ describe('buildSetVisibleOps — hidden = !visible', () => {
232
+ test('visible=true → hidden=false', () => {
233
+ const r = buildSetVisibleOps({ refids: [1], visible: true })
234
+ expect(r.ops[0].patch).toEqual({ hidden: false })
235
+ })
236
+
237
+ test('visible=false → hidden=true', () => {
238
+ const r = buildSetVisibleOps({ refids: [1], visible: false })
239
+ expect(r.ops[0].patch).toEqual({ hidden: true })
240
+ })
241
+
242
+ test('multi-target 일괄 hide', () => {
243
+ const r = buildSetVisibleOps({ refids: [1, 2, 3, 4, 5], visible: false })
244
+ expect(r.ops).toHaveLength(5)
245
+ expect(r.ops.every(o => (o as any).patch.hidden === true)).toBe(true)
246
+ })
247
+ })
248
+
249
+ describe('effect-tools — 시나리오', () => {
250
+ test('카드에 부드러운 그림자', () => {
251
+ const r = buildSetShadowOps({
252
+ refids: [42],
253
+ color: 'rgba(0,0,0,0.2)',
254
+ blur: 12,
255
+ offsetX: 0,
256
+ offsetY: 4
257
+ })
258
+ // rgba 는 정규화되어 hex 로 변환 (alpha 채널 손실, color-utils 의 정책)
259
+ expect(r.ops[0].patch.shadow).toMatchObject({
260
+ blur: 12,
261
+ offsetX: 0,
262
+ offsetY: 4
263
+ })
264
+ })
265
+
266
+ test('비활성 컴포넌트 — 50% 투명', () => {
267
+ const r = buildSetOpacityOps({ refids: [1, 2], alpha: 0.5 })
268
+ expect(r.ops).toHaveLength(2)
269
+ expect(r.ops[0].patch).toEqual({ alpha: 0.5 })
270
+ })
271
+
272
+ test('숨김 토글 — 다중 컴포넌트', () => {
273
+ const r = buildSetVisibleOps({ refids: [10, 11, 12], visible: false })
274
+ expect(r.ops).toHaveLength(3)
275
+ expect(r.ops[0].patch).toEqual({ hidden: true })
276
+ })
277
+ })
@@ -0,0 +1,124 @@
1
+ /**
2
+ * setShadow / setOpacity / setVisible — 효과 / 가시성 / 투명도.
3
+ *
4
+ * setShadow: state.shadow (color/blur/offsetX/offsetY)
5
+ * setOpacity: state.alpha (컴포넌트 전체 알파, 0..1)
6
+ * setVisible: state.hidden 토글 (true → 숨김)
7
+ */
8
+ import type { BoardEditOp } from '../types'
9
+ import { normalizeColor, validateColor } from './color-utils'
10
+
11
+ export interface SetShadowInput {
12
+ refids: number[]
13
+ /** synthetic — false 면 blur=0 + color=transparent 매핑 (drawer 가 안 그림). 모델에 enabled 필드 없음. */
14
+ enabled?: boolean
15
+ /** state.shadow.color */
16
+ color?: string
17
+ /** Blur radius (CSS box-shadow 의 blur-radius, Canvas 의 shadowBlur). state.shadow.blur. */
18
+ blur?: number
19
+ /** Horizontal offset (CSS box-shadow 의 offset-x, Canvas 의 shadowOffsetX). state.shadow.offsetX. */
20
+ offsetX?: number
21
+ /** Vertical offset (CSS box-shadow 의 offset-y, Canvas 의 shadowOffsetY). state.shadow.offsetY. */
22
+ offsetY?: number
23
+ }
24
+
25
+ export interface SetOpacityInput {
26
+ refids: number[]
27
+ /** state.alpha 의 canonical 이름. 0..1. */
28
+ alpha: number
29
+ }
30
+
31
+ export interface SetVisibleInput {
32
+ refids: number[]
33
+ visible: boolean
34
+ }
35
+
36
+ export interface OpsResult {
37
+ ops: BoardEditOp[]
38
+ errors: string[]
39
+ }
40
+
41
+ /**
42
+ * setShadow — CSS box-shadow / Canvas API 표준 이름 (color/offsetX/offsetY/blur) 그대로
43
+ * state.shadow 에 set. things-scene drawer 는 옛 이름 (left/top/blurSize) 도
44
+ * backwards-compat 으로 읽지만 (effect/shadow.ts:36-47), 새 모델은 canonical 만 사용.
45
+ *
46
+ * synthetic enabled=false → blur 0 + transparent 매핑 (drawer 가 안 그림).
47
+ * 모델에 enabled 필드는 없음.
48
+ */
49
+ export function buildSetShadowOps(input: SetShadowInput): OpsResult {
50
+ if (!Array.isArray(input.refids) || input.refids.length === 0) {
51
+ return { ops: [], errors: ['refids must be a non-empty array'] }
52
+ }
53
+ const refids = input.refids.filter(n => typeof n === 'number' && Number.isFinite(n))
54
+ if (refids.length === 0) return { ops: [], errors: ['No valid numeric refids'] }
55
+
56
+ const noOp =
57
+ input.enabled === undefined &&
58
+ input.color === undefined &&
59
+ input.blur === undefined &&
60
+ input.offsetX === undefined &&
61
+ input.offsetY === undefined
62
+ if (noOp) return { ops: [], errors: ['No shadow changes specified'] }
63
+
64
+ if (input.enabled === false) {
65
+ // synthetic — false 면 blur 0 + transparent 으로 변환 (drawer 가 안 그림)
66
+ const patch = { shadow: { blur: 0, color: 'transparent' } }
67
+ return { ops: refids.map(refid => ({ op: 'modify', refid, patch })), errors: [] }
68
+ }
69
+
70
+ if (input.color !== undefined) {
71
+ const v = validateColor(input.color)
72
+ if (!v.valid) return { ops: [], errors: [v.error ?? 'Invalid shadow color'] }
73
+ }
74
+ if (input.blur !== undefined && (input.blur < 0 || !Number.isFinite(input.blur))) {
75
+ return { ops: [], errors: [`Invalid blur: ${input.blur}`] }
76
+ }
77
+ if (input.offsetX !== undefined && !Number.isFinite(input.offsetX)) {
78
+ return { ops: [], errors: [`Invalid offsetX: ${input.offsetX}`] }
79
+ }
80
+ if (input.offsetY !== undefined && !Number.isFinite(input.offsetY)) {
81
+ return { ops: [], errors: [`Invalid offsetY: ${input.offsetY}`] }
82
+ }
83
+
84
+ // CSS / Canvas 표준 canonical 이름 — rename 없음
85
+ const sh: Record<string, number | string> = {}
86
+ // validateColor 가 통과한 색만 도달 — normalizeColor 가 여기서 null 반환 안 함
87
+ if (input.color !== undefined) sh.color = normalizeColor(input.color)!
88
+ if (input.blur !== undefined) sh.blur = input.blur
89
+ if (input.offsetX !== undefined) sh.offsetX = input.offsetX
90
+ if (input.offsetY !== undefined) sh.offsetY = input.offsetY
91
+
92
+ const patch = { shadow: sh }
93
+ return { ops: refids.map(refid => ({ op: 'modify', refid, patch })), errors: [] }
94
+ }
95
+
96
+ export function buildSetOpacityOps(input: SetOpacityInput): OpsResult {
97
+ if (!Array.isArray(input.refids) || input.refids.length === 0) {
98
+ return { ops: [], errors: ['refids must be a non-empty array'] }
99
+ }
100
+ if (typeof input.alpha !== 'number' || input.alpha < 0 || input.alpha > 1) {
101
+ return { ops: [], errors: [`alpha must be 0..1: ${input.alpha}`] }
102
+ }
103
+ const refids = input.refids.filter(n => typeof n === 'number' && Number.isFinite(n))
104
+ if (refids.length === 0) return { ops: [], errors: ['No valid numeric refids'] }
105
+
106
+ // canonical 이름 그대로
107
+ const patch = { alpha: input.alpha }
108
+ return { ops: refids.map(refid => ({ op: 'modify', refid, patch })), errors: [] }
109
+ }
110
+
111
+ export function buildSetVisibleOps(input: SetVisibleInput): OpsResult {
112
+ if (!Array.isArray(input.refids) || input.refids.length === 0) {
113
+ return { ops: [], errors: ['refids must be a non-empty array'] }
114
+ }
115
+ if (typeof input.visible !== 'boolean') {
116
+ return { ops: [], errors: ['visible must be a boolean'] }
117
+ }
118
+ const refids = input.refids.filter(n => typeof n === 'number' && Number.isFinite(n))
119
+ if (refids.length === 0) return { ops: [], errors: ['No valid numeric refids'] }
120
+
121
+ // hidden 은 visible 의 inverse
122
+ const patch = { hidden: !input.visible }
123
+ return { ops: refids.map(refid => ({ op: 'modify', refid, patch })), errors: [] }
124
+ }