acrylblends 1.1.0 → 1.3.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 (263) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +47 -2
  3. package/dist/index.js +2017 -32
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/getting-started/desktop.md +23 -4
  7. package/docs/framework/guides/create-acrylblends.md +5 -3
  8. package/docs/framework/workshop/README.md +139 -153
  9. package/docs/framework/workshop/facilitator.md +69 -101
  10. package/legacy/README.md +9 -0
  11. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  12. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  13. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  14. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  15. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  16. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  17. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  18. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  19. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  20. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  21. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  22. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  23. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  24. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  25. package/legacy/agent/855359304707/src/app.ts +168 -0
  26. package/legacy/agent/855359304707/src/routes.ts +126 -0
  27. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  28. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  29. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  30. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  31. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  32. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  33. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  34. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  35. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  36. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  37. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  38. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  39. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  40. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  41. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  42. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  43. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  44. package/legacy/layouts.json +524 -0
  45. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  46. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  47. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  48. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  49. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  50. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  51. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  52. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  53. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  54. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  55. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  56. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  57. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  58. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  59. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  60. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  61. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  62. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  63. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  64. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  65. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  66. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  67. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  68. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  69. package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
  70. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  71. package/lib/cordis/door.d.ts +87 -9
  72. package/lib/cordis/door.js +324 -60
  73. package/lib/cordis/inspect.js +1 -1
  74. package/lib/cordis/node-server.d.ts +101 -3
  75. package/lib/cordis/node-server.js +3 -3
  76. package/lib/cordis/router-http.d.ts +7 -1
  77. package/lib/cordis/router-http.js +44 -3
  78. package/lib/cordis/router.d.ts +1 -1
  79. package/lib/cordis/router.js +1 -1
  80. package/lib/cordis/state.js +1 -1
  81. package/lib/door/host.d.ts +2 -2
  82. package/lib/door/host.js +1 -1
  83. package/lib/door/page.d.ts +10 -1
  84. package/lib/door/page.js +46 -7
  85. package/lib/door/protocol.d.ts +1 -1
  86. package/lib/drivers.d.ts +10 -1
  87. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  88. package/lib/node-server.d.ts +7 -1
  89. package/lib/node-server.js +2 -2
  90. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  91. package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
  92. package/lib/src-CtkIX_Gg.js +427 -0
  93. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  94. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  95. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  96. package/package.json +2 -1
  97. package/plugins/acryl-door/plugin.json +3 -0
  98. package/plugins/acryl-door/src/cli/acryl.ts +63 -6
  99. package/plugins/acryl-door/src/host/channel.ts +86 -9
  100. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  101. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  102. package/plugins/acryl-door/src/host/door.ts +128 -24
  103. package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
  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 +14 -1
  107. package/plugins/acryl-door/src/index.ts +168 -30
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +3 -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 +32 -4
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +41 -2
  115. package/plugins/acryl-door/src/page/outline.ts +56 -0
  116. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  117. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  118. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  119. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  120. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  121. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  122. package/plugins/acryl-node-server/src/index.ts +304 -16
  123. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  124. package/plugins/acryl-page/plugin.json +12 -0
  125. package/plugins/acryl-page/src/index.ts +163 -0
  126. package/plugins/acryl-router/src/service.ts +77 -14
  127. package/plugins/acryl-router-http/src/index.ts +15 -1
  128. package/plugins/acryl-router-http/src/serve.ts +31 -0
  129. package/plugins/acryl-web-server/plugin.json +14 -0
  130. package/plugins/acryl-web-server/src/index.ts +164 -0
  131. package/plugins/agent/agent.ts +38 -7
  132. package/plugins/agent/index.ts +1 -1
  133. package/plugins/agent/model.ts +10 -2
  134. package/plugins/agent/tools.ts +9 -3
  135. package/plugins/agent-panel/client/describe.ts +57 -0
  136. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  137. package/plugins/agent-panel/client/frame.ts +51 -0
  138. package/plugins/agent-panel/client/main.ts +238 -26
  139. package/plugins/agent-panel/drivers.ts +7 -2
  140. package/plugins/agent-panel/headless.ts +5 -2
  141. package/plugins/agent-panel/index.ts +146 -57
  142. package/plugins/agent-panel/mount.ts +27 -0
  143. package/plugins/agent-panel/visitors.ts +231 -0
  144. package/plugins/builder/index.ts +13 -35
  145. package/plugins/builder/package.ts +186 -0
  146. package/plugins/builder/plugin.json +3 -1
  147. package/plugins/desktop/frame.ts +231 -0
  148. package/plugins/desktop/launch.ts +44 -9
  149. package/plugins/desktop/main.ts +100 -32
  150. package/plugins/desktop/window.ts +21 -4
  151. package/plugins/driver-pty/client/terminal.ts +4 -1
  152. package/plugins/driver-pty/index.ts +38 -11
  153. package/plugins/driver-pty/spawn.ts +38 -0
  154. package/plugins/driver-pty/terminal.ts +2 -0
  155. package/plugins/extensions/index.ts +47 -18
  156. package/plugins/extensions/plugin-store.ts +27 -0
  157. package/plugins/extensions/plugin.json +1 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/extensions/stage.ts +15 -5
  160. package/plugins/feedback/client.d.ts +22 -0
  161. package/plugins/feedback/client.js +471 -0
  162. package/plugins/feedback/index.ts +143 -0
  163. package/plugins/feedback/plugin.json +11 -0
  164. package/plugins/feedback/store.ts +307 -0
  165. package/plugins/home/index.ts +122 -0
  166. package/plugins/home/plugin.json +11 -0
  167. package/plugins/instance/index.ts +6 -0
  168. package/plugins/manifest/app-plugins.ts +86 -0
  169. package/plugins/manifest/index.ts +12 -2
  170. package/plugins/manifest/plugin.json +1 -1
  171. package/plugins/manifest/snapshot.ts +275 -0
  172. package/plugins/openai/index.ts +64 -5
  173. package/plugins/openai/prompt.ts +23 -0
  174. package/plugins/plugin-share/README.md +51 -0
  175. package/plugins/plugin-share/index.ts +467 -0
  176. package/plugins/plugin-share/plugin.json +15 -0
  177. package/plugins/plugin-share/publish.ts +65 -0
  178. package/plugins/plugin-share/pull-check.ts +129 -0
  179. package/plugins/plugin-share/pulled-records.ts +70 -0
  180. package/shared/AGENTS.core.md +36 -16
  181. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  182. package/shared/skills/acryl-app/SKILL.md +13 -0
  183. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  184. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  185. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  186. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  187. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  188. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  189. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  190. package/templates/agent/.gitattributes +2 -0
  191. package/templates/agent/AGENTS.md +1 -1
  192. package/templates/agent/blend.yaml +6 -0
  193. package/templates/agent/gitignore +1 -0
  194. package/templates/agent/package.json +1 -0
  195. package/templates/agent/src/app.ts +154 -33
  196. package/templates/agent/src/frame.ts +17 -0
  197. package/templates/agent/src/main.ts +5 -3
  198. package/templates/agent/src/routes.ts +38 -19
  199. package/templates/agent/template.json +3 -0
  200. package/templates/agent/test/app.test.ts +45 -3
  201. package/templates/blank/.gitattributes +2 -0
  202. package/templates/blank/AGENTS.md +3 -2
  203. package/templates/blank/blend.yaml +11 -0
  204. package/templates/blank/gitignore +1 -0
  205. package/templates/blank/package.json +1 -0
  206. package/templates/blank/src/app.ts +100 -29
  207. package/templates/blank/src/door.ts +7 -0
  208. package/templates/blank/src/first-page.ts +5 -0
  209. package/templates/blank/src/frame.ts +17 -0
  210. package/templates/blank/src/routes.ts +65 -8
  211. package/templates/blank/template.json +14 -1
  212. package/templates/blank/test/app.test.ts +103 -16
  213. package/templates/chat/.gitattributes +2 -0
  214. package/templates/chat/blend.yaml +2 -1
  215. package/templates/chat/gitignore +1 -0
  216. package/templates/chat/package.json +1 -0
  217. package/templates/chat/src/app.ts +72 -22
  218. package/templates/chat/src/chat-app.ts +11 -2
  219. package/templates/chat/src/client/chat.tsx +4 -2
  220. package/templates/chat/src/frame.ts +17 -0
  221. package/templates/chat/src/page.tsx +6 -4
  222. package/templates/chat/src/routes.ts +14 -4
  223. package/templates/chat/src/runtime.ts +9 -3
  224. package/templates/chat/template.json +3 -1
  225. package/templates/chat/test/chat.test.ts +46 -1
  226. package/templates/chat/test/server.test.ts +17 -3
  227. package/templates/nodejs/.gitattributes +2 -0
  228. package/templates/nodejs/blend.yaml +2 -1
  229. package/templates/nodejs/gitignore +1 -0
  230. package/templates/nodejs/package.json +1 -0
  231. package/templates/nodejs/src/app.ts +76 -21
  232. package/templates/nodejs/src/frame.ts +17 -0
  233. package/templates/nodejs/src/page.tsx +3 -3
  234. package/templates/nodejs/src/routes.ts +32 -13
  235. package/templates/nodejs/template.json +3 -1
  236. package/templates/nodejs/test/app.test.ts +38 -3
  237. package/templates/nodejs/test/server.test.ts +17 -3
  238. package/templates/notes/.gitattributes +2 -0
  239. package/templates/notes/blend.yaml +2 -1
  240. package/templates/notes/gitignore +1 -0
  241. package/templates/notes/package.json +1 -0
  242. package/templates/notes/src/app.ts +72 -22
  243. package/templates/notes/src/frame.ts +17 -0
  244. package/templates/notes/src/notes-app.ts +24 -17
  245. package/templates/notes/src/routes.ts +11 -4
  246. package/templates/notes/src/runtime.ts +9 -3
  247. package/templates/notes/src/views.tsx +26 -17
  248. package/templates/notes/template.json +3 -1
  249. package/templates/notes/test/notes.test.ts +39 -1
  250. package/templates/notes/test/server.test.ts +17 -3
  251. package/templates/todo/.gitattributes +2 -0
  252. package/templates/todo/blend.yaml +3 -0
  253. package/templates/todo/gitignore +1 -0
  254. package/templates/todo/package.json +1 -0
  255. package/templates/todo/src/app.ts +81 -27
  256. package/templates/todo/src/door.ts +7 -0
  257. package/templates/todo/src/frame.ts +17 -0
  258. package/templates/todo/src/page.ts +13 -6
  259. package/templates/todo/src/todo.ts +18 -6
  260. package/templates/todo/template.json +11 -1
  261. package/templates/todo/test/todo.test.ts +67 -9
  262. package/lib/src-LX0E4ec4.js +0 -117
  263. package/shared/SKILL.md +0 -14
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The notes people sent, in the app's own data: one JSON line per note in `notes.jsonl`, the ids the builder has seen in `seen.json`, and a note's
3
+ * screenshot as a file in `shots/`. Appending never rewrites a note, so a crash loses at most the note being written; an image is written before its
4
+ * note, so a note never names a missing file.
5
+ */
6
+ import { randomUUID } from 'node:crypto'
7
+ import { existsSync } from 'node:fs'
8
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+
11
+ /** The element a note was pinned on: a CSS selector to find it again, and a short name to show. */
12
+ export interface PinnedElement {
13
+ readonly selector: string
14
+ readonly label: string
15
+ }
16
+
17
+ export type ScreenshotMediaType = 'image/webp' | 'image/png'
18
+
19
+ /** A note's screenshot, as stored beside the notes. */
20
+ export interface FeedbackScreenshot {
21
+ /** The file under `shots/`. */
22
+ readonly file: string
23
+ readonly mediaType: ScreenshotMediaType
24
+ readonly width: number
25
+ readonly height: number
26
+ readonly bytes: number
27
+ }
28
+
29
+ /**
30
+ * A note already pinned on an element of a page, as that page's own script sees it: where to find the element again and the number its badge
31
+ * shows. It carries no note text, because anyone who opens the page can read this.
32
+ */
33
+ export interface FeedbackPin {
34
+ readonly selector: string
35
+ readonly number: number
36
+ }
37
+
38
+ export interface FeedbackNote {
39
+ readonly id: string
40
+ /** ISO time it was sent. */
41
+ readonly at: string
42
+ readonly text: string
43
+ /** The page's path (and query) it was sent from. */
44
+ readonly page: string
45
+ /** Absent for "Nothing specific". */
46
+ readonly element?: PinnedElement
47
+ /** What the person saw when they sent it; absent when the browser refused the capture. */
48
+ readonly screenshot?: FeedbackScreenshot
49
+ /** The builder has seen it in the frame. */
50
+ readonly seen: boolean
51
+ }
52
+
53
+ /** One screenshot as the page sent it, decoded and checked. */
54
+ export interface NewScreenshot {
55
+ readonly mediaType: ScreenshotMediaType
56
+ readonly width: number
57
+ readonly height: number
58
+ readonly data: Buffer
59
+ }
60
+
61
+ export interface NewNote {
62
+ readonly text: string
63
+ readonly page: string
64
+ readonly element?: PinnedElement
65
+ readonly screenshot?: NewScreenshot
66
+ }
67
+
68
+ /** A stored screenshot's bytes and type. */
69
+ export interface StoredScreenshot {
70
+ readonly bytes: Buffer
71
+ readonly mediaType: ScreenshotMediaType
72
+ }
73
+
74
+ export const LIMITS = {
75
+ text: 2000,
76
+ page: 500,
77
+ selector: 500,
78
+ label: 120,
79
+ /** Decoded image bytes; the page downscales and re-encodes below this. */
80
+ screenshotBytes: 512 * 1024,
81
+ screenshotSide: 2000,
82
+ } as const
83
+
84
+ const DATA_URL = /^data:(image\/(?:webp|png));base64,([A-Za-z0-9+/]+={0,2})$/u
85
+ const NOTE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
86
+
87
+ /** One screenshot from the page: a WebP or PNG data URL, its size, and the bytes. A string says what is wrong. */
88
+ export function parseScreenshot(value: unknown): NewScreenshot | string {
89
+ if (typeof value !== 'object' || value === null) return 'the screenshot must be an object'
90
+ const { data, width, height } = value as Record<string, unknown>
91
+ if (typeof data !== 'string') return 'the screenshot has no image'
92
+ const match = DATA_URL.exec(data)
93
+ if (match === null || match[1] === undefined || match[2] === undefined)
94
+ return 'the screenshot must be a WebP or PNG data URL'
95
+ const mediaType = match[1] as ScreenshotMediaType
96
+ const bytes = Buffer.from(match[2], 'base64')
97
+ if (bytes.length === 0) return 'the screenshot is empty'
98
+ if (bytes.length > LIMITS.screenshotBytes)
99
+ return `a screenshot is at most ${String(Math.round(LIMITS.screenshotBytes / 1024))} KB`
100
+ const webp =
101
+ bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP'
102
+ const png = bytes[0] === 0x89 && bytes.subarray(1, 4).toString() === 'PNG'
103
+ if ((mediaType === 'image/webp' && !webp) || (mediaType === 'image/png' && !png))
104
+ return 'the screenshot is not the image its type names'
105
+ if (
106
+ !Number.isInteger(width) ||
107
+ !Number.isInteger(height) ||
108
+ (width as number) < 1 ||
109
+ (height as number) < 1 ||
110
+ (width as number) > LIMITS.screenshotSide ||
111
+ (height as number) > LIMITS.screenshotSide
112
+ )
113
+ return `the screenshot needs its size, up to ${String(LIMITS.screenshotSide)} px a side`
114
+ return { mediaType, width: width as number, height: height as number, data: bytes }
115
+ }
116
+
117
+ /** A note as a page sent it, checked; a string says what is wrong. */
118
+ export function parseNewNote(body: unknown): NewNote | string {
119
+ if (body === null || typeof body !== 'object') return 'send a JSON object'
120
+ const { text, page, element, screenshot } = body as Record<string, unknown>
121
+ if (typeof text !== 'string' || text.trim() === '') return 'write a note'
122
+ if (text.length > LIMITS.text) return `a note has at most ${LIMITS.text} characters`
123
+ if (typeof page !== 'string' || !page.startsWith('/') || page.length > LIMITS.page)
124
+ return 'the page must be a path'
125
+ let pinned: PinnedElement | undefined
126
+ if (element !== undefined && element !== null) {
127
+ if (typeof element !== 'object') return 'the element must be an object'
128
+ const { selector, label } = element as Record<string, unknown>
129
+ if (typeof selector !== 'string' || selector === '' || selector.length > LIMITS.selector)
130
+ return 'the element needs a selector'
131
+ if (typeof label !== 'string' || label === '' || label.length > LIMITS.label)
132
+ return 'the element needs a name'
133
+ pinned = { selector, label }
134
+ }
135
+ let shot: NewScreenshot | undefined
136
+ if (screenshot !== undefined && screenshot !== null) {
137
+ const parsed = parseScreenshot(screenshot)
138
+ if (typeof parsed === 'string') return parsed
139
+ shot = parsed
140
+ }
141
+ return {
142
+ text: text.trim(),
143
+ page,
144
+ ...(pinned === undefined ? {} : { element: pinned }),
145
+ ...(shot === undefined ? {} : { screenshot: shot }),
146
+ }
147
+ }
148
+
149
+ function storedScreenshot(raw: unknown): FeedbackScreenshot | undefined {
150
+ if (raw === null || typeof raw !== 'object') return undefined
151
+ const { file, mediaType, width, height, bytes } = raw as Record<string, unknown>
152
+ if (
153
+ typeof file !== 'string' ||
154
+ (mediaType !== 'image/webp' && mediaType !== 'image/png') ||
155
+ !Number.isInteger(width) ||
156
+ !Number.isInteger(height) ||
157
+ !Number.isInteger(bytes)
158
+ )
159
+ return undefined
160
+ return {
161
+ file,
162
+ mediaType,
163
+ width: width as number,
164
+ height: height as number,
165
+ bytes: bytes as number,
166
+ }
167
+ }
168
+
169
+ export class FeedbackStore {
170
+ private readonly notesFile: string
171
+ private readonly seenFile: string
172
+ private readonly shotsDir: string
173
+ /** Writes one after another, so two notes never interleave in the file. */
174
+ private queue: Promise<unknown> = Promise.resolve()
175
+
176
+ constructor(dir: string) {
177
+ this.notesFile = join(dir, 'notes.jsonl')
178
+ this.seenFile = join(dir, 'seen.json')
179
+ this.shotsDir = join(dir, 'shots')
180
+ }
181
+
182
+ add(note: NewNote, now: Date = new Date()): Promise<FeedbackNote> {
183
+ const id = randomUUID()
184
+ const shot = note.screenshot
185
+ const stored = {
186
+ id,
187
+ at: now.toISOString(),
188
+ text: note.text,
189
+ page: note.page,
190
+ ...(note.element === undefined ? {} : { element: note.element }),
191
+ ...(shot === undefined
192
+ ? {}
193
+ : {
194
+ screenshot: {
195
+ file: `${id}.${shot.mediaType === 'image/webp' ? 'webp' : 'png'}`,
196
+ mediaType: shot.mediaType,
197
+ width: shot.width,
198
+ height: shot.height,
199
+ bytes: shot.data.length,
200
+ },
201
+ }),
202
+ }
203
+ return this.serial(async () => {
204
+ await mkdir(join(this.notesFile, '..'), { recursive: true })
205
+ if (shot !== undefined) {
206
+ // The image first: a note in the file always has its screenshot.
207
+ await mkdir(this.shotsDir, { recursive: true })
208
+ await writeFile(
209
+ join(this.shotsDir, `${id}.${shot.mediaType === 'image/webp' ? 'webp' : 'png'}`),
210
+ shot.data,
211
+ )
212
+ }
213
+ await appendFile(this.notesFile, `${JSON.stringify(stored)}\n`)
214
+ return { ...stored, seen: false }
215
+ })
216
+ }
217
+
218
+ /** One note's screenshot, when it has one and the file is still there. */
219
+ async screenshot(id: string): Promise<StoredScreenshot | undefined> {
220
+ if (!NOTE_ID.test(id)) return undefined
221
+ for (const [ext, mediaType] of [
222
+ ['webp', 'image/webp'],
223
+ ['png', 'image/png'],
224
+ ] as const) {
225
+ try {
226
+ return { bytes: await readFile(join(this.shotsDir, `${id}.${ext}`)), mediaType }
227
+ } catch {
228
+ // Not this type; try the other.
229
+ }
230
+ }
231
+ return undefined
232
+ }
233
+
234
+ /**
235
+ * The pins of one page, for the badges its visitors see: the elements a note was pinned on, numbered in the order their first note came,
236
+ * oldest first. One pin per element, so two notes on the same element share its badge, and the number of a pin never moves once it is given.
237
+ */
238
+ async pins(page: string): Promise<FeedbackPin[]> {
239
+ const pins: FeedbackPin[] = []
240
+ const taken = new Set<string>()
241
+ for (const note of (await this.list()).reverse()) {
242
+ const element = note.element
243
+ if (note.page !== page || element === undefined || taken.has(element.selector)) continue
244
+ taken.add(element.selector)
245
+ pins.push({ selector: element.selector, number: pins.length + 1 })
246
+ }
247
+ return pins
248
+ }
249
+
250
+ /** Every note, newest first. A line that is not a note is skipped. */
251
+ async list(): Promise<FeedbackNote[]> {
252
+ if (!existsSync(this.notesFile)) return []
253
+ const seen = await this.seen()
254
+ const notes: FeedbackNote[] = []
255
+ for (const line of (await readFile(this.notesFile, 'utf8')).split('\n')) {
256
+ if (line.trim() === '') continue
257
+ try {
258
+ const raw = JSON.parse(line) as Omit<FeedbackNote, 'seen'> & {
259
+ readonly screenshot?: unknown
260
+ }
261
+ if (
262
+ typeof raw.id === 'string' &&
263
+ typeof raw.at === 'string' &&
264
+ typeof raw.text === 'string' &&
265
+ typeof raw.page === 'string'
266
+ ) {
267
+ const { screenshot, ...rest } = raw
268
+ const shot = storedScreenshot(screenshot)
269
+ notes.push({
270
+ ...rest,
271
+ seen: seen.has(raw.id),
272
+ ...(shot === undefined ? {} : { screenshot: shot }),
273
+ })
274
+ }
275
+ } catch {
276
+ // A torn last line: skipped.
277
+ }
278
+ }
279
+ return notes.reverse()
280
+ }
281
+
282
+ markSeen(ids: readonly string[]): Promise<void> {
283
+ return this.serial(async () => {
284
+ const seen = await this.seen()
285
+ for (const id of ids) seen.add(id)
286
+ await mkdir(join(this.seenFile, '..'), { recursive: true })
287
+ await writeFile(this.seenFile, JSON.stringify([...seen]))
288
+ })
289
+ }
290
+
291
+ private async seen(): Promise<Set<string>> {
292
+ try {
293
+ const ids: unknown = JSON.parse(await readFile(this.seenFile, 'utf8'))
294
+ return new Set(
295
+ Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string') : [],
296
+ )
297
+ } catch {
298
+ return new Set()
299
+ }
300
+ }
301
+
302
+ private serial<T>(work: () => Promise<T>): Promise<T> {
303
+ const next = this.queue.then(work, work)
304
+ this.queue = next.catch(() => {})
305
+ return next
306
+ }
307
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The app's home page as a place plugins put their widgets (`ctx.home`).
3
+ *
4
+ * A feature is its own plugin, so its UI must reach the page without anyone editing the page's code: a plugin registers a widget, the home page
5
+ * renders every widget registered at the time of the request, and the widget leaves the page with the plugin that registered it (switched off in
6
+ * Settings > Plugins, removed, or reloaded). The widget is HTML rendered into the page itself, not a frame, so the page, its tests and the door's
7
+ * `look` all see it.
8
+ *
9
+ * A widget names its plugin's URLs as the app's own server answers them (`/notes/widget.js`, `fetch('/notes/items')`): on `npm start` they are at the
10
+ * root, and in the frame the canvas page sends them to the same routes under its prefix.
11
+ */
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+
14
+ /** One widget on the home page. */
15
+ export interface HomeWidget {
16
+ /** Unique on the page: the name of the plugin that registers it. */
17
+ readonly id: string
18
+ /** The widget's heading; the page escapes it. */
19
+ readonly title: string
20
+ /** The widget's HTML, rendered into the page at each request. It is inserted as it is: escape any user text in it. */
21
+ render(): string | Promise<string>
22
+ }
23
+
24
+ export interface Home {
25
+ /**
26
+ * Put a widget on the home page. Call it inside `ctx.effect` so the widget leaves with the plugin:
27
+ * `ctx.effect(() => ctx.home.widget({ id: 'notes', title: 'Notes', render: () => '<div id="notes"></div>' }))`.
28
+ * @returns the disposer, which takes the widget off the page.
29
+ * @throws when a widget with the same id is already on the page.
30
+ */
31
+ widget(widget: HomeWidget): () => void
32
+ /** The widgets on the page now, in the order they were registered. */
33
+ widgets(): readonly HomeWidget[]
34
+ }
35
+
36
+ declare module '@deepseek-ai/cordis' {
37
+ interface Context {
38
+ home: Home
39
+ }
40
+ }
41
+
42
+ const ID = /^[a-z0-9][a-z0-9-]*$/
43
+
44
+ export function createHome(): Home {
45
+ const registered = new Map<string, HomeWidget>()
46
+ return {
47
+ widget(widget) {
48
+ if (!ID.test(widget.id))
49
+ throw new Error(
50
+ `a widget id is a plugin name (lowercase letters, digits and dashes), not "${widget.id}"`,
51
+ )
52
+ if (registered.has(widget.id))
53
+ throw new Error(`the home page already has a widget "${widget.id}"`)
54
+ registered.set(widget.id, widget)
55
+ return () => {
56
+ if (registered.get(widget.id) === widget) registered.delete(widget.id)
57
+ }
58
+ },
59
+ widgets: () => [...registered.values()],
60
+ }
61
+ }
62
+
63
+ const escapeHtml = (text: string): string =>
64
+ text
65
+ .replaceAll('&', '&amp;')
66
+ .replaceAll('<', '&lt;')
67
+ .replaceAll('>', '&gt;')
68
+ .replaceAll('"', '&quot;')
69
+
70
+ /**
71
+ * The widgets' look, the ACRYL frame's plain one: each widget a card, and the plain HTML a widget writes (a list, a form, inputs, buttons, an output)
72
+ * styled inside it, light or dark with the person's system. Every selector is under `.widget`, so the page around the widgets keeps its own styles,
73
+ * and a widget's own `<style>` still wins (it comes later on the page).
74
+ */
75
+ export const WIDGET_STYLES = `.widget{--widget-bg:#fff;--widget-surface:#f7f7f9;--widget-border:#e2e2e8;--widget-text:#18181b;--widget-dim:#6b6b75;--widget-accent:#5468ff;--widget-accent-dim:rgba(84,104,255,.18);color-scheme:light;margin:16px 0;padding:16px 18px;border:1px solid var(--widget-border);border-radius:12px;background:var(--widget-surface);color:var(--widget-text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
76
+ @media (prefers-color-scheme:dark){.widget{--widget-bg:#0b0b0d;--widget-surface:#18181b;--widget-border:#2a2a2f;--widget-text:#e8e8ea;--widget-dim:#8b8b93;color-scheme:dark}}
77
+ .widget h2{margin:0 0 12px;font-size:15px;font-weight:600}
78
+ .widget p{margin:0 0 10px}
79
+ .widget p:last-child,.widget ul:last-child,.widget ol:last-child,.widget form:last-child{margin-bottom:0}
80
+ .widget ul,.widget ol{list-style:none;margin:0 0 12px;padding:0}
81
+ .widget li{display:flex;align-items:center;gap:8px;padding:7px 0;border-top:1px solid var(--widget-border);overflow-wrap:anywhere}
82
+ .widget li:first-child{border-top:0}
83
+ .widget li>:last-child:is(button,form){margin-left:auto}
84
+ .widget a{color:var(--widget-accent);text-decoration:none}
85
+ .widget a:hover{text-decoration:underline}
86
+ .widget form{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 10px}
87
+ .widget input,.widget textarea,.widget select{flex:1 1 140px;min-width:0;padding:7px 10px;border:1px solid var(--widget-border);border-radius:8px;background:var(--widget-bg);color:inherit;font:inherit}
88
+ .widget input::placeholder,.widget textarea::placeholder{color:var(--widget-dim)}
89
+ .widget input:focus,.widget textarea:focus,.widget select:focus{outline:2px solid var(--widget-accent-dim);border-color:var(--widget-accent)}
90
+ .widget button{padding:7px 14px;border:0;border-radius:8px;background:var(--widget-accent);color:#fff;font:inherit;font-weight:500;cursor:pointer}
91
+ .widget button:hover{filter:brightness(1.1)}
92
+ .widget li button{flex:none;padding:3px 10px;border:1px solid var(--widget-border);background:transparent;color:var(--widget-dim);font-weight:400}
93
+ .widget output{font-weight:600;font-variant-numeric:tabular-nums}
94
+ .widget [role=alert]{color:#d9480f}`
95
+
96
+ /**
97
+ * The widgets as one block of the page: their styles ({@link WIDGET_STYLES}), then a `<section data-widget="<id>">` with the heading and the
98
+ * widget's HTML for each; nothing at all when there is no widget. A widget whose render throws shows one line saying so, and the page still renders.
99
+ */
100
+ export async function renderWidgets(home: Home): Promise<string> {
101
+ const sections = await Promise.all(
102
+ home.widgets().map(async (widget) => {
103
+ let body: string
104
+ try {
105
+ body = await widget.render()
106
+ } catch (cause) {
107
+ body = `<p role="alert">This widget could not be shown: ${escapeHtml(cause instanceof Error ? cause.message : String(cause))}</p>`
108
+ }
109
+ return `<section class="widget" data-widget="${escapeHtml(widget.id)}">\n<h2>${escapeHtml(widget.title)}</h2>\n${body}\n</section>`
110
+ }),
111
+ )
112
+ return sections.length === 0
113
+ ? ''
114
+ : [`<style data-home-widgets>${WIDGET_STYLES}</style>`, ...sections].join('\n')
115
+ }
116
+
117
+ /** Provides `ctx.home`. Mount it before the routes that render the home page and before the plugins that put widgets on it. */
118
+ export const name = 'home'
119
+
120
+ export function apply(ctx: Context): void {
121
+ ctx.provide('home', createHome())
122
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "home",
3
+ "description": "The app's home page as a place plugins put their widgets: a plugin registers a widget in ctx.effect, the page renders it, and it leaves with the plugin.",
4
+ "requires": [],
5
+ "inject": [],
6
+ "provides": ["home"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ },
10
+ "mount": "a row `- id: home` / `name: home` before the routes row in blend.yaml; the routes render renderWidgets(ctx.home) on the home page; a plugin does ctx.effect(() => ctx.home.widget({ id, title, render }))"
11
+ }
@@ -30,6 +30,11 @@ export interface AppInstance {
30
30
  readonly root: string
31
31
  /** The app's own data: settings, installed extensions, the extension ledger. Never shared with another app. */
32
32
  readonly home: string
33
+ /**
34
+ * The host's engine home: where the engine keeps its own files, the logs included. ACRYL's plugins read it (the support download serves `dshHome/logs`);
35
+ * in this host the engine and the app share one home, so it equals `home`.
36
+ */
37
+ readonly dshHome: string
33
38
  readonly port: PortPreference
34
39
  /** The interface to listen on. Absent means loopback only; a container sets `0.0.0.0` so its published port reaches the app. */
35
40
  readonly host?: string
@@ -84,6 +89,7 @@ function family(input: {
84
89
  name: input.name,
85
90
  root: input.root,
86
91
  home: input.home,
92
+ dshHome: input.home,
87
93
  port: Object.freeze({ ...input.port }),
88
94
  runLockFile: join(input.home, RUN_LOCK_FILE),
89
95
  })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The app's own plugins: the folders of `plugins/` that are not library copies (`plugins/library.json` lists those). A feature is one of them, and
3
+ * `blend.yaml` mounts it with a row of its own (`- id: notes` / `name: notes`), with no import in `src/app.ts`: a row whose name the static registry
4
+ * does not have resolves to the folder of that name here. So `blend.yaml` plus `plugins/` is the whole composition, and a version, a shared app or a
5
+ * blueprint made from the folder starts the same plugins.
6
+ */
7
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
8
+ import { basename, join } from 'node:path'
9
+ import { pathToFileURL } from 'node:url'
10
+
11
+ /** The file a plugin folder starts from, in the order they are looked for. */
12
+ export const PLUGIN_ENTRIES = ['index.ts', 'index.js', 'index.mjs'] as const
13
+ /** A plugin's name: its folder's name and its Loader row id. */
14
+ const NAME = /^[a-z0-9][a-z0-9-]*$/
15
+
16
+ /** The library copies a plugins folder holds (`library.json`, written by `acrylblends add`); absent or unreadable means none. */
17
+ export function libraryCopies(dir: string): ReadonlySet<string> {
18
+ try {
19
+ const listed = (
20
+ JSON.parse(readFileSync(join(dir, 'library.json'), 'utf8')) as { plugins?: unknown }
21
+ ).plugins
22
+ return new Set(
23
+ Array.isArray(listed)
24
+ ? listed.filter((name): name is string => typeof name === 'string')
25
+ : [],
26
+ )
27
+ } catch {
28
+ return new Set()
29
+ }
30
+ }
31
+
32
+ /** The entry file of a plugin folder, or undefined when it has none. */
33
+ export function pluginEntry(dir: string): string | undefined {
34
+ return PLUGIN_ENTRIES.map((file) => join(dir, file)).find((path) => existsSync(path))
35
+ }
36
+
37
+ /** The names of the app's own plugins in `dir` (the app's `plugins/`): folders with an entry file that are not library copies, sorted. */
38
+ export function appPluginNames(dir: string): string[] {
39
+ if (!existsSync(dir)) return []
40
+ const library = libraryCopies(dir)
41
+ return readdirSync(dir, { withFileTypes: true })
42
+ .filter(
43
+ (entry) =>
44
+ entry.isDirectory() &&
45
+ NAME.test(entry.name) &&
46
+ !library.has(entry.name) &&
47
+ pluginEntry(join(dir, entry.name)) !== undefined,
48
+ )
49
+ .map((entry) => entry.name)
50
+ .sort()
51
+ }
52
+
53
+ /** A plugin module as a row mounts it. */
54
+ export interface AppPluginModule {
55
+ readonly name: string
56
+ readonly apply: (...args: never[]) => unknown
57
+ }
58
+
59
+ /**
60
+ * Import the app's own plugins `names` from `dir`, each checked to be a Cordis plugin whose `name` is its folder's.
61
+ * @throws an error that names the folder and the fix, for a folder that does not export a plugin.
62
+ */
63
+ export async function loadAppPlugins(
64
+ dir: string,
65
+ names: readonly string[],
66
+ ): Promise<Record<string, AppPluginModule>> {
67
+ const loaded: Record<string, AppPluginModule> = {}
68
+ for (const name of names) {
69
+ const entry = pluginEntry(join(dir, name))
70
+ if (entry === undefined) throw new Error(`plugins/${name} has no ${PLUGIN_ENTRIES.join(', ')}`)
71
+ const module = (await import(pathToFileURL(entry).href)) as Partial<AppPluginModule> & {
72
+ default?: Partial<AppPluginModule>
73
+ }
74
+ const plugin = typeof module.apply === 'function' ? module : module.default
75
+ if (plugin === undefined || typeof plugin.apply !== 'function')
76
+ throw new Error(
77
+ `plugins/${name}/${basename(entry)} must export a Cordis plugin: export const name = '${name}' and export function apply(ctx) { ... }`,
78
+ )
79
+ if (plugin.name !== undefined && plugin.name !== name)
80
+ throw new Error(
81
+ `plugins/${name} exports the plugin name "${String(plugin.name)}"; a plugin's name is its folder's, "${name}"`,
82
+ )
83
+ loaded[name] = { ...plugin, name } as AppPluginModule
84
+ }
85
+ return loaded
86
+ }
@@ -13,8 +13,9 @@
13
13
  * which of this Blend's overrides and rows it breaks.
14
14
  *
15
15
  * Problems are diagnostics with a code and a path, never a bare "invalid". The code an app can use stays in `src/app.ts` as a static
16
- * registry of imports (so it type-checks); the manifest chooses, orders, configures and switches those plugins. Extensions are not
17
- * listed here: they come and go while the app runs.
16
+ * registry of imports (so it type-checks); the manifest chooses, orders, configures and switches those plugins. A row may also name one of the
17
+ * app's own plugin folders (`plugins/<name>/`, app-plugins.ts): that is how a feature is mounted. Extensions are not listed here: they come and go
18
+ * while the app runs.
18
19
  */
19
20
  import { createHash } from 'node:crypto'
20
21
  import { existsSync, readFileSync } from 'node:fs'
@@ -633,3 +634,12 @@ export function blueprintsIn(directory: string): (id: string) => string | undefi
633
634
  return DEFINITION_ID.test(id) && existsSync(file) ? file : undefined
634
635
  }
635
636
  }
637
+
638
+ export {
639
+ type AppPluginModule,
640
+ appPluginNames,
641
+ libraryCopies,
642
+ loadAppPlugins,
643
+ PLUGIN_ENTRIES,
644
+ pluginEntry,
645
+ } from './app-plugins.ts'
@@ -7,7 +7,7 @@
7
7
  "dependencies": {
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
9
  "yaml": "2.9.0",
10
- "@webboxes/blends-core": "0.4.0"
10
+ "@webboxes/blends-core": "0.5.0"
11
11
  },
12
12
  "mount": "in src/app.ts: const manifest = loadManifest(join(chosen.root, 'blend.yaml')); await compose(root, manifest, registry)"
13
13
  }