acrylblends 1.0.10 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +60 -2
  3. package/dist/index.js +1992 -33
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/api/summary.md +1 -1
  7. package/docs/framework/getting-started/desktop.md +23 -4
  8. package/docs/framework/guides/create-acrylblends.md +1 -1
  9. package/docs/framework/guides/production.md +20 -0
  10. package/docs/framework/workshop/README.md +174 -285
  11. package/docs/framework/workshop/facilitator.md +81 -90
  12. package/legacy/README.md +9 -0
  13. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  14. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  15. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  16. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  17. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  18. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  19. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  20. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  21. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  22. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  23. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  24. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  25. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  26. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  27. package/legacy/agent/855359304707/src/app.ts +168 -0
  28. package/legacy/agent/855359304707/src/routes.ts +126 -0
  29. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  30. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  31. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  32. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  33. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  34. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  35. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  36. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  37. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  38. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  39. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  40. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  41. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  42. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  43. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  44. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  45. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  46. package/legacy/layouts.json +524 -0
  47. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  48. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  49. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  50. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  51. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  52. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  53. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  54. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  55. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  56. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  57. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  58. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  59. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  60. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  61. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  62. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  63. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  64. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  65. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  66. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  67. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  68. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  69. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  70. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  71. package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
  72. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  73. package/lib/cordis/door.d.ts +44 -7
  74. package/lib/cordis/door.js +214 -60
  75. package/lib/cordis/inspect.js +1 -1
  76. package/lib/cordis/node-server.d.ts +101 -3
  77. package/lib/cordis/node-server.js +3 -3
  78. package/lib/cordis/router-http.d.ts +7 -1
  79. package/lib/cordis/router-http.js +44 -3
  80. package/lib/cordis/router.d.ts +1 -1
  81. package/lib/cordis/router.js +1 -1
  82. package/lib/cordis/state.d.ts +3 -2
  83. package/lib/cordis/state.js +371 -7
  84. package/lib/door/host.d.ts +2 -2
  85. package/lib/door/host.js +1 -1
  86. package/lib/door/page.d.ts +20 -1
  87. package/lib/door/page.js +75 -14
  88. package/lib/door/protocol.d.ts +1 -1
  89. package/lib/drivers.d.ts +10 -1
  90. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  91. package/lib/node-server.d.ts +7 -1
  92. package/lib/node-server.js +2 -2
  93. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  94. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
  95. package/lib/src-CtkIX_Gg.js +427 -0
  96. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  97. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  98. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  99. package/package.json +2 -1
  100. package/plugins/acryl-door/src/host/channel.ts +124 -9
  101. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  102. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  103. package/plugins/acryl-door/src/host/door.ts +58 -23
  104. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  105. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  106. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  107. package/plugins/acryl-door/src/index.ts +166 -29
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +11 -1
  110. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  111. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  112. package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +46 -2
  115. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  116. package/plugins/acryl-door/src/page/outline.ts +56 -0
  117. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  118. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  119. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  120. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  121. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  122. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  123. package/plugins/acryl-node-server/src/index.ts +304 -16
  124. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  125. package/plugins/acryl-page/plugin.json +12 -0
  126. package/plugins/acryl-page/src/index.ts +163 -0
  127. package/plugins/acryl-router/src/service.ts +77 -14
  128. package/plugins/acryl-router-http/src/index.ts +15 -1
  129. package/plugins/acryl-router-http/src/serve.ts +31 -0
  130. package/plugins/acryl-state/plugin.json +4 -3
  131. package/plugins/acryl-state/src/client-source.ts +244 -0
  132. package/plugins/acryl-state/src/service.ts +33 -6
  133. package/plugins/acryl-state/src/socket.ts +96 -0
  134. package/plugins/acryl-web-server/plugin.json +14 -0
  135. package/plugins/acryl-web-server/src/index.ts +164 -0
  136. package/plugins/agent/agent.ts +38 -7
  137. package/plugins/agent/index.ts +1 -1
  138. package/plugins/agent/model.ts +10 -2
  139. package/plugins/agent/tools.ts +9 -3
  140. package/plugins/agent-panel/client/describe.ts +57 -0
  141. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  142. package/plugins/agent-panel/client/frame.ts +51 -0
  143. package/plugins/agent-panel/client/main.ts +343 -81
  144. package/plugins/agent-panel/drivers.ts +7 -2
  145. package/plugins/agent-panel/headless.ts +5 -2
  146. package/plugins/agent-panel/index.ts +187 -61
  147. package/plugins/agent-panel/mount.ts +27 -0
  148. package/plugins/agent-panel/visitors.ts +231 -0
  149. package/plugins/desktop/frame.ts +231 -0
  150. package/plugins/desktop/launch.ts +44 -9
  151. package/plugins/desktop/main.ts +100 -32
  152. package/plugins/desktop/window.ts +21 -4
  153. package/plugins/driver-pty/client/terminal.ts +4 -1
  154. package/plugins/driver-pty/index.ts +38 -11
  155. package/plugins/driver-pty/spawn.ts +38 -0
  156. package/plugins/driver-pty/terminal.ts +2 -0
  157. package/plugins/extensions/index.ts +26 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/feedback/client.d.ts +22 -0
  160. package/plugins/feedback/client.js +471 -0
  161. package/plugins/feedback/index.ts +143 -0
  162. package/plugins/feedback/plugin.json +11 -0
  163. package/plugins/feedback/store.ts +307 -0
  164. package/plugins/home/index.ts +122 -0
  165. package/plugins/home/plugin.json +11 -0
  166. package/plugins/instance/index.ts +12 -3
  167. package/plugins/manifest/app-plugins.ts +86 -0
  168. package/plugins/manifest/index.ts +12 -2
  169. package/plugins/manifest/snapshot.ts +275 -0
  170. package/plugins/openai/index.ts +64 -5
  171. package/plugins/openai/prompt.ts +23 -0
  172. package/shared/AGENTS.core.md +36 -16
  173. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  174. package/shared/skills/acryl-app/SKILL.md +13 -0
  175. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  176. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  177. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  178. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  179. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  180. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  181. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  182. package/templates/agent/AGENTS.md +4 -2
  183. package/templates/agent/blend.yaml +4 -0
  184. package/templates/agent/gitignore +1 -0
  185. package/templates/agent/package.json +1 -0
  186. package/templates/agent/src/app.ts +150 -33
  187. package/templates/agent/src/frame.ts +17 -0
  188. package/templates/agent/src/main.ts +5 -3
  189. package/templates/agent/src/routes.ts +73 -21
  190. package/templates/agent/template.json +2 -0
  191. package/templates/agent/test/app.test.ts +83 -5
  192. package/templates/blank/AGENTS.md +3 -2
  193. package/templates/blank/blend.yaml +11 -0
  194. package/templates/blank/gitignore +1 -0
  195. package/templates/blank/package.json +1 -0
  196. package/templates/blank/src/app.ts +100 -29
  197. package/templates/blank/src/door.ts +7 -0
  198. package/templates/blank/src/first-page.ts +5 -0
  199. package/templates/blank/src/frame.ts +17 -0
  200. package/templates/blank/src/routes.ts +65 -8
  201. package/templates/blank/template.json +14 -1
  202. package/templates/blank/test/app.test.ts +103 -16
  203. package/templates/chat/blend.yaml +2 -1
  204. package/templates/chat/gitignore +1 -0
  205. package/templates/chat/package.json +1 -0
  206. package/templates/chat/src/app.ts +72 -22
  207. package/templates/chat/src/chat-app.ts +11 -2
  208. package/templates/chat/src/client/chat.tsx +4 -2
  209. package/templates/chat/src/frame.ts +17 -0
  210. package/templates/chat/src/main.ts +1 -6
  211. package/templates/chat/src/page.tsx +6 -4
  212. package/templates/chat/src/routes.ts +14 -4
  213. package/templates/chat/src/runtime.ts +9 -3
  214. package/templates/chat/template.json +3 -1
  215. package/templates/chat/test/chat.test.ts +46 -1
  216. package/templates/chat/test/server.test.ts +17 -3
  217. package/templates/nodejs/blend.yaml +2 -1
  218. package/templates/nodejs/gitignore +1 -0
  219. package/templates/nodejs/package.json +1 -0
  220. package/templates/nodejs/src/app.ts +76 -21
  221. package/templates/nodejs/src/frame.ts +17 -0
  222. package/templates/nodejs/src/main.ts +1 -6
  223. package/templates/nodejs/src/page.tsx +3 -3
  224. package/templates/nodejs/src/routes.ts +32 -13
  225. package/templates/nodejs/template.json +3 -1
  226. package/templates/nodejs/test/app.test.ts +38 -3
  227. package/templates/nodejs/test/server.test.ts +17 -3
  228. package/templates/notes/blend.yaml +2 -1
  229. package/templates/notes/gitignore +1 -0
  230. package/templates/notes/package.json +1 -0
  231. package/templates/notes/src/app.ts +72 -22
  232. package/templates/notes/src/frame.ts +17 -0
  233. package/templates/notes/src/main.ts +1 -6
  234. package/templates/notes/src/notes-app.ts +24 -17
  235. package/templates/notes/src/routes.ts +11 -4
  236. package/templates/notes/src/runtime.ts +9 -3
  237. package/templates/notes/src/views.tsx +26 -17
  238. package/templates/notes/template.json +3 -1
  239. package/templates/notes/test/notes.test.ts +39 -1
  240. package/templates/notes/test/server.test.ts +17 -3
  241. package/templates/todo/blend.yaml +3 -0
  242. package/templates/todo/gitignore +1 -0
  243. package/templates/todo/package.json +1 -0
  244. package/templates/todo/src/app.ts +81 -27
  245. package/templates/todo/src/door.ts +7 -0
  246. package/templates/todo/src/frame.ts +17 -0
  247. package/templates/todo/src/page.ts +13 -6
  248. package/templates/todo/src/todo.ts +18 -6
  249. package/templates/todo/template.json +11 -1
  250. package/templates/todo/test/todo.test.ts +67 -9
  251. package/lib/src-LX0E4ec4.js +0 -117
  252. package/shared/SKILL.md +0 -14
@@ -0,0 +1,471 @@
1
+ // The Feedback button on every page of the app (./index.ts serves this file and calls start with the routes' prefix). Plain browser code, no build:
2
+ // the button and its popover live in a shadow root, so the app's styles and these never touch. Pressing Feedback turns the picker on: the element
3
+ // under the pointer is outlined, a click pins it (the click does nothing else), and "Nothing specific" sends a note about the whole page.
4
+
5
+ const STYLE = `
6
+ :host { all: initial; }
7
+ * { box-sizing: border-box; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
8
+ .button { position: fixed; right: 16px; bottom: 16px; z-index: 2147483646; border: 0; border-radius: 99px; padding: 8px 14px; background: #1d1c1a; color: #fff; font-size: 12.5px; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.18); }
9
+ .button[aria-expanded="true"] { background: #c2410c; }
10
+ .pop { position: fixed; right: 16px; bottom: 58px; z-index: 2147483646; width: 320px; max-width: calc(100vw - 32px); background: #fff; color: #1d1c1a; border: 1px solid #e3dfd5; border-radius: 10px; padding: 10px 12px; box-shadow: 0 10px 30px rgba(0,0,0,.18); font-size: 12.5px; }
11
+ .pop[hidden] { display: none; }
12
+ b { font-weight: 600; }
13
+ .options { display: flex; gap: 6px; margin: 6px 0 2px; flex-wrap: wrap; }
14
+ .option { font-size: 11px; border: 1px solid #e3dfd5; border-radius: 99px; padding: 2px 9px; color: #77736b; background: transparent; cursor: pointer; text-align: left; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
15
+ .option[aria-pressed="true"] { border-color: #e0a43a; background: #fdf3e2; color: #1d1c1a; }
16
+ textarea { display: block; width: 100%; min-height: 64px; resize: vertical; border: 1px solid #e3dfd5; border-radius: 6px; padding: 6px 8px; margin: 6px 0; color: #1d1c1a; font-size: 12.5px; }
17
+ textarea:focus { outline: none; border-color: #c2410c; }
18
+ .row { display: flex; justify-content: space-between; align-items: center; gap: 10px; color: #77736b; font-size: 11px; }
19
+ .send { border: 0; border-radius: 6px; padding: 4px 11px; background: #c2410c; color: #fff; font-size: 12px; cursor: pointer; flex: none; }
20
+ .send:disabled { opacity: .5; cursor: default; }
21
+ .note[data-error] { color: #b42318; }
22
+ .outline { position: fixed; z-index: 2147483645; pointer-events: none; border: 2px dashed #e0a43a; border-radius: 6px; display: none; }
23
+ .outline[data-picked] { display: block; }
24
+ .outline span { position: absolute; right: 6px; top: -11px; background: #e0a43a; color: #1d1c1a; font-size: 10.5px; font-weight: 600; padding: 1px 7px; border-radius: 4px; white-space: nowrap; }
25
+ .pins { position: fixed; inset: 0; pointer-events: none; z-index: 2147483644; }
26
+ .pin { position: absolute; width: 22px; height: 22px; border-radius: 50% 50% 50% 0; background: #e0a43a; color: #1d1c1a; font-size: 11px; font-weight: 700; display: grid; place-items: center; transform: rotate(-45deg); box-shadow: 0 1px 3px rgba(0,0,0,.25); }
27
+ .pin span { transform: rotate(45deg); }
28
+ .shotRow[hidden] { display: none; }
29
+ .shot { display: flex; align-items: center; gap: 6px; margin: 6px 0 0; color: #4b4b52; font-size: 12px; cursor: pointer; }
30
+ .shot input { width: auto; margin: 0; accent-color: #c2410c; }
31
+ .shotHint { margin: 2px 0 0; color: #8a867c; font-size: 11px; }
32
+ `
33
+
34
+ /** A short name for an element: what it says it is, else the words it shows, else its tag. */
35
+ export function elementLabel(element) {
36
+ const named =
37
+ element.getAttribute('aria-label') ||
38
+ element.getAttribute('title') ||
39
+ element.getAttribute('alt')
40
+ if (named) return clip(named)
41
+ // A heading names its section; bold text inside does not (a card's bold person name is not the card), so it falls to the first text.
42
+ const heading = element.querySelector('h1, h2, h3, h4, h5, h6')
43
+ const text = (heading?.textContent || firstText(element) || '').replace(/\s+/g, ' ').trim()
44
+ return text === '' ? element.tagName.toLowerCase() : `"${clip(text)}"`
45
+ }
46
+
47
+ /** The first piece of text an element shows, in document order. */
48
+ function firstText(node) {
49
+ for (const child of node.childNodes) {
50
+ if (child.nodeType === 3 && (child.textContent || '').trim() !== '') return child.textContent
51
+ if (child.nodeType === 1) {
52
+ const tag = child.tagName.toLowerCase()
53
+ if (tag === 'script' || tag === 'style') continue
54
+ const found = firstText(child)
55
+ if (found !== undefined) return found
56
+ }
57
+ }
58
+ return undefined
59
+ }
60
+
61
+ function clip(text) {
62
+ return text.length > 36 ? `${text.slice(0, 34).trimEnd()}…` : text
63
+ }
64
+
65
+ /** A CSS selector that finds the element again: up from it to the nearest id, each step its tag and place among its siblings. */
66
+ export function elementSelector(element) {
67
+ const parts = []
68
+ for (
69
+ let node = element;
70
+ node && node.nodeType === 1 && parts.length < 8;
71
+ node = node.parentElement
72
+ ) {
73
+ if (node.id) {
74
+ parts.unshift(`#${cssEscape(node.id)}`)
75
+ break
76
+ }
77
+ const tag = node.tagName.toLowerCase()
78
+ if (tag === 'body' || tag === 'html') {
79
+ parts.unshift(tag)
80
+ break
81
+ }
82
+ const same = node.parentElement
83
+ ? [...node.parentElement.children].filter((child) => child.tagName === node.tagName)
84
+ : []
85
+ parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(node) + 1})` : tag)
86
+ }
87
+ return parts.join(' > ')
88
+ }
89
+
90
+ function cssEscape(value) {
91
+ return typeof globalThis.CSS?.escape === 'function'
92
+ ? globalThis.CSS.escape(value)
93
+ : value.replace(/[^A-Za-z0-9_-]/g, '\\$&')
94
+ }
95
+
96
+ // The ACRYL frame's view (ide/acryl-blends-frame/src/client/frame-view.ts): the frame's canvas hides the app's own build-time controls in Build and
97
+ // shows what production shows in Preview. A page opened directly has no frame parent and keeps its own behavior.
98
+ const FRAME_VIEW = Symbol.for('acryl.frame.view')
99
+ const FRAME_MODE_EVENT = 'acryl:frame-mode'
100
+
101
+ /** The frame's view handle, or undefined when this page is the top window or its parent is cross-origin. */
102
+ export function frameViewOf(win) {
103
+ try {
104
+ if (win.parent === win) return undefined
105
+ return win.parent[FRAME_VIEW]
106
+ } catch {
107
+ return undefined
108
+ }
109
+ }
110
+
111
+ /** The size a screenshot is stored at: the whole view, downscaled to `maxSide`. */
112
+ export function shotSize(width, height, maxSide = 1280) {
113
+ const scale = Math.min(1, maxSide / Math.max(width, height))
114
+ return {
115
+ width: Math.max(1, Math.round(width * scale)),
116
+ height: Math.max(1, Math.round(height * scale)),
117
+ }
118
+ }
119
+
120
+ /** A screenshot is re-encoded below this size; the Host refuses more than 512 KB decoded. */
121
+ export const SHOT_MAX_BYTES = 400 * 1024
122
+
123
+ /**
124
+ * Whether this browser can capture the current tab. Chromium's getDisplayMedia takes `preferCurrentTab`; Firefox and Safari ignore it and would offer
125
+ * the whole screen, which is not what a visitor sending feedback means, so the form does not offer the option there at all.
126
+ */
127
+ export function canCaptureTab(userAgent, hasDisplayCapture) {
128
+ if (hasDisplayCapture !== true) return false
129
+ if (/Android|iPhone|iPad|iPod/u.test(userAgent)) return false
130
+ return /Chrome\/|Chromium\/|Edg\/|OPR\//u.test(userAgent)
131
+ }
132
+
133
+ // biome-ignore lint/correctness/noUnusedVariables: ./index.ts serves this file with a call to start appended
134
+ function start(prefix) {
135
+ if (document.querySelector('acryl-feedback')) return
136
+ const host = document.createElement('acryl-feedback')
137
+ const root = host.attachShadow({ mode: 'open' })
138
+ root.innerHTML = `<style>${STYLE}</style>
139
+ <div class="outline"><span></span></div>
140
+ <div class="pins"></div>
141
+ <div class="pop" role="dialog" aria-label="Your feedback" hidden>
142
+ <b>Your feedback</b>
143
+ <div class="options">
144
+ <button type="button" class="option" data-choice="element" aria-pressed="true">⌖ Click the element you mean</button>
145
+ <button type="button" class="option" data-choice="page" aria-pressed="false">Nothing specific</button>
146
+ </div>
147
+ <textarea aria-label="Your note" placeholder="What happened, or what would help?" maxlength="2000"></textarea>
148
+ <div class="shotRow">
149
+ <label class="shot"><input type="checkbox" class="shotBox" checked> Attach a screenshot of this page</label>
150
+ <p class="shotHint">Your browser will ask to share this tab.</p>
151
+ </div>
152
+ <div class="row"><span class="note">Pressing Feedback turns the picker on; click anything, or choose "Nothing specific".</span><button type="button" class="send" disabled>Send</button></div>
153
+ </div>
154
+ <button type="button" class="button" aria-expanded="false">Feedback</button>`
155
+ document.body.appendChild(host)
156
+
157
+ const button = root.querySelector('.button')
158
+ const pop = root.querySelector('.pop')
159
+ const outline = root.querySelector('.outline')
160
+ const outlineLabel = outline.querySelector('span')
161
+ const pinsLayer = root.querySelector('.pins')
162
+ /** The numbered pins for this page: a note already on an element shows a badge there, as the mockup's production does. */
163
+ let pins = []
164
+ const placePins = () => {
165
+ pinsLayer.replaceChildren()
166
+ for (const pin of pins) {
167
+ const target = document.querySelector(pin.selector)
168
+ if (target === null || target.getClientRects().length === 0) continue
169
+ const box = target.getBoundingClientRect()
170
+ const badge = document.createElement('span')
171
+ badge.className = 'pin'
172
+ badge.style.left = `${box.right - 11}px`
173
+ badge.style.top = `${box.top - 9}px`
174
+ const number = document.createElement('span')
175
+ number.textContent = String(pin.number)
176
+ badge.append(number)
177
+ pinsLayer.append(badge)
178
+ }
179
+ }
180
+ const readPins = async () => {
181
+ try {
182
+ const response = await fetch(
183
+ `${prefix}/pins?page=${encodeURIComponent(location.pathname + location.search)}`,
184
+ {
185
+ headers: { Accept: 'application/json' },
186
+ },
187
+ )
188
+ if (!response.ok) return
189
+ const body = await response.json()
190
+ pins = Array.isArray(body?.pins)
191
+ ? body.pins.filter(
192
+ (pin) =>
193
+ pin !== null &&
194
+ typeof pin === 'object' &&
195
+ typeof pin.selector === 'string' &&
196
+ Number.isInteger(pin.number),
197
+ )
198
+ : []
199
+ placePins()
200
+ } catch {
201
+ // No pins this time; the next read tries again.
202
+ }
203
+ }
204
+ const onElement = root.querySelector('[data-choice="element"]')
205
+ const nothing = root.querySelector('[data-choice="page"]')
206
+ const text = root.querySelector('textarea')
207
+ const send = root.querySelector('.send')
208
+ const note = root.querySelector('.note')
209
+ const hint = note.textContent
210
+
211
+ /** @type {{ open: boolean, picking: boolean, picked: Element | null, choice: 'element' | 'page' }} */
212
+ const state = { open: false, picking: false, picked: null, choice: 'element', attach: true }
213
+ const canShot = canCaptureTab(
214
+ navigator.userAgent,
215
+ typeof navigator.mediaDevices?.getDisplayMedia === 'function',
216
+ )
217
+ const shotRow = root.querySelector('.shotRow')
218
+ const shotBox = root.querySelector('.shotBox')
219
+ if (!canShot) {
220
+ shotRow.hidden = true
221
+ state.attach = false
222
+ }
223
+ shotBox.addEventListener('change', () => {
224
+ state.attach = shotBox.checked
225
+ })
226
+
227
+ const place = (element, label) => {
228
+ if (!element) {
229
+ outline.removeAttribute('data-picked')
230
+ return
231
+ }
232
+ const box = element.getBoundingClientRect()
233
+ Object.assign(outline.style, {
234
+ left: `${box.left - 3}px`,
235
+ top: `${box.top - 3}px`,
236
+ width: `${box.width + 6}px`,
237
+ height: `${box.height + 6}px`,
238
+ })
239
+ outlineLabel.textContent = label
240
+ outline.setAttribute('data-picked', '')
241
+ }
242
+ const render = () => {
243
+ button.setAttribute('aria-expanded', String(state.open))
244
+ pop.hidden = !state.open
245
+ onElement.setAttribute('aria-pressed', String(state.choice === 'element'))
246
+ nothing.setAttribute('aria-pressed', String(state.choice === 'page'))
247
+ onElement.textContent = state.picked
248
+ ? `⌖ On this element: ${elementLabel(state.picked)}`
249
+ : '⌖ Click the element you mean'
250
+ send.disabled = text.value.trim() === '' || (state.choice === 'element' && !state.picked)
251
+ if (state.open && state.choice === 'element' && state.picked)
252
+ place(state.picked, `Picked: ${elementLabel(state.picked)}`)
253
+ else if (!state.picking) place(null)
254
+ }
255
+ const ours = (event) => event.composedPath().includes(host)
256
+ const onMove = (event) => {
257
+ if (!state.picking || ours(event)) return
258
+ const target = event.target instanceof Element ? event.target : null
259
+ if (target) place(target, elementLabel(target))
260
+ }
261
+ const onClick = (event) => {
262
+ if (!state.picking || ours(event)) return
263
+ event.preventDefault()
264
+ event.stopPropagation()
265
+ if (!(event.target instanceof Element)) return
266
+ state.picked = event.target
267
+ state.picking = false
268
+ state.choice = 'element'
269
+ render()
270
+ text.focus()
271
+ }
272
+ const onKey = (event) => {
273
+ if (event.key === 'Escape' && state.open) close()
274
+ }
275
+ const reposition = () => {
276
+ if (state.open) render()
277
+ }
278
+ const open = () => {
279
+ state.open = true
280
+ state.picking = true
281
+ state.picked = null
282
+ state.choice = 'element'
283
+ note.textContent = hint
284
+ note.removeAttribute('data-error')
285
+ render()
286
+ }
287
+ const close = () => {
288
+ state.open = false
289
+ state.picking = false
290
+ state.picked = null
291
+ render()
292
+ }
293
+
294
+ /**
295
+ * The tab as the person sees it, with the picker's outline on the page, as a WebP (PNG where WebP is unavailable). The Feedback button and the
296
+ * note form are hidden for the picture, so it shows the app and the outline, not the form. A refused picker or a browser without screen capture
297
+ * sends the note without an image.
298
+ */
299
+ const takeShot = async () => {
300
+ if (!canShot) return undefined
301
+ let stream
302
+ try {
303
+ // Called from the Send click itself: the browser opens its picker only inside a gesture.
304
+ stream = await navigator.mediaDevices.getDisplayMedia({
305
+ video: { width: { max: 1920 }, height: { max: 1080 } },
306
+ preferCurrentTab: true,
307
+ audio: false,
308
+ })
309
+ } catch {
310
+ return undefined
311
+ }
312
+ const popHidden = pop.hidden
313
+ const buttonDisplay = button.style.display
314
+ try {
315
+ const video = document.createElement('video')
316
+ video.srcObject = stream
317
+ video.muted = true
318
+ await video.play()
319
+ if (video.readyState < 2)
320
+ await new Promise((resolve) =>
321
+ video.addEventListener('loadeddata', resolve, { once: true }),
322
+ )
323
+ // Hide the form after the picker closed; the outline the picker drew stays on the page and in the picture.
324
+ pop.hidden = true
325
+ button.style.display = 'none'
326
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
327
+ // A tab capture delivers frames a little late: wait for frames made after the hide, so the picture shows the app, not the form.
328
+ await new Promise((resolve) => {
329
+ const done = () => resolve()
330
+ const timer = setTimeout(done, 600)
331
+ if (typeof video.requestVideoFrameCallback !== 'function') return
332
+ let frames = 0
333
+ const step = () => {
334
+ frames += 1
335
+ if (frames >= 3) {
336
+ clearTimeout(timer)
337
+ done()
338
+ return
339
+ }
340
+ video.requestVideoFrameCallback(step)
341
+ }
342
+ video.requestVideoFrameCallback(step)
343
+ })
344
+ let maxSide = 1280
345
+ for (let attempt = 0; attempt < 2; attempt += 1) {
346
+ const size = shotSize(video.videoWidth || 1, video.videoHeight || 1, maxSide)
347
+ const canvas = document.createElement('canvas')
348
+ canvas.width = size.width
349
+ canvas.height = size.height
350
+ const context = canvas.getContext('2d')
351
+ if (context === null) return undefined
352
+ context.drawImage(video, 0, 0, size.width, size.height)
353
+ let blob = await new Promise((resolve) =>
354
+ canvas.toBlob(resolve, 'image/webp', attempt === 0 ? 0.82 : 0.7),
355
+ )
356
+ if (blob === null)
357
+ blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
358
+ if (blob === null) return undefined
359
+ if (blob.size <= SHOT_MAX_BYTES) {
360
+ const data = await new Promise((resolve, reject) => {
361
+ const reader = new FileReader()
362
+ reader.onload = () => resolve(reader.result)
363
+ reader.onerror = () => reject(reader.error)
364
+ reader.readAsDataURL(blob)
365
+ })
366
+ return { data, width: size.width, height: size.height }
367
+ }
368
+ maxSide = Math.max(640, Math.round(maxSide * 0.72))
369
+ }
370
+ return undefined
371
+ } catch {
372
+ return undefined
373
+ } finally {
374
+ pop.hidden = popHidden
375
+ button.style.display = buttonDisplay
376
+ for (const track of stream.getTracks()) track.stop()
377
+ }
378
+ }
379
+
380
+ button.addEventListener('click', () => {
381
+ if (state.open) close()
382
+ else open()
383
+ })
384
+ onElement.addEventListener('click', () => {
385
+ state.choice = 'element'
386
+ state.picking = true
387
+ state.picked = null
388
+ render()
389
+ })
390
+ nothing.addEventListener('click', () => {
391
+ state.choice = 'page'
392
+ state.picking = false
393
+ state.picked = null
394
+ render()
395
+ text.focus()
396
+ })
397
+ text.addEventListener('input', render)
398
+ send.addEventListener('click', async () => {
399
+ send.disabled = true
400
+ const element =
401
+ state.choice === 'element' && state.picked
402
+ ? { selector: elementSelector(state.picked), label: elementLabel(state.picked) }
403
+ : undefined
404
+ // The screenshot is taken here, inside the click, only when the person left the option on; a refused capture still sends the note.
405
+ const screenshot = state.attach ? await takeShot() : undefined
406
+ try {
407
+ const response = await fetch(`${prefix}/notes`, {
408
+ method: 'POST',
409
+ headers: { 'content-type': 'application/json' },
410
+ body: JSON.stringify({
411
+ text: text.value,
412
+ page: location.pathname + location.search,
413
+ ...(element ? { element } : {}),
414
+ ...(screenshot ? { screenshot } : {}),
415
+ }),
416
+ })
417
+ if (!response.ok)
418
+ throw new Error(
419
+ (await response.json().catch(() => ({}))).error || `sending failed (${response.status})`,
420
+ )
421
+ text.value = ''
422
+ close()
423
+ button.textContent = 'Thanks, sent'
424
+ setTimeout(() => {
425
+ button.textContent = 'Feedback'
426
+ }, 2500)
427
+ } catch (error) {
428
+ note.textContent = error instanceof Error ? error.message : 'sending failed'
429
+ note.setAttribute('data-error', '')
430
+ render()
431
+ }
432
+ })
433
+ // The frame's Build is the builder's screen: the users' Feedback button is not part of it. Preview shows what production shows, and the app runs
434
+ // feedback in production, so the button returns there. A direct page (no frame) is unchanged.
435
+ const frame = frameViewOf(window)
436
+ const applyFrame = () => {
437
+ const visible = frame === undefined || frame.mode !== 'build'
438
+ host.style.display = visible ? '' : 'none'
439
+ if (!visible && state.open) close()
440
+ if (visible) void readPins()
441
+ }
442
+ window.addEventListener(FRAME_MODE_EVENT, applyFrame)
443
+ applyFrame()
444
+ // agent-panel sits in the same corner: its button in development, the Assistant docked on the right in production. Stand to the left of whichever shows.
445
+ const besideOthers = () => {
446
+ const shadow = document.getElementById('agent-panel')?.shadowRoot
447
+ const shown = (element) =>
448
+ element && getComputedStyle(element).display !== 'none'
449
+ ? element.getBoundingClientRect()
450
+ : null
451
+ const box =
452
+ shown(shadow?.querySelector('section.shown')) ?? shown(shadow?.querySelector('button.open'))
453
+ const right = box && box.width > 0 ? `${Math.round(window.innerWidth - box.left + 8)}px` : ''
454
+ button.style.right = right
455
+ pop.style.right = right
456
+ }
457
+ for (const delay of [0, 500, 2000]) setTimeout(besideOthers, delay)
458
+ window.addEventListener('agent-panel:layout', besideOthers)
459
+ window.addEventListener('resize', besideOthers)
460
+ document.addEventListener('mousemove', onMove, true)
461
+ document.addEventListener('click', onClick, true)
462
+ document.addEventListener('keydown', onKey, true)
463
+ window.addEventListener('scroll', reposition, true)
464
+ window.addEventListener('resize', reposition)
465
+ // The builder's Build screen has no user feedback in it, so the pins are read again when the frame's view comes back.
466
+ window.setInterval(() => {
467
+ if (host.style.display !== 'none') void readPins()
468
+ }, 15_000)
469
+ window.addEventListener('scroll', placePins, true)
470
+ window.addEventListener('resize', placePins)
471
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Feedback from the people who use the app (docs/design/blends-frame-mockup.html, scenario 9): a Feedback button on every page of the app, in
3
+ * production too. Pressing it turns a picker on: the person clicks the element where they hit a problem or had an idea, or chooses "Nothing
4
+ * specific", writes a note and sends it. The notes are kept in the app's own data and read through the `feedback` service, which the ACRYL frame
5
+ * shows in the builder's left panel ("Feedback from users"), where one click hands a note to an agent.
6
+ *
7
+ * - A middleware appends one script tag (`<mount>/api/feedback/feedback.js`) to every HTML response, so no page is edited (agent-panel's way).
8
+ * - In the ACRYL frame's canvas the button follows the frame's view: Build hides it (the builder has its own left panel for notes), and Preview shows it as
9
+ * production does. A page opened directly keeps it on every page.
10
+ * - `POST <mount>/api/feedback/notes` takes one note from the app's own page (same origin), at most 30 a minute across the app. The form offers
11
+ * "Attach a screenshot of this page" (checked) and says the browser will ask to share the tab; the page adds the tab capture with the picker's
12
+ * outline, downscaled and capped. Only Chromium is offered the option (Firefox and Safari ignore preferCurrentTab and would share the whole
13
+ * screen); unchecking it or a refused capture still sends the note.
14
+ * - `GET <mount>/api/feedback/pins?page=...` answers the numbered badges a page draws on the elements people commented on (the mockup's
15
+ * production feedback): each selector with its number, and no note text, since anyone who opens the page can read this route.
16
+ * - Nobody reads the notes over HTTP here: the builder reads them through the service. Opt-in: `acrylblends add feedback`.
17
+ */
18
+ import { readFileSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+ import type { Context } from '@deepseek-ai/cordis'
21
+ import { Acryl } from '../acryl-router/src/index.ts'
22
+ import type {} from '../instance/index.ts'
23
+ import {
24
+ type FeedbackNote,
25
+ FeedbackStore,
26
+ LIMITS,
27
+ parseNewNote,
28
+ type StoredScreenshot,
29
+ } from './store.ts'
30
+
31
+ export type { FeedbackNote, FeedbackPin, PinnedElement } from './store.ts'
32
+
33
+ export const name = 'feedback'
34
+ export const inject = ['router', 'appInstance']
35
+
36
+ /** What the builder's tools read: every note, newest first, and marking notes seen. */
37
+ export interface Feedback {
38
+ list(): Promise<FeedbackNote[]>
39
+ markSeen(ids: readonly string[]): Promise<void>
40
+ /** A note's screenshot, when it has one and the file is still there. */
41
+ screenshot(id: string): Promise<StoredScreenshot | undefined>
42
+ }
43
+
44
+ declare module '@deepseek-ai/cordis' {
45
+ interface Context {
46
+ feedback: Feedback
47
+ }
48
+ }
49
+
50
+ export interface FeedbackConfig {
51
+ /** The URL prefix the app is mounted at: empty on the app's own server, `/app` when the ACRYL frame hosts it (agent-panel's `mount`). */
52
+ mount?: string
53
+ }
54
+
55
+ const NOTES_PER_MINUTE = 30
56
+ const MOUNT = /^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/u
57
+ const CLIENT = readFileSync(join(import.meta.dirname, 'client.js'), 'utf8')
58
+
59
+ /** A request from the app's own page: a browser sends Origin on a POST, and it must name this host. */
60
+ function fromOwnPage(headers: Headers): boolean {
61
+ const origin = headers.get('origin')
62
+ const host = headers.get('host')
63
+ if (origin === null || host === null) return false
64
+ try {
65
+ return new URL(origin).host === host
66
+ } catch {
67
+ return false
68
+ }
69
+ }
70
+
71
+ export function apply(ctx: Context, config: FeedbackConfig = {}): void {
72
+ const mount = config.mount ?? ''
73
+ if (mount !== '' && !MOUNT.test(mount))
74
+ throw new TypeError(
75
+ `feedback: the app's mount "${mount}" must be empty or an absolute path such as "/app"`,
76
+ )
77
+ const FEEDBACK_PATH = '/api/feedback'
78
+ const PREFIX = `${mount}${FEEDBACK_PATH}`
79
+ // The routes mount at the app's own mount and keep the fixed feedback path inside (the frame's router prefixes the mount, not the routes).
80
+ const APP_MOUNT = mount
81
+ const store = new FeedbackStore(join(ctx.appInstance.home, 'feedback'))
82
+ ctx.effect(
83
+ () =>
84
+ ctx.reflect.provide('feedback', {
85
+ list: () => store.list(),
86
+ markSeen: (ids) => store.markSeen(ids),
87
+ screenshot: (id) => store.screenshot(id),
88
+ } satisfies Feedback),
89
+ 'feedback: service',
90
+ )
91
+
92
+ const tag = `<script type="module" src="${PREFIX}/feedback.js"></script>`
93
+ const addScript = async (c: { res: Response }, next: () => Promise<void>): Promise<void> => {
94
+ await next()
95
+ if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) return
96
+ const html = await c.res.text()
97
+ const headers = new Headers(c.res.headers)
98
+ headers.delete('content-length')
99
+ const body = html.includes(`${PREFIX}/feedback.js`)
100
+ ? html
101
+ : html.includes('</body>')
102
+ ? html.replace('</body>', `${tag}</body>`)
103
+ : html + tag
104
+ c.res = new Response(body, { status: c.res.status, headers })
105
+ }
106
+ const router = ctx.router as unknown as { wrap?: (middleware: typeof addScript) => unknown }
107
+ const wrapped = typeof router.wrap === 'function'
108
+ if (wrapped) router.wrap?.(addScript)
109
+
110
+ const sent: number[] = []
111
+ const app = new Acryl()
112
+ .use('*', async (c, next) => {
113
+ if (wrapped) return next()
114
+ await addScript(c, next)
115
+ })
116
+ .get(`${FEEDBACK_PATH}/feedback.js`, (c) =>
117
+ c.body(`${CLIENT}\nstart(${JSON.stringify(PREFIX)})\n`, 200, {
118
+ 'content-type': 'text/javascript; charset=utf-8',
119
+ 'cache-control': 'no-cache',
120
+ }),
121
+ )
122
+ .get(`${FEEDBACK_PATH}/pins`, async (c) => {
123
+ const page = c.req.query('page')
124
+ if (typeof page !== 'string' || !page.startsWith('/') || page.length > LIMITS.page)
125
+ return c.json({ error: 'the page must be a path' }, 400)
126
+ // The page's visitors can read this route: it says where the commented elements are and nothing the notes say.
127
+ return c.json({ pins: await store.pins(page) }, 200, { 'cache-control': 'no-store' })
128
+ })
129
+ .post(`${FEEDBACK_PATH}/notes`, async (c) => {
130
+ if (!fromOwnPage(c.req.raw.headers))
131
+ return c.json({ error: "only the app's own page may send feedback" }, 403)
132
+ const now = Date.now()
133
+ while (sent.length > 0 && (sent[0] as number) < now - 60_000) sent.shift()
134
+ if (sent.length >= NOTES_PER_MINUTE)
135
+ return c.json({ error: 'too much feedback at once; try again in a minute' }, 429)
136
+ const note = parseNewNote(await c.req.json().catch(() => undefined))
137
+ if (typeof note === 'string') return c.json({ error: note }, 400)
138
+ sent.push(now)
139
+ const stored = await store.add(note)
140
+ return c.json({ id: stored.id }, 201)
141
+ })
142
+ ctx.router.route(APP_MOUNT === '' ? '/' : APP_MOUNT, app)
143
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "feedback",
3
+ "description": "Feedback from the people who use the app: a Feedback button on every page, in production too, that lets them pinpoint the element they mean (or choose \"Nothing specific\") and send a note; the ACRYL frame shows the notes in the builder's left panel.",
4
+ "requires": ["acryl-router", "instance"],
5
+ "inject": ["router", "appInstance"],
6
+ "provides": ["feedback"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ },
10
+ "mount": "add a row `- id: feedback` / `name: feedback` to blend.yaml BEFORE the `routes` row (its middleware adds one script tag to every HTML page the routes after it answer) and register it in src/app.ts: 'feedback': feedback"
11
+ }