acrylblends 1.1.0 → 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.
- package/README.md +9 -0
- package/dist/index.d.ts +45 -1
- package/dist/index.js +1980 -27
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/workshop/README.md +138 -152
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +42 -7
- package/lib/cordis/door.js +204 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +53 -23
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- 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
|
+
}
|