@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1

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 (311) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
@@ -0,0 +1,1223 @@
1
+ // The assistant's avatar, wired to the page.
2
+ //
3
+ // The robot button in the assistant's header shows the avatar (Druplicon, or another one settings.yml
4
+ // `assistant.avatars:` names and the settings pop picks) and hides it again;
5
+ // hidden means silent, and its windows close with it. While shown it thinks while a reply is on its way,
6
+ // moves its mouth with the spoken summary, and acts out the reply: the moves written in it ([wave] …),
7
+ // the board (BOARD: and DRAW:, drawn stroke by stroke), a video (SHOW:), a page (BROWSE:, read through
8
+ // /fragments/reader) and where it flies to (MOVE:). Shown, it is free on the page, never confined to the
9
+ // assistant's panel. Quick replies under it answer the assistant in one click.
10
+ //
11
+ // The 3D part (assistant-avatar.js, three.js and the model) is fetched the first time it is shown.
12
+ (function () {
13
+ const root = document.documentElement;
14
+ const toggle = document.querySelector('.assistant-avatar-toggle');
15
+ const stage = document.querySelector('.assistant-avatar-stage');
16
+ if (!toggle || !stage) return;
17
+ const canvas = stage.querySelector('.assistant-avatar-canvas');
18
+ // Every control lives behind the robot button in the assistant's header: showing it, its tools and all its
19
+ // settings, in one menu. The avatar itself is free on the page and carries none.
20
+ const menuBtn = document.querySelector('.assistant-avatar-menu-btn');
21
+ const bar = document.querySelector('.assistant-avatar-bar');
22
+ // The voice dock under the conversation, as in the avatar app: voice on or off, hold to talk, keep listening.
23
+ const dock = document.querySelector('.assistant-dock');
24
+ const mute = dock.querySelector('.dock-voice');
25
+ const chips = document.querySelector('.assistant-avatar-chips');
26
+ const home = stage.parentNode; // where the stage lives in the panel
27
+ const homeNext = stage.nextSibling;
28
+ const pref = (k, v) => { try { window.wsPrefSet(k, v); } catch (_) { /* no cookie, no memory of it */ } };
29
+ const getPref = (k, d) => { try { return window.wsPref(k, d); } catch (_) { return d; } };
30
+ const voice = () => window.wsAssistantVoice || null;
31
+ const reduced = () => window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
32
+ const esc = (v) => String(v).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
33
+ const svg = (d, size = 16) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
34
+ // The interface glyphs, in public/vendor/webship-icons.
35
+ // An interface icon is drawn as a mask in the text colour, at its own size from the design, so it takes the
36
+ // hover blue, the pressed state and dark mode from CSS; a brand icon (value, service) keeps its own colours.
37
+ const VI = '/vendor/webship-icons';
38
+ const vi = (name, label = '') => `<span class="vi vi-${name}" style="-webkit-mask-image:url(${VI}/ui/${name}.svg);mask-image:url(${VI}/ui/${name}.svg)"${label ? ` role="img" aria-label="${label}"` : ' aria-hidden="true"'}></span>`;
39
+ const ICON = {
40
+ speaker: '<path d="M11 5 6 9H2v6h4l5 4V5z"/>',
41
+ close: '<path d="M18 6 6 18M6 6l12 12"/>',
42
+ tab: '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>',
43
+ };
44
+
45
+ // ---- Voice and the two header controls -------------------------------------------------------
46
+ const paintMute = () => {
47
+ const on = voice() ? voice().isOn() : false;
48
+ mute.innerHTML = vi(on ? 'sound-on' : 'sound-off');
49
+ mute.setAttribute('aria-pressed', String(on));
50
+ mute.title = on ? 'The voice is on: click to mute it' : 'The voice is off: click to hear the answers';
51
+ mute.setAttribute('aria-label', mute.title);
52
+ };
53
+ const paintToggle = (on) => {
54
+ toggle.setAttribute('aria-pressed', String(on));
55
+ toggle.textContent = on ? 'Hide the avatar' : 'Show the avatar';
56
+ toggle.title = on ? "Hide the assistant's avatar, and mute it" : "Show the assistant's avatar";
57
+ if (menuBtn) menuBtn.classList.toggle('is-current', on);
58
+ };
59
+
60
+ // ---- The avatar itself -----------------------------------------------------------------------
61
+ // Who it can be: only the avatars settings.yml names (server.js puts them on the stage). The one picked
62
+ // is kept per browser.
63
+ let cast = { start: 'druplicon', avatars: [] };
64
+ try { cast = { ...cast, ...JSON.parse(stage.dataset.cast || '{}') }; } catch (_) { /* Druplicon alone */ }
65
+ if (!cast.avatars.length) cast.avatars = [{ id: 'druplicon', name: 'Druplicon', model: '/avatar/druplicon.vrm', credit: '' }];
66
+ const who = () => cast.avatars.find((a) => a.id === getPref('ws-avatar-who', cast.start)) || cast.avatars.find((a) => a.id === cast.start) || cast.avatars[0];
67
+ window.wsAvatarName = () => who().name;
68
+ window.wsAvatarId = () => who().id;
69
+ // 2D (flat, a cartoon drawing) or 3D (lit, in perspective), per browser; 2D unless 3D was chosen.
70
+ const lookPref = () => (getPref('ws-avatar-look', '2d') === '3d' ? '3d' : '2d');
71
+ // Its hair colour, per avatar and per browser; settings.yml's avatar_hair is where it starts.
72
+ // The colours picked for each part of an avatar (its suit, cape, hair…), per avatar and per browser.
73
+ const coloursOf = (a) => { try { const c = JSON.parse(getPref(`ws-avatar-colours-${a.id}`, '') || '{}'); return c && typeof c === 'object' ? c : {}; } catch (_) { return {}; } };
74
+ const saveColours = (a, c) => pref(`ws-avatar-colours-${a.id}`, JSON.stringify(c));
75
+ const hairOf = (a) => { const v = String(getPref(`ws-avatar-hair-${a.id}`, '') || ''); return /^#[0-9a-f]{6}$/i.test(v) ? v : (a.hair || ''); };
76
+ let avatar = null;
77
+ let loading = null;
78
+ // Each avatar keeps its own voice, any of them an ElevenLabs one; until one is picked for it, the best
79
+ // free voice. ElevenLabs is never a default: it spends credits, so it is always somebody's choice.
80
+ const useVoiceOf = (a) => { if (voice() && voice().set) voice().set('voiceName', String(getPref(`ws-voice-name-${a.id}`, '') || '')); };
81
+ function mount(greet) {
82
+ if (avatar || loading) return;
83
+ const picked = who();
84
+ useVoiceOf(picked);
85
+ stage.setAttribute('aria-label', `${picked.name}, the assistant's avatar`);
86
+ loading = import('/assistant-avatar.js')
87
+ .then((m) => m.mountAvatar(canvas, { model: picked.model, moves: picked.moves || '', hair: hairOf(picked), box: () => floatCard.getBoundingClientRect(), look: lookPref() }))
88
+ .then((a) => {
89
+ loading = null;
90
+ if (!root.classList.contains('assistant-avatar-on') || who().id !== picked.id) { a.dispose(); if (root.classList.contains('assistant-avatar-on')) mount(greet); return; }
91
+ avatar = a;
92
+ for (const [part, hex] of Object.entries(coloursOf(picked))) if (/^#[0-9a-f]{6}$/i.test(String(hex))) avatar.setColour(part, hex);
93
+ stage.classList.add('is-ready');
94
+ if (greet) avatar.gesture('wave');
95
+ })
96
+ .catch((err) => { loading = null; stage.classList.add('is-failed'); console.warn('The avatar could not be shown:', err); });
97
+ }
98
+ // A few moves one after another, as the reply wrote them: a big move (BIG, below) takes as long as its
99
+ // journey, any other about 1.7 s. A newer reply's moves replace whatever is still waiting.
100
+ let playing = 0;
101
+ async function play(list) {
102
+ const run = ++playing;
103
+ for (const g of list || []) {
104
+ if (run !== playing || !avatar) return;
105
+ if (BIG[g]) await BIG[g]();
106
+ else { avatar.gesture(g); await new Promise((r) => setTimeout(r, 1700)); }
107
+ }
108
+ }
109
+
110
+ // ---- Free on the page: Druplicon alone, no card, flying from place to place ----------------------
111
+ // Shown, it is never confined to the assistant's panel: the stage moves into a frameless layer of its
112
+ // own on the page. Drag Druplicon itself to put it somewhere; it flies there itself when the reply says
113
+ // MOVE:, and now and then on its own while nothing is happening (not with reduced motion).
114
+ const floatCard = document.createElement('section');
115
+ floatCard.className = 'avatar-float';
116
+ floatCard.setAttribute('aria-label', "The assistant's avatar");
117
+ // The drawing covers the whole window, click-through, so its moves and flights have the full browser
118
+ // and never meet an edge; the card above is where it stands, and what you drag.
119
+ const layer = document.createElement('div');
120
+ layer.className = 'avatar-layer';
121
+ let popped = false;
122
+ const PLACES = { left: [0.04, 0.6], center: [0.5, 0.55], right: [0.96, 0.6], panel: [0, 0.55] };
123
+ // Where it can stand without covering the assistant's conversation.
124
+ const panelBox = () => {
125
+ const panel = document.querySelector('.assistant-side');
126
+ return panel && !root.classList.contains('assistant-closed') ? panel.getBoundingClientRect() : null;
127
+ };
128
+ const freeLeft = () => { const p = panelBox(); return p ? p.right + 8 : 8; };
129
+ // Its home: just beside the assistant's text box, its feet level with the bottom of the panel. It
130
+ // stays near there unless it is moved, or told to roam the whole page.
131
+ const homeSpot = () => {
132
+ const r = floatCard.getBoundingClientRect(); const p = panelBox();
133
+ return [freeLeft(), (p ? p.bottom : window.innerHeight - 8) - r.height];
134
+ };
135
+ const roams = () => getPref('ws-avatar-roam', '0') === '1';
136
+ function flyTo(x, y) {
137
+ const r = floatCard.getBoundingClientRect();
138
+ const nx = Math.max(freeLeft(), Math.min(window.innerWidth - r.width - 8, x));
139
+ const ny = Math.max(64, Math.min(window.innerHeight - r.height - 4, y));
140
+ const far = Math.hypot(nx - r.left, ny - r.top) > 40;
141
+ if (far && avatar) avatar.gesture('fly');
142
+ floatCard.style.left = `${nx}px`;
143
+ floatCard.style.top = `${ny}px`;
144
+ pref('ws-avatar-at', `${Math.round(nx)},${Math.round(ny)}`);
145
+ }
146
+ function place(where) {
147
+ if (where === 'home') { flyTo(...homeSpot()); return; }
148
+ const r = floatCard.getBoundingClientRect();
149
+ const [fx, fy] = PLACES[where] || PLACES.right;
150
+ flyTo(where === 'panel' ? freeLeft() : fx * window.innerWidth - r.width * fx, fy * window.innerHeight - r.height / 2);
151
+ }
152
+ function popOut(where) {
153
+ if (popped) { if (where) place(where); return; }
154
+ popped = true;
155
+ if (!floatCard.isConnected) document.body.appendChild(floatCard);
156
+ if (!layer.isConnected) document.body.appendChild(layer);
157
+ layer.appendChild(stage);
158
+ applySize();
159
+ root.classList.add('avatar-popped');
160
+ const at = String(getPref('ws-avatar-at', '')).split(',').map(Number);
161
+ if (where) place(where);
162
+ // Where it was left, when it roams; by the text box otherwise, wherever the panel is now.
163
+ else if (roams() && at.length === 2 && at.every(Number.isFinite)) { floatCard.style.left = `${at[0]}px`; floatCard.style.top = `${at[1]}px`; }
164
+ else place('home');
165
+ }
166
+ function popIn() {
167
+ if (!popped) return;
168
+ popped = false;
169
+ home.insertBefore(stage, homeNext);
170
+ floatCard.remove();
171
+ layer.remove();
172
+ root.classList.remove('avatar-popped');
173
+ }
174
+ // Dragging: a window by its title bar, Druplicon by Druplicon itself (a click without moving is a click).
175
+ function draggable(box, handle, { onStart, onEnd } = {}) {
176
+ handle.addEventListener('pointerdown', (e) => {
177
+ if (e.button !== 0 || e.target.closest('button, a, input, form')) return;
178
+ const r = box.getBoundingClientRect(); const dx = e.clientX - r.left; const dy = e.clientY - r.top;
179
+ const sx = e.clientX; const sy = e.clientY; let moved = false;
180
+ handle.setPointerCapture(e.pointerId);
181
+ const move = (ev) => {
182
+ if (!moved && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 4) return;
183
+ if (!moved) { moved = true; box.classList.add('is-dragging'); if (onStart) onStart(); }
184
+ box.style.left = `${Math.max(0, Math.min(window.innerWidth - 60, ev.clientX - dx))}px`;
185
+ box.style.top = `${Math.max(0, Math.min(window.innerHeight - 40, ev.clientY - dy))}px`;
186
+ };
187
+ const up = () => {
188
+ handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up);
189
+ box.classList.remove('is-dragging');
190
+ if (moved && onEnd) onEnd();
191
+ };
192
+ handle.addEventListener('pointermove', move);
193
+ handle.addEventListener('pointerup', up);
194
+ });
195
+ }
196
+ draggable(floatCard, floatCard, {
197
+ onStart: () => { if (avatar) avatar.gesture('fly'); },
198
+ onEnd: () => { const r = floatCard.getBoundingClientRect(); pref('ws-avatar-at', `${Math.round(r.left)},${Math.round(r.top)}`); },
199
+ });
200
+ // Now and then, while nothing is going on, it flies somewhere else on the page.
201
+ let lastActivity = Date.now();
202
+ const busy = () => document.querySelector('.assistant-panel[data-working]')
203
+ || (window.speechSynthesis && window.speechSynthesis.speaking) || floatCard.matches(':hover') || bar.matches(':focus-within');
204
+ setInterval(() => {
205
+ if (!popped || !avatar || !wanders() || reduced() || busy() || Date.now() - lastActivity < 15000 + Math.random() * 10000) return;
206
+ lastActivity = Date.now();
207
+ const r = floatCard.getBoundingClientRect();
208
+ if (roams()) { flyTo(freeLeft() + Math.random() * (window.innerWidth - freeLeft() - r.width), window.innerHeight * (0.35 + Math.random() * 0.4) - r.height / 2); return; }
209
+ // Near the text box: a short hop around its home, and back.
210
+ const [hx, hy] = homeSpot();
211
+ flyTo(hx + Math.random() * Math.min(260, window.innerWidth * 0.18), hy - Math.random() * Math.min(160, window.innerHeight * 0.15));
212
+ }, 5000);
213
+ ['pointerdown', 'keydown'].forEach((ev) => document.addEventListener(ev, () => { lastActivity = Date.now(); }, { passive: true }));
214
+
215
+ // ---- Big moves: the whole figure travels the page ------------------------------------------------
216
+ // The card follows a path frame by frame (the drawing follows the card), while the body does a move
217
+ // to match: a walk, a flight, a leap. Each ends where it began, or at home. With reduced motion only the
218
+ // move on the spot is done.
219
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
220
+ const at = () => { const r = floatCard.getBoundingClientRect(); return [r.left, r.top]; };
221
+ const inPage = ([x, y]) => {
222
+ const r = floatCard.getBoundingClientRect();
223
+ return [Math.max(freeLeft(), Math.min(window.innerWidth - r.width - 8, x)), Math.max(64, Math.min(window.innerHeight - r.height - 4, y))];
224
+ };
225
+ // Along the points (page coordinates of the card), in `ms`, repeating `move` every `every` ms.
226
+ function travel(points, ms, move, every = 1100) {
227
+ return new Promise((done) => {
228
+ const pts = [at(), ...points.map(inPage)];
229
+ const legs = pts.slice(1).map((p, i) => Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1]));
230
+ const total = legs.reduce((a, b) => a + b, 0) || 1;
231
+ floatCard.classList.add('is-travelling');
232
+ const t0 = performance.now(); let nextMove = 0;
233
+ const step = (now) => {
234
+ const k = Math.min(1, (now - t0) / ms);
235
+ if (move && avatar && now >= nextMove) { avatar.gesture(move); nextMove = now + every; }
236
+ let d = (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2) * total; let i = 0;
237
+ while (i < legs.length - 1 && d > legs[i]) { d -= legs[i]; i += 1; }
238
+ const f = legs[i] ? Math.min(1, d / legs[i]) : 1;
239
+ floatCard.style.left = `${pts[i][0] + (pts[i + 1][0] - pts[i][0]) * f}px`;
240
+ floatCard.style.top = `${pts[i][1] + (pts[i + 1][1] - pts[i][1]) * f}px`;
241
+ if (k < 1 && popped) requestAnimationFrame(step);
242
+ else { floatCard.classList.remove('is-travelling'); done(); }
243
+ };
244
+ requestAnimationFrame(step);
245
+ });
246
+ }
247
+ const still = (move, ms) => { if (avatar) avatar.gesture(move); return wait(ms); };
248
+ const BIG = {
249
+ // A loop round the page and back home.
250
+ async tour() {
251
+ if (reduced()) return still('fly', 1500);
252
+ const w = window.innerWidth; const h = window.innerHeight; const r = floatCard.getBoundingClientRect();
253
+ await travel([[w - r.width - 30, h * 0.12], [w * 0.55, 70], [freeLeft() + 20, h * 0.2], [freeLeft() + 40, h * 0.6], homeSpot()], 7000, 'fly', 1300);
254
+ },
255
+ // A walk across to the far side, a wave, and back.
256
+ async dash() {
257
+ if (reduced()) return still('walk', 1200);
258
+ const back = at(); const r = floatCard.getBoundingClientRect();
259
+ await travel([[window.innerWidth - r.width - 30, back[1]]], 2600, 'walk');
260
+ await still('wave', 1400);
261
+ await travel([back], 2600, 'walk');
262
+ },
263
+ // A big leap up the page and down again.
264
+ async jump() {
265
+ if (reduced()) return still('leap', 1300);
266
+ const [x, y] = at();
267
+ avatar.gesture('leap');
268
+ await wait(330);
269
+ await travel([[x, y - window.innerHeight * 0.3]], 420, null);
270
+ await travel([[x, y]], 480, null);
271
+ await wait(250);
272
+ },
273
+ // Grows to its biggest, waves, and back to the size it was.
274
+ async zoom() {
275
+ const was = floatCard.dataset.size || 'medium';
276
+ resize('huge'); await still('wave', 2600); resize(was); await wait(600);
277
+ },
278
+ // Walks to the middle, grows, explains, then goes home at the size it was.
279
+ async present() {
280
+ const was = floatCard.dataset.size || 'medium';
281
+ if (!reduced()) { const r = floatCard.getBoundingClientRect(); await travel([[(window.innerWidth - r.width) / 2, window.innerHeight * 0.3]], 2000, 'walk'); }
282
+ resize('xl'); await wait(500); await still('explain', 4000);
283
+ resize(was); await wait(400);
284
+ if (!reduced()) await travel([homeSpot()], 2000, 'walk');
285
+ },
286
+ // A full turn on the spot.
287
+ spin: () => still('spin', 1700),
288
+ };
289
+
290
+ // ---- Windows beside the avatar: the board, a video, a page -----------------------------------
291
+ const windows = {};
292
+ function windowFor(kind, title) {
293
+ let w = windows[kind];
294
+ if (!w) {
295
+ w = document.createElement('section');
296
+ w.className = `avatar-window avatar-window-${kind}`;
297
+ w.setAttribute('role', 'dialog');
298
+ w.innerHTML = `<header class="avatar-window-head"><strong></strong><span class="avatar-window-tools"></span>
299
+ <button type="button" class="avatar-window-btn avatar-window-max" title="Fill the page" aria-label="Fill the page" aria-pressed="false">${svg(ICON.tab)}</button>
300
+ <button type="button" class="avatar-window-btn avatar-window-close" title="Close" aria-label="Close">${svg(ICON.close)}</button></header>
301
+ <div class="avatar-window-body"></div>`;
302
+ w.querySelector('.avatar-window-close').addEventListener('click', () => closeWindow(kind));
303
+ // Not limited to its first size: drag its corner, or fill the page and come back.
304
+ w.querySelector('.avatar-window-max').addEventListener('click', (e) => {
305
+ const on = w.classList.toggle('is-max');
306
+ e.currentTarget.setAttribute('aria-pressed', String(on));
307
+ e.currentTarget.title = on ? 'Back to its size' : 'Fill the page';
308
+ e.currentTarget.setAttribute('aria-label', e.currentTarget.title);
309
+ });
310
+ draggable(w, w.querySelector('.avatar-window-head'));
311
+ document.body.appendChild(w);
312
+ windows[kind] = w;
313
+ // Beside the avatar: to the left of it when it stands on the right, to its right otherwise.
314
+ const a = floatCard.getBoundingClientRect();
315
+ const width = Math.min(560, window.innerWidth - 32);
316
+ const n = Object.keys(windows).length - 1;
317
+ const left = a.left > window.innerWidth / 2 ? a.left - width - 16 : a.right + 16;
318
+ w.style.left = `${Math.max(freeLeft() + 8, Math.min(window.innerWidth - width - 16, left)) + n * 24}px`;
319
+ w.style.top = `${Math.max(80, a.top) + n * 24}px`;
320
+ w.style.width = `${width}px`;
321
+ // And Druplicon flies over beside it, to present it.
322
+ setTimeout(() => {
323
+ const wr = w.getBoundingClientRect(); const fr = floatCard.getBoundingClientRect();
324
+ if (popped) flyTo(wr.right + 8 + fr.width < window.innerWidth ? wr.right + 8 : wr.left - fr.width - 8, wr.top + wr.height / 2 - fr.height / 2);
325
+ }, 50);
326
+ }
327
+ w.querySelector('.avatar-window-head strong').textContent = title;
328
+ if (openPop === 'windows') setTimeout(() => renderPop('windows'), 0);
329
+ w.setAttribute('aria-label', title);
330
+ w.hidden = false;
331
+ return w;
332
+ }
333
+ function closeWindow(kind) {
334
+ const w = windows[kind];
335
+ if (!w) return;
336
+ w.remove();
337
+ delete windows[kind];
338
+ if (videos && videos[kind]) {
339
+ delete videos[kind];
340
+ const left = Object.values(videos);
341
+ video = left.length ? left[left.length - 1] : null;
342
+ paintChips();
343
+ }
344
+ }
345
+ document.addEventListener('keydown', (e) => {
346
+ if (e.key !== 'Escape') return;
347
+ const open = Object.keys(windows);
348
+ if (open.length && !document.querySelector('.uk-modal.uk-open')) closeWindow(open[open.length - 1]);
349
+ });
350
+
351
+ // The board: lines of text at the top, and shapes on a 100 x 60 grid drawn stroke by stroke.
352
+ const NS = 'http://www.w3.org/2000/svg';
353
+ // The board: a workspace for the team as well as for Druplicon. Lines of text at the top, a drawing on a
354
+ // 100 x 60 grid that scales with the window, freehand pen, sticky notes to type in and drag, undo,
355
+ // clear, and saving the drawing as SVG and the notes as Markdown.
356
+ const added = []; // what was put on the board, newest last, for Undo
357
+ function board() {
358
+ const w = windowFor('board', 'Board');
359
+ const body = w.querySelector('.avatar-window-body');
360
+ if (!body.firstChild) {
361
+ body.innerHTML = `<div class="avatar-board-bar" role="toolbar" aria-label="Board tools">
362
+ <button type="button" class="avatar-chip" data-board="pen" aria-pressed="false">Pen</button>
363
+ <span class="avatar-board-palette" role="group" aria-label="Pen colour">${['ink', 'blue', 'sky', 'teal', 'green', 'amber', 'coral', 'purple'].map((c, i) => `<button type="button" class="avatar-swatch" data-colour="${c}" aria-label="${c}" title="${c}" aria-pressed="${i === 0}" style="background:${COLOURS[c]}"></button>`).join('')}</span>
364
+ <span class="avatar-board-widths" role="group" aria-label="Pen width">${[['fine', 0.15], ['thin', 0.3], ['medium', 0.6], ['bold', 1.1]].map(([l, w], i) => `<button type="button" class="avatar-chip" data-width="${w}" aria-pressed="${i === 1}">${l}</button>`).join('')}</span>
365
+ <button type="button" class="avatar-chip" data-board="note">Note</button>
366
+ <button type="button" class="avatar-chip" data-board="undo">Undo</button>
367
+ <button type="button" class="avatar-chip" data-board="clear">Clear</button>
368
+ <button type="button" class="avatar-chip" data-board="svg">Save drawing</button>
369
+ <button type="button" class="avatar-chip avatar-chip-icon" data-board="md">${vi('bookmark-outlined')}Save notes</button>
370
+ </div>
371
+ <ol class="avatar-board-lines"></ol><div class="avatar-board-area"></div>`;
372
+ const area = body.querySelector('.avatar-board-area');
373
+ const s = document.createElementNS(NS, 'svg');
374
+ s.setAttribute('viewBox', '0 0 100 60');
375
+ s.setAttribute('class', 'avatar-board-svg');
376
+ s.innerHTML = '<defs><marker id="avatar-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="currentColor"/></marker></defs>';
377
+ area.appendChild(s);
378
+ const pen = { colour: COLOURS.ink, width: 0.3 };
379
+ body.querySelector('.avatar-board-bar').addEventListener('click', (e) => {
380
+ const sw = e.target.closest('[data-colour], [data-width]');
381
+ if (sw) {
382
+ sw.parentElement.querySelectorAll('button').forEach((x) => x.setAttribute('aria-pressed', String(x === sw)));
383
+ if (sw.dataset.colour) pen.colour = COLOURS[sw.dataset.colour];
384
+ if (sw.dataset.width) pen.width = Number(sw.dataset.width);
385
+ const penBtn = body.querySelector('[data-board="pen"]');
386
+ if (penBtn.getAttribute('aria-pressed') !== 'true') penBtn.click();
387
+ return;
388
+ }
389
+ const b = e.target.closest('[data-board]');
390
+ if (!b) return;
391
+ const act = b.dataset.board;
392
+ if (act === 'pen') { const on = b.getAttribute('aria-pressed') !== 'true'; b.setAttribute('aria-pressed', String(on)); area.classList.toggle('is-pen', on); }
393
+ if (act === 'note') addNote(area, '');
394
+ if (act === 'undo') { const last = added.pop(); if (last) last.remove(); }
395
+ if (act === 'clear') clearBoard();
396
+ if (act === 'svg') download('board.svg', 'image/svg+xml', `<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(s).replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg" style="color:#2b447c;background:#fff"')}`);
397
+ if (act === 'md') {
398
+ const lines = [...body.querySelectorAll('.avatar-board-lines li')].map((li) => `1. ${li.textContent}`);
399
+ const notes = [...area.querySelectorAll('.avatar-note-text')].map((n) => `- ${n.innerText.trim().replace(/\n+/g, ' ')}`).filter((l) => l.length > 2);
400
+ download('board-notes.md', 'text/markdown', `# Board, ${new Date().toISOString().slice(0, 10)}\n\n${lines.join('\n')}${lines.length ? '\n\n' : ''}${notes.length ? `## Notes\n\n${notes.join('\n')}\n` : ''}`);
401
+ }
402
+ });
403
+ // The pen: freehand strokes in the board's own coordinates, so they scale with it.
404
+ let stroke = null;
405
+ const at = (e) => { const r = s.getBoundingClientRect(); return [((e.clientX - r.left) / r.width) * 100, ((e.clientY - r.top) / r.height) * 60]; };
406
+ s.addEventListener('pointerdown', (e) => {
407
+ if (!area.classList.contains('is-pen')) return;
408
+ e.preventDefault();
409
+ s.setPointerCapture(e.pointerId);
410
+ const [x, y] = at(e);
411
+ stroke = document.createElementNS(NS, 'path');
412
+ stroke.setAttribute('class', 'avatar-stroke avatar-ink');
413
+ stroke.style.stroke = pen.colour;
414
+ stroke.style.strokeWidth = String(pen.width);
415
+ stroke.setAttribute('d', `M${x.toFixed(2)} ${y.toFixed(2)}`);
416
+ s.appendChild(stroke);
417
+ added.push(stroke);
418
+ });
419
+ s.addEventListener('pointermove', (e) => {
420
+ if (!stroke) return;
421
+ const [x, y] = at(e);
422
+ stroke.setAttribute('d', `${stroke.getAttribute('d')} L${x.toFixed(2)} ${y.toFixed(2)}`);
423
+ });
424
+ ['pointerup', 'pointercancel'].forEach((ev) => s.addEventListener(ev, () => { stroke = null; }));
425
+ }
426
+ return { lines: body.querySelector('.avatar-board-lines'), svg: body.querySelector('svg'), area: body.querySelector('.avatar-board-area') };
427
+ }
428
+ const NOTE_STAMPS = ['improvement', 'sharing', 'respect', 'customer', 'mastery'];
429
+ // A sticky note: type in it, drag it by its edge, remove it with its ×.
430
+ function addNote(area, text) {
431
+ const n = document.createElement('div');
432
+ n.className = 'avatar-note';
433
+ const count = area.querySelectorAll('.avatar-note').length;
434
+ // In a row along the lower part of the board, below where a drawing usually goes, never on each other.
435
+ n.style.left = `${2 + (count % 4) * 24.5}%`;
436
+ n.style.top = `${56 + (Math.floor(count / 4) % 2) * 22}%`;
437
+ // Each note carries one of the Webship values' marks, in turn: improvement, sharing, respect, customer, mastery.
438
+ const stamp = NOTE_STAMPS[count % NOTE_STAMPS.length];
439
+ n.innerHTML = `<div class="avatar-note-grip"><img class="avatar-note-stamp" src="${VI}/value/${stamp}-primary.svg" alt="" width="14" height="14"><button type="button" class="avatar-note-x" aria-label="Remove the note" title="Remove">×</button></div><div class="avatar-note-text" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Note"></div>`;
440
+ n.querySelector('.avatar-note-text').textContent = text;
441
+ n.querySelector('.avatar-note-x').addEventListener('click', () => n.remove());
442
+ // Dragged inside the board: positions in percent, so a note stays put when the board is resized.
443
+ n.querySelector('.avatar-note-grip').addEventListener('pointerdown', (e) => {
444
+ if (e.target.closest('button')) return;
445
+ const r = area.getBoundingClientRect(); const nr = n.getBoundingClientRect();
446
+ const dx = e.clientX - nr.left; const dy = e.clientY - nr.top;
447
+ e.currentTarget.setPointerCapture(e.pointerId);
448
+ const move = (ev) => {
449
+ n.style.left = `${Math.max(0, Math.min(90, ((ev.clientX - dx - r.left) / r.width) * 100))}%`;
450
+ n.style.top = `${Math.max(0, Math.min(90, ((ev.clientY - dy - r.top) / r.height) * 100))}%`;
451
+ };
452
+ const grip = e.currentTarget;
453
+ const up = () => { grip.removeEventListener('pointermove', move); grip.removeEventListener('pointerup', up); };
454
+ grip.addEventListener('pointermove', move);
455
+ grip.addEventListener('pointerup', up);
456
+ });
457
+ area.appendChild(n);
458
+ added.push(n);
459
+ if (!text) n.querySelector('.avatar-note-text').focus();
460
+ return n;
461
+ }
462
+ function clearBoard() {
463
+ const b = board();
464
+ b.lines.replaceChildren();
465
+ [...b.svg.querySelectorAll('rect, circle, line, text, path:not(marker path)')].forEach((e) => e.remove());
466
+ b.area.querySelectorAll('.avatar-note').forEach((n) => n.remove());
467
+ added.length = 0;
468
+ }
469
+ function download(name, type, text) {
470
+ const a = document.createElement('a');
471
+ a.href = URL.createObjectURL(new Blob([text], { type }));
472
+ a.download = name;
473
+ document.body.appendChild(a);
474
+ a.click();
475
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
476
+ }
477
+ const num = (v, max) => Math.max(0, Math.min(max, Number(v) || 0));
478
+ let strokeDelay = 0;
479
+ // Colours by name, for the pen and for the assistant: a calm, friendly set on the brand's blues.
480
+ const COLOURS = { ink: '#2b447c', blue: '#0074e0', sky: '#34ade2', teal: '#0f9d8a', green: '#3fa34d', amber: '#e5a00d',
481
+ coral: '#ef6a5a', pink: '#d6589f', purple: '#7a5cc7', grey: '#7b8494', black: '#1d1d1f', white: '#ffffff' };
482
+ const colour = (v, fallback) => {
483
+ const c = String(v || '').toLowerCase();
484
+ if (COLOURS[c]) return COLOURS[c];
485
+ return /^#[0-9a-f]{3}([0-9a-f]{3})?$/.test(c) ? c : fallback;
486
+ };
487
+ // Thin by default: the assistant draws like an artist, in fine lines, unless it asks for more.
488
+ function shape(s, name, attrs, label, at, o = {}) {
489
+ const el = document.createElementNS(NS, name);
490
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
491
+ el.setAttribute('class', 'avatar-stroke');
492
+ el.style.stroke = colour(o.color, '');
493
+ el.style.strokeWidth = String(Math.max(0.08, Math.min(3, Number(o.width) || 0.3)));
494
+ if (o.fill && o.fill !== 'none') { el.style.fill = colour(o.fill, 'none'); el.style.fillOpacity = String(Math.max(0.05, Math.min(1, Number(o.opacity) || 0.35))); }
495
+ if (o.dash) el.style.strokeDasharray = '1 1';
496
+ s.appendChild(el);
497
+ added.push(el);
498
+ // Drawn, not placed: the stroke runs out along the shape, one after another.
499
+ if (!reduced() && el.getTotalLength && !o.dash) {
500
+ const len = el.getTotalLength();
501
+ el.style.strokeDasharray = len;
502
+ el.style.strokeDashoffset = len;
503
+ el.style.animation = `avatar-draw ${Math.min(1.6, 0.4 + len / 120).toFixed(2)}s ease ${strokeDelay}s forwards`;
504
+ }
505
+ if (label) {
506
+ const t = document.createElementNS(NS, 'text');
507
+ t.setAttribute('x', at[0]); t.setAttribute('y', at[1]);
508
+ t.setAttribute('class', 'avatar-board-label');
509
+ if (o.color) t.style.fill = colour(o.color, '');
510
+ t.textContent = label.slice(0, 40);
511
+ if (!reduced()) t.style.animation = `avatar-fade .4s ease ${strokeDelay + 0.45}s both`;
512
+ s.appendChild(t);
513
+ added.push(t);
514
+ }
515
+ strokeDelay += 0.45;
516
+ }
517
+ // A smooth line through points (Catmull-Rom as cubic Béziers): the artist's curve.
518
+ function smoothPath(pts) {
519
+ if (pts.length < 2) return '';
520
+ let d = `M${pts[0][0]} ${pts[0][1]}`;
521
+ for (let i = 0; i < pts.length - 1; i++) {
522
+ const p0 = pts[i - 1] || pts[i]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] || p2;
523
+ const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
524
+ const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
525
+ d += ` C${c1[0].toFixed(2)} ${c1[1].toFixed(2)} ${c2[0].toFixed(2)} ${c2[1].toFixed(2)} ${p2[0]} ${p2[1]}`;
526
+ }
527
+ return d;
528
+ }
529
+ function draw(cmds) {
530
+ const { svg: s } = board();
531
+ strokeDelay = 0;
532
+ for (const raw of cmds || []) {
533
+ const all = String(raw).trim().split(/\s+/);
534
+ const kind = (all.shift() || '').toLowerCase();
535
+ // Options anywhere after the shape: color=teal fill=amber width=0.2 opacity=0.4 dash
536
+ const o = {};
537
+ const rest = all.filter((t) => {
538
+ const m = t.match(/^(color|colour|fill|width|opacity)=(\S+)$/i);
539
+ if (m) { o[m[1].toLowerCase() === 'colour' ? 'color' : m[1].toLowerCase()] = m[2]; return false; }
540
+ if (/^dash(ed)?$/i.test(t)) { o.dash = true; return false; }
541
+ return true;
542
+ });
543
+ const n = rest.slice(0, 4).map(Number);
544
+ const label = (k) => rest.slice(k).join(' ').replace(/^["']|["']$/g, '');
545
+ const points = () => {
546
+ const nums = rest.map(Number).filter((v) => Number.isFinite(v));
547
+ const pts = [];
548
+ for (let i = 0; i + 1 < nums.length && pts.length < 40; i += 2) pts.push([num(nums[i], 100), num(nums[i + 1], 60)]);
549
+ return pts;
550
+ };
551
+ switch (kind) {
552
+ case 'box': case 'rect': {
553
+ const [x, y, w, h] = [num(n[0], 100), num(n[1], 60), num(n[2], 100), num(n[3], 60)];
554
+ shape(s, 'rect', { x, y, width: w, height: h, rx: 1.6 }, label(4), [x + w / 2, y + h / 2 + 1.2], o);
555
+ break;
556
+ }
557
+ case 'circle': {
558
+ const [x, y, r] = [num(n[0], 100), num(n[1], 60), num(n[2], 30)];
559
+ shape(s, 'circle', { cx: x, cy: y, r }, label(3), [x, y + 1.2], o);
560
+ break;
561
+ }
562
+ case 'ellipse': {
563
+ const [x, y, rx, ry] = [num(n[0], 100), num(n[1], 60), num(n[2], 50), num(n[3], 30)];
564
+ shape(s, 'ellipse', { cx: x, cy: y, rx, ry }, label(4), [x, y + 1.2], o);
565
+ break;
566
+ }
567
+ case 'line': case 'arrow':
568
+ shape(s, 'line', { x1: num(n[0], 100), y1: num(n[1], 60), x2: num(n[2], 100), y2: num(n[3], 60), ...(kind === 'arrow' ? { 'marker-end': 'url(#avatar-arrow)' } : {}) }, '', null, o);
569
+ break;
570
+ case 'curve': case 'poly': case 'polygon': {
571
+ const pts = points();
572
+ if (pts.length < 2) break;
573
+ const d = kind === 'curve' ? smoothPath(pts) : `M${pts.map((p) => p.join(' ')).join(' L')}${kind === 'polygon' ? ' Z' : ''}`;
574
+ shape(s, 'path', { d }, '', null, o);
575
+ break;
576
+ }
577
+ case 'text': {
578
+ const t = document.createElementNS(NS, 'text');
579
+ t.setAttribute('x', num(n[0], 100)); t.setAttribute('y', num(n[1], 60));
580
+ t.setAttribute('class', 'avatar-board-label avatar-board-text');
581
+ if (o.color) t.style.fill = colour(o.color, '');
582
+ if (o.width) t.style.fontSize = `${Math.max(1.5, Math.min(8, Number(o.width) * 3))}px`;
583
+ t.textContent = label(2).slice(0, 60);
584
+ if (!reduced()) t.style.animation = `avatar-fade .4s ease ${strokeDelay}s both`;
585
+ strokeDelay += 0.3;
586
+ s.appendChild(t);
587
+ added.push(t);
588
+ break;
589
+ }
590
+ case 'clear': clearBoard(); break;
591
+ default: break;
592
+ }
593
+ }
594
+ }
595
+ function write(lines) {
596
+ const b = board();
597
+ for (const line of lines || []) {
598
+ if (/^clear$/i.test(line)) { clearBoard(); continue; }
599
+ const note = line.match(/^note:?\s+(.+)$/i);
600
+ if (note) { addNote(b.area, note[1]); continue; }
601
+ const li = document.createElement('li');
602
+ li.textContent = line;
603
+ b.lines.appendChild(li);
604
+ }
605
+ }
606
+
607
+ // A video: YouTube's privacy-enhanced player, its pause and loop on the quick replies too.
608
+ let video = null;
609
+ // Every video gets a player of its own, opened and closed on its own, with its own controls: back and
610
+ // forward ten seconds, play and pause, quieter and louder, mute, speed, restart and loop. The assistant
611
+ // has the same set (VIDEO: …), acting on the latest player. YouTube tells each player's page where it
612
+ // is (time, volume, speed) once asked to, which is what lets "forward" and "louder" know from where.
613
+ const videos = {};
614
+ const YT = 'https://www.youtube-nocookie.com';
615
+ function videoCommand(func, args = [], v = video) {
616
+ if (!v || !v.frame.contentWindow) return;
617
+ v.frame.contentWindow.postMessage(JSON.stringify({ event: 'command', func, args }), YT);
618
+ }
619
+ window.addEventListener('message', (e) => {
620
+ if (e.origin !== YT) return;
621
+ let d;
622
+ try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; } catch (_) { return; }
623
+ const v = Object.values(videos).find((x) => x.frame.contentWindow === e.source);
624
+ if (!v || !d || !d.info) return;
625
+ if (Number.isFinite(d.info.currentTime)) v.time = d.info.currentTime;
626
+ if (Number.isFinite(d.info.volume)) v.volume = d.info.volume;
627
+ if (typeof d.info.muted === 'boolean') v.muted = d.info.muted;
628
+ if (Number.isFinite(d.info.playbackRate)) v.rate = d.info.playbackRate;
629
+ if (Number.isFinite(d.info.playerState)) { v.paused = d.info.playerState === 2; paintVideo(v); }
630
+ });
631
+ const SPEEDS = [0.75, 1, 1.25, 1.5, 2];
632
+ function controlVideo(raw, v = video) {
633
+ if (!v) return;
634
+ const [cmd, arg] = String(raw).trim().toLowerCase().split(/\s+/);
635
+ const t = v.time || 0;
636
+ switch (cmd) {
637
+ case 'play': videoCommand('playVideo', [], v); v.paused = false; break;
638
+ case 'pause': videoCommand('pauseVideo', [], v); v.paused = true; break;
639
+ case 'toggle': controlVideo(v.paused ? 'play' : 'pause', v); return;
640
+ case 'forward': videoCommand('seekTo', [t + (Number(arg) || 10), true], v); break;
641
+ case 'back': videoCommand('seekTo', [Math.max(0, t - (Number(arg) || 10)), true], v); break;
642
+ case 'restart': videoCommand('seekTo', [0, true], v); videoCommand('playVideo', [], v); break;
643
+ case 'stop': videoCommand('pauseVideo', [], v); videoCommand('seekTo', [0, true], v); v.paused = true; break;
644
+ case 'seek': {
645
+ const parts = String(arg || '').split(':').map(Number);
646
+ const secs = parts.reduce((acc, x) => acc * 60 + (Number.isFinite(x) ? x : 0), 0);
647
+ videoCommand('seekTo', [secs, true], v);
648
+ break;
649
+ }
650
+ case 'quieter': case 'louder': {
651
+ v.volume = Math.max(0, Math.min(100, (Number.isFinite(v.volume) ? v.volume : 100) + (cmd === 'louder' ? 15 : -15)));
652
+ videoCommand('setVolume', [v.volume], v);
653
+ if (v.muted && cmd === 'louder') { videoCommand('unMute', [], v); v.muted = false; }
654
+ break;
655
+ }
656
+ case 'volume': v.volume = Math.max(0, Math.min(100, Number(arg) || 0)); videoCommand('setVolume', [v.volume], v); break;
657
+ case 'mute': videoCommand('mute', [], v); v.muted = true; break;
658
+ case 'unmute': videoCommand('unMute', [], v); v.muted = false; break;
659
+ case 'speed': {
660
+ const next = Number(arg) || SPEEDS[(SPEEDS.indexOf(v.rate || 1) + 1) % SPEEDS.length];
661
+ v.rate = Math.max(0.25, Math.min(2, next));
662
+ videoCommand('setPlaybackRate', [v.rate], v);
663
+ break;
664
+ }
665
+ case 'loop': { const { id, kind, loop } = v; closeWindow(kind); showVideo(id, !loop); return; }
666
+ case 'close': closeWindow(v.kind); return;
667
+ default: return;
668
+ }
669
+ paintVideo(v);
670
+ }
671
+ function paintVideo(v) {
672
+ const w = windows[v.kind];
673
+ if (!w) return;
674
+ const play = w.querySelector('[data-video="toggle"]');
675
+ if (play) { play.innerHTML = vi(v.paused ? 'play' : 'pause'); play.title = v.paused ? 'Play' : 'Pause'; play.setAttribute('aria-label', play.title); }
676
+ const mute = w.querySelector('[data-video="mute"]');
677
+ if (mute) { mute.setAttribute('aria-pressed', String(Boolean(v.muted))); mute.innerHTML = vi(v.muted ? 'sound-off' : 'sound-on'); }
678
+ const speed = w.querySelector('[data-video="speed"]');
679
+ if (speed) speed.textContent = `${v.rate || 1}×`;
680
+ const loop = w.querySelector('[data-video="loop"]');
681
+ if (loop) loop.setAttribute('aria-pressed', String(Boolean(v.loop)));
682
+ if (v === video) paintChips();
683
+ }
684
+ function showVideo(id, loop = false) {
685
+ if (!/^[\w-]{11}$/.test(id)) return;
686
+ const kind = `video-${id}`;
687
+ const w = windowFor(kind, 'Video');
688
+ w.classList.add('avatar-window-video');
689
+ const body = w.querySelector('.avatar-window-body');
690
+ const src = `${YT}/embed/${id}?autoplay=1&rel=0&modestbranding=1&playsinline=1&enablejsapi=1&origin=${encodeURIComponent(location.origin)}${loop ? `&loop=1&playlist=${id}` : ''}`;
691
+ const btn = (cmd, title, inner) => `<button type="button" class="avatar-browser-btn" data-video="${cmd}" title="${title}" aria-label="${title}">${inner}</button>`;
692
+ body.innerHTML = `<div class="avatar-video"><iframe allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe></div>
693
+ <div class="avatar-video-bar" role="toolbar" aria-label="Video controls">
694
+ ${btn('back', 'Back 10 seconds', svg('<path d="M11 17l-5-5 5-5M18 17l-5-5 5-5"/>'))}
695
+ ${btn('toggle', 'Pause', vi('pause'))}
696
+ ${btn('stop', 'Stop', vi('stop'))}
697
+ ${btn('forward', 'Forward 10 seconds', svg('<path d="M13 17l5-5-5-5M6 17l5-5-5-5"/>'))}
698
+ <span class="avatar-video-gap"></span>
699
+ ${btn('quieter', 'Quieter', svg(ICON.speaker + '<path d="M16 12h5"/>'))}
700
+ ${btn('louder', 'Louder', svg(ICON.speaker + '<path d="M16 12h5M18.5 9.5v5"/>'))}
701
+ ${btn('mute', 'Mute', vi('sound-on'))}
702
+ <span class="avatar-video-gap"></span>
703
+ <button type="button" class="avatar-chip" data-video="speed" title="Speed" aria-label="Speed">1×</button>
704
+ ${btn('restart', 'From the start', svg('<path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/>'))}
705
+ <button type="button" class="avatar-chip" data-video="loop" aria-pressed="${loop}" title="Loop">Loop</button>
706
+ </div>`;
707
+ const f = body.querySelector('iframe');
708
+ f.title = 'Video';
709
+ f.src = src;
710
+ const v = video = videos[kind] = { id, kind, loop, paused: false, muted: false, volume: 100, rate: 1, time: 0, frame: f };
711
+ // Ask the player to report where it is, so the controls know.
712
+ f.addEventListener('load', () => f.contentWindow.postMessage(JSON.stringify({ event: 'listening', id: kind }), YT));
713
+ body.querySelector('.avatar-video-bar').addEventListener('click', (e) => {
714
+ const b = e.target.closest('[data-video]');
715
+ if (b) { video = v; controlVideo(b.dataset.video, v); }
716
+ });
717
+ paintVideo(v);
718
+ chips.hidden = false;
719
+ }
720
+ // The browser: a window with its own history, an address field, back, forward, reload, zoom and a real
721
+ // tab, and the same commands for the assistant (BROWSE: back, down, find …, click …). Pages come
722
+ // through the reader: served by the dashboard with no scripts at all, so this page may reach into them
723
+ // (sandbox allow-same-origin, never allow-scripts) to scroll, find, click and read what is in view.
724
+ const nav = { list: [], at: -1, zoom: 1 };
725
+ const readerUrl = (href) => `/fragments/reader?u=${encodeURIComponent(href)}`;
726
+ function browserWindow() {
727
+ const w = windowFor('browser', 'Browser');
728
+ const body = w.querySelector('.avatar-window-body');
729
+ if (!body.querySelector('.avatar-reader')) {
730
+ body.innerHTML = `<form class="avatar-browser-bar" role="toolbar" aria-label="Browser">
731
+ <button type="button" class="avatar-browser-btn" data-nav="back" title="Back" aria-label="Back">${svg('<path d="M15 18l-6-6 6-6"/>')}</button>
732
+ <button type="button" class="avatar-browser-btn" data-nav="forward" title="Forward" aria-label="Forward">${svg('<path d="M9 18l6-6-6-6"/>')}</button>
733
+ <button type="button" class="avatar-browser-btn" data-nav="reload" title="Reload" aria-label="Reload">${svg('<path d="M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5"/>')}</button>
734
+ <input type="text" class="avatar-browser-address" aria-label="Address or search words" placeholder="Address or search words">
735
+ <button type="button" class="avatar-browser-btn" data-nav="zoomout" title="Smaller text" aria-label="Smaller text">${svg('<path d="M5 12h14"/>')}</button>
736
+ <button type="button" class="avatar-browser-btn" data-nav="zoomin" title="Bigger text" aria-label="Bigger text">${svg('<path d="M12 5v14M5 12h14"/>')}</button>
737
+ <button type="button" class="avatar-browser-btn" data-nav="read" title="Read aloud what is in view" aria-label="Read aloud what is in view">${vi('sound-on')}</button>
738
+ <button type="button" class="avatar-browser-btn" data-nav="newtab" title="Open in a real tab" aria-label="Open in a real tab">${vi('share')}</button>
739
+ </form><iframe class="avatar-reader" sandbox="allow-same-origin" referrerpolicy="no-referrer"></iframe>`;
740
+ const form = body.querySelector('.avatar-browser-bar');
741
+ form.addEventListener('click', (e) => { const b = e.target.closest('[data-nav]'); if (b) browserCommand(b.dataset.nav); });
742
+ form.addEventListener('submit', (e) => { e.preventDefault(); openAnything(form.querySelector('input').value.trim()); });
743
+ const f = body.querySelector('iframe');
744
+ f.title = 'Browser';
745
+ // A link clicked in a page stays in the window and in its history.
746
+ f.addEventListener('load', () => {
747
+ const doc = readerDoc();
748
+ if (!doc) return;
749
+ doc.documentElement.style.zoom = String(nav.zoom);
750
+ w.querySelector('.avatar-window-head strong').textContent = doc.title || 'Browser';
751
+ doc.addEventListener('click', (e) => {
752
+ const link = e.target.closest && e.target.closest('a[href^="/fragments/reader?u="]');
753
+ if (!link) return;
754
+ e.preventDefault();
755
+ browse(new URL(link.getAttribute('href'), location.origin).searchParams.get('u'));
756
+ });
757
+ });
758
+ }
759
+ return w;
760
+ }
761
+ const readerDoc = () => { try { return windows.browser && windows.browser.querySelector('.avatar-reader').contentDocument; } catch (_) { return null; } };
762
+ function load(href) {
763
+ const w = browserWindow();
764
+ w.querySelector('.avatar-browser-address').value = href;
765
+ w.querySelector('.avatar-reader').src = readerUrl(href);
766
+ }
767
+ function browse(link) {
768
+ let u;
769
+ try { u = new URL(link); } catch (_) { return; }
770
+ if (u.protocol !== 'https:') return;
771
+ nav.list = nav.list.slice(0, nav.at + 1);
772
+ nav.list.push(u.href);
773
+ nav.at = nav.list.length - 1;
774
+ load(u.href);
775
+ }
776
+ function browserCommand(raw) {
777
+ const [cmd, ...rest] = String(raw).trim().split(/\s+/);
778
+ const words = rest.join(' ');
779
+ const doc = readerDoc();
780
+ const se = doc && doc.scrollingElement;
781
+ switch ((cmd || '').toLowerCase()) {
782
+ case 'back': if (nav.at > 0) load(nav.list[--nav.at]); break;
783
+ case 'forward': if (nav.at < nav.list.length - 1) load(nav.list[++nav.at]); break;
784
+ case 'reload': if (nav.at >= 0) load(nav.list[nav.at]); break;
785
+ case 'close': closeWindow('browser'); break;
786
+ case 'read': {
787
+ const view = window.wsAvatarContext && window.wsAvatarContext();
788
+ if (view && view.text && voice() && voice().speak) { voice().speak(view.text.slice(0, 900)); if (avatar) avatar.gesture('explain'); }
789
+ break;
790
+ }
791
+ case 'newtab': if (nav.at >= 0) window.open(nav.list[nav.at], '_blank', 'noopener'); break;
792
+ case 'zoomin': case 'zoomout':
793
+ nav.zoom = Math.max(0.6, Math.min(2, nav.zoom + (cmd.toLowerCase() === 'zoomin' ? 0.15 : -0.15)));
794
+ if (doc) doc.documentElement.style.zoom = String(nav.zoom);
795
+ break;
796
+ case 'down': case 'up': case 'top': case 'bottom':
797
+ if (se) se.scrollTo({ top: { down: se.scrollTop + se.clientHeight * 0.8, up: se.scrollTop - se.clientHeight * 0.8, top: 0, bottom: se.scrollHeight }[cmd.toLowerCase()], behavior: reduced() ? 'auto' : 'smooth' });
798
+ break;
799
+ case 'find': {
800
+ if (!doc || !words) break;
801
+ doc.querySelectorAll('mark.avatar-found').forEach((m) => m.replaceWith(...m.childNodes));
802
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
803
+ const want = words.toLowerCase();
804
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
805
+ const i = n.nodeValue.toLowerCase().indexOf(want);
806
+ if (i < 0) continue;
807
+ const r = doc.createRange(); r.setStart(n, i); r.setEnd(n, i + words.length);
808
+ const mark = doc.createElement('mark'); mark.className = 'avatar-found';
809
+ r.surroundContents(mark);
810
+ mark.scrollIntoView({ block: 'center' });
811
+ break;
812
+ }
813
+ break;
814
+ }
815
+ case 'click': {
816
+ if (!doc || !words) break;
817
+ const want = words.toLowerCase();
818
+ const links = [...doc.querySelectorAll('a[href^="/fragments/reader?u="]')];
819
+ const a = links.find((x) => x.textContent.trim().toLowerCase() === want) || links.find((x) => x.textContent.toLowerCase().includes(want));
820
+ if (a) browse(new URL(a.getAttribute('href'), location.origin).searchParams.get('u'));
821
+ break;
822
+ }
823
+ default: break;
824
+ }
825
+ }
826
+ // What the browser shows, for the assistant with each message: so it can read the page and go on.
827
+ window.wsAvatarContext = () => {
828
+ if (!windows.browser || nav.at < 0) return null;
829
+ const doc = readerDoc();
830
+ if (!doc || !doc.body) return { url: nav.list[nav.at] };
831
+ const se = doc.scrollingElement;
832
+ const blocks = [...doc.body.querySelectorAll('h1, h2, h3, p, li, pre, td')];
833
+ const view = blocks.filter((el) => { const r = el.getBoundingClientRect(); return r.bottom > 0 && r.top < (se ? se.clientHeight : 600); })
834
+ .map((el) => el.textContent.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ').slice(0, 1500);
835
+ const links = [...doc.querySelectorAll('a[href^="/fragments/reader?u="]')].map((a) => a.textContent.replace(/\s+/g, ' ').trim()).filter((t) => t && t.length < 60).slice(0, 30);
836
+ return { title: (doc.title || '').slice(0, 140), url: nav.list[nav.at], scrolled: se ? Math.round((se.scrollTop / Math.max(1, se.scrollHeight - se.clientHeight)) * 100) : 0, text: view, links };
837
+ };
838
+ // One field opens anything: a video link plays, a web address is read, words are searched.
839
+ function openAnything(v) {
840
+ if (!v) return;
841
+ let u = null;
842
+ try { u = new URL(/^[a-z]+:\/\//i.test(v) ? v : (/^[\w-]+(\.[\w-]+)+(\/\S*)?$/.test(v) ? `https://${v}` : '')); } catch (_) { u = null; }
843
+ const yt = u && (u.hostname.replace(/^www\.|^m\./, '') === 'youtu.be' ? u.pathname.slice(1, 12)
844
+ : /(^|\.)youtube\.com$/.test(u.hostname) ? (u.searchParams.get('v') || '') : '');
845
+ if (yt && /^[\w-]{11}$/.test(yt)) showVideo(yt);
846
+ else if (u) browse(u.href);
847
+ else search(v);
848
+ }
849
+
850
+ // A web search, read through the same reader: DuckDuckGo's plain results, each one opened in the window.
851
+ const search = (words) => browse(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(String(words).slice(0, 160))}`);
852
+
853
+ // ---- The robot button's menu: show or hide, the board, the open windows, settings ---------------
854
+ const micBtn = dock.querySelector('.dock-talk');
855
+ const keepBtn = dock.querySelector('.dock-mic');
856
+ const pops = bar.querySelector('.assistant-avatar-pops');
857
+ const MIC = '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>';
858
+ micBtn.querySelector('.dock-talk-icon').innerHTML = svg(MIC, 20);
859
+ keepBtn.innerHTML = vi('headset');
860
+ // The menu's tool buttons carry the dashboard's own Tabler icons, drawn by the server with the rest of the header.
861
+ // Its settings, kept per browser on the hub domain: how big it is, whether it wanders, where it stands.
862
+ const SIZES = { small: 'Small', medium: 'Medium', large: 'Large', xl: 'Very big', huge: 'Huge' };
863
+ const SIZE_ORDER = Object.keys(SIZES);
864
+ const applySize = () => { floatCard.dataset.size = SIZES[getPref('ws-avatar-size', 'medium')] ? getPref('ws-avatar-size', 'medium') : 'medium'; };
865
+ // A size by name, or a step bigger or smaller; it keeps its feet where they were, inside the page.
866
+ function resize(to) {
867
+ const now = SIZE_ORDER.indexOf(floatCard.dataset.size || 'medium');
868
+ const next = to === 'bigger' ? SIZE_ORDER[Math.min(SIZE_ORDER.length - 1, now + 1)] : to === 'smaller' ? SIZE_ORDER[Math.max(0, now - 1)] : to;
869
+ if (!SIZES[next]) return;
870
+ const before = floatCard.getBoundingClientRect();
871
+ pref('ws-avatar-size', next);
872
+ // The new size at once (no transition), so it can be measured now, and then it flies to stay inside the
873
+ // page with its feet where they were. Waiting for a size transition to end is not reliable on a busy machine.
874
+ floatCard.classList.add('is-sizing');
875
+ applySize();
876
+ const after = floatCard.getBoundingClientRect();
877
+ floatCard.classList.remove('is-sizing');
878
+ flyTo(before.left, before.bottom - after.height);
879
+ if (avatar) avatar.gesture(SIZE_ORDER.indexOf(next) > now ? 'cheer' : 'nod');
880
+ }
881
+ const wanders = () => getPref('ws-avatar-wander', '1') === '1';
882
+ // The voices: the browser's own (free), and the ElevenLabs voices of the account once the Workspace has
883
+ // its key (listing is free; each reply spoken in one spends credits, which the group's label says).
884
+ let eleven = null;
885
+ const loadEleven = () => {
886
+ if (eleven) return;
887
+ eleven = { voices: [] };
888
+ fetch('/fragments/assistant/voices', { headers: { Accept: 'application/json' } }).then((r) => r.json())
889
+ .then((d) => { eleven = d && Array.isArray(d.voices) ? d : { voices: [] }; if (openPop === 'settings') renderPop('settings'); })
890
+ .catch(() => { /* the browser's voices are still there */ });
891
+ };
892
+ function voiceOptions() {
893
+ const current = (voice() && voice().get && voice().get('voiceName')) || '';
894
+ const opt = (value, label) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(label)}</option>`;
895
+ const own = (window.speechSynthesis ? speechSynthesis.getVoices() : []).map((vo) => opt(vo.name, `${vo.name} (${vo.lang})`)).join('');
896
+ const paid = eleven && eleven.voices.length
897
+ ? `<optgroup label="ElevenLabs (spends credits)">${eleven.voices.map((v) => opt(`elevenlabs:${v.id}`, v.label ? `${v.name} (${v.label})` : v.name)).join('')}</optgroup>` : '';
898
+ return `${opt('', 'The best free voice')}${own ? `<optgroup label="This browser (free)">${own}</optgroup>` : ''}${paid}`;
899
+ }
900
+ // Hair colours offered: the brand's own first, then everyday ones.
901
+ const HAIR = [['#0074e0', 'Brand blue'], ['#2b447c', 'Brand navy'], ['#1f2937', 'Black'], ['#6b4226', 'Brown'],
902
+ ['#c9a26b', 'Blond'], ['#9ca3af', 'Grey'], ['', 'As the model came']];
903
+ const setHair = (hex) => {
904
+ const me = who(); pref(`ws-avatar-hair-${me.id}`, hex);
905
+ const c = coloursOf(me); delete c.Hair; saveColours(me, c);
906
+ if (avatar && avatar.setHair) avatar.setHair(hex || me.hair || '');
907
+ };
908
+ let openPop = '';
909
+ function renderPop(name) {
910
+ if (name === 'settings') loadEleven();
911
+ if (name === 'windows') {
912
+ const open = Object.keys(windows);
913
+ pops.innerHTML = open.length
914
+ ? `<ul class="avatar-kit-list">${open.map((k) => `<li><span>${windows[k].getAttribute('aria-label') || k}</span><button type="button" class="avatar-chip" data-front="${k}">Show</button><button type="button" class="avatar-chip" data-close="${k}">Close</button></li>`).join('')}</ul>`
915
+ : '<p class="avatar-kit-empty">No windows are open. The board, a video or a page open here when the avatar or you open them.</p>';
916
+ } else if (name === 'settings') {
917
+ const size = floatCard.dataset.size || 'medium';
918
+ const me = who();
919
+ pops.innerHTML = `<div class="avatar-kit-settings">
920
+ ${cast.avatars.length > 1 ? `<label class="avatar-kit-range"><span>Avatar</span><select data-avatar-who aria-label="Avatar">${cast.avatars.map((a) => `<option value="${esc(a.id)}"${a.id === me.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('')}</select></label>` : ''}
921
+ ${me.credit ? `<p class="avatar-kit-credit">${esc(me.credit)}</p>` : ''}
922
+ ${avatar && avatar.colourParts ? `<div class="avatar-kit-colours" role="group" aria-label="Colours"><span>Colours</span><div>${avatar.colourParts().map(({ part, hex }) => `<label class="avatar-kit-colour"><input type="color" data-part="${esc(part)}" value="${esc(hex)}" aria-label="${esc(part)} colour"><span>${esc(part)}</span></label>`).join('')}</div><button type="button" class="avatar-chip" data-colours-reset>Original colours</button></div>` : ''}
923
+ ${avatar && avatar.hasHair ? `<div role="group" aria-label="Hair colour"><span>Hair</span>${HAIR.map(([c, l]) => `<button type="button" class="avatar-swatch" data-hair="${c}" title="${l}" aria-label="${l}" aria-pressed="${hairOf(me) === c}" style="background:${c || 'transparent'}">${c ? '' : '×'}</button>`).join('')}<input type="color" data-hair-pick value="${hairOf(me) || '#0074e0'}" aria-label="Any hair colour" title="Any colour"></div>` : ''}
924
+ <div role="group" aria-label="Look"><span>Look</span>${[['2d', '2D'], ['3d', '3D']].map(([k, l]) => `<button type="button" class="avatar-chip" data-look="${k}" aria-pressed="${lookPref() === k}">${l}</button>`).join('')}</div>
925
+ <div role="group" aria-label="Size"><span>Size</span>${Object.entries(SIZES).map(([k, l]) => `<button type="button" class="avatar-chip" data-size="${k}" aria-pressed="${k === size}">${l}</button>`).join('')}</div>
926
+ <div role="group" aria-label="Where it stands"><span>Stand</span>${[['home', 'By the text box'], ['center', 'Center'], ['right', 'Right']].map(([k, l]) => `<button type="button" class="avatar-chip" data-place="${k}">${l}</button>`).join('')}</div>
927
+ <label class="avatar-kit-range"><span>Voice</span><select data-voice-name aria-label="Voice">${voiceOptions()}</select></label>
928
+ <label class="avatar-kit-range"><span>Volume</span><input type="range" min="0" max="1" step="0.05" data-voice="volume" value="${voice() && voice().get ? (voice().get('volume') ?? 1) : 1}" aria-label="Voice volume"></label>
929
+ <label class="avatar-kit-range"><span>Speed</span><input type="range" min="0.5" max="2" step="0.05" data-voice="rate" value="${voice() && voice().get ? (voice().get('rate') ?? 1) : 1}" aria-label="Voice speed"></label>
930
+ <label class="avatar-kit-range"><span>Pitch</span><input type="range" min="0" max="2" step="0.05" data-voice="pitch" value="${voice() && voice().get ? (voice().get('pitch') ?? 1) : 1}" aria-label="Voice pitch"></label>
931
+ <label class="avatar-kit-check"><input type="checkbox" data-wander ${wanders() ? 'checked' : ''}> Move about now and then</label>
932
+ <label class="avatar-kit-check"><input type="checkbox" data-roam ${roams() ? 'checked' : ''}> Roam the whole page, not only by the text box</label>
933
+ </div>`;
934
+ } else pops.innerHTML = '';
935
+ }
936
+ // The board opens in a window of its own, so the menu steps out of its way.
937
+ const closeMenu = () => { try { if (window.UIkit) window.UIkit.dropdown(bar).hide(false); } catch (_) { /* no menu to close */ } };
938
+ function setPop(name) {
939
+ openPop = openPop === name ? '' : name;
940
+ bar.querySelectorAll('[data-pop]').forEach((b) => b.setAttribute('aria-expanded', String(b.dataset.pop === openPop)));
941
+ renderPop(openPop);
942
+ }
943
+ bar.addEventListener('click', (e) => {
944
+ const lk = e.target.closest('[data-look]');
945
+ if (lk) { pref('ws-avatar-look', lk.dataset.look); if (avatar && avatar.setLook) avatar.setLook(lk.dataset.look); renderPop('settings'); return; }
946
+ if (e.target.closest('[data-colours-reset]')) {
947
+ const me = who(); const c = coloursOf(me);
948
+ for (const part of Object.keys(c)) if (avatar) avatar.setColour(part, '');
949
+ saveColours(me, {}); pref(`ws-avatar-hair-${me.id}`, '');
950
+ if (avatar && me.hair) avatar.setHair(me.hair);
951
+ renderPop('settings'); return;
952
+ }
953
+ const h = e.target.closest('[data-hair]');
954
+ if (h) { setHair(h.dataset.hair); renderPop('settings'); return; }
955
+ const b = e.target.closest('[data-pop], [data-kit], [data-front], [data-close], [data-size], [data-place]');
956
+ if (!b) return;
957
+ if (b.dataset.pop) setPop(b.dataset.pop);
958
+ else if (b.dataset.kit === 'board') { closeMenu(); board(); if (avatar) avatar.gesture('explain'); }
959
+ else if (b.dataset.front) { const w = windows[b.dataset.front]; if (w) { document.body.appendChild(w); w.focus?.(); } }
960
+ else if (b.dataset.close) { closeWindow(b.dataset.close); renderPop('windows'); }
961
+ else if (b.dataset.size) { resize(b.dataset.size); renderPop('settings'); }
962
+ else if (b.dataset.place) place(b.dataset.place);
963
+ });
964
+ bar.addEventListener('change', (e) => {
965
+ if (e.target.matches('[data-wander]')) pref('ws-avatar-wander', e.target.checked ? '1' : '0');
966
+ if (e.target.matches('[data-hair-pick]')) { setHair(e.target.value); renderPop('settings'); }
967
+ if (e.target.matches('[data-roam]')) { pref('ws-avatar-roam', e.target.checked ? '1' : '0'); if (!e.target.checked) place('home'); }
968
+ });
969
+ // A part's colour, live as the picker moves, and kept.
970
+ bar.addEventListener('input', (e) => {
971
+ if (!e.target.matches('[data-part]') || !avatar) return;
972
+ const me = who(); const c = coloursOf(me);
973
+ c[e.target.dataset.part] = e.target.value; saveColours(me, c);
974
+ avatar.setColour(e.target.dataset.part, e.target.value);
975
+ });
976
+ bar.addEventListener('input', (e) => { if (e.target.matches('[data-voice]') && voice() && voice().set) voice().set(e.target.dataset.voice, e.target.value); });
977
+ bar.addEventListener('change', (e) => {
978
+ if (!e.target.matches('[data-avatar-who]')) return;
979
+ pref('ws-avatar-who', e.target.value);
980
+ if (avatar) { avatar.dispose(); avatar = null; stage.classList.remove('is-ready'); }
981
+ if (root.classList.contains('assistant-avatar-on')) mount(true);
982
+ renderPop('settings');
983
+ });
984
+ bar.addEventListener('change', (e) => {
985
+ if (!e.target.matches('[data-voice-name]') || !voice() || !voice().set) return;
986
+ pref(`ws-voice-name-${who().id}`, e.target.value);
987
+ voice().set('voiceName', e.target.value);
988
+ if (voice().speak) voice().speak('This is how I sound now.');
989
+ });
990
+
991
+ // Talk, as in the avatar app. Hold the big button, or hold the Space bar anywhere you are not typing:
992
+ // what you say is sent when you let go. Or keep listening with the round mic: each sentence is sent as
993
+ // you finish it, until you press it again. The browser's speech recognition, in the assistant's own
994
+ // language setting; the buttons go when the browser has none.
995
+ const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition;
996
+ if (!Recognition) { micBtn.hidden = true; keepBtn.hidden = true; }
997
+ const lang = () => (window.WS_SPEECH && window.WS_SPEECH.speechToText && window.WS_SPEECH.speechToText.webSpeech && window.WS_SPEECH.speechToText.webSpeech.language) || 'en-US';
998
+ let rec = null; let heard = ''; let keep = false;
999
+ const markListening = (on) => {
1000
+ [floatCard, layer].forEach((el) => el.classList.toggle('is-listening', on));
1001
+ micBtn.classList.toggle('is-held', on && !keep);
1002
+ };
1003
+ function listen(on) {
1004
+ if (!Recognition || keep) return;
1005
+ if (on && !rec) {
1006
+ heard = '';
1007
+ if (voice()) voice().cancel();
1008
+ rec = new Recognition();
1009
+ rec.lang = lang();
1010
+ rec.interimResults = true;
1011
+ rec.continuous = true;
1012
+ rec.onresult = (ev) => { heard = [...ev.results].map((r) => r[0].transcript).join(' ').trim(); };
1013
+ rec.onend = () => { if (rec) finish(); };
1014
+ try { rec.start(); } catch (_) { rec = null; return; }
1015
+ markListening(true);
1016
+ if (avatar) avatar.gesture('look');
1017
+ } else if (!on && rec) {
1018
+ try { rec.stop(); } catch (_) { finish(); }
1019
+ }
1020
+ }
1021
+ function finish() {
1022
+ rec = null;
1023
+ markListening(false);
1024
+ if (heard) { send(heard); if (avatar) avatar.gesture('nod'); }
1025
+ heard = '';
1026
+ }
1027
+ // Keep listening: every final sentence is sent; recognition restarts when the browser ends it.
1028
+ let keepRec = null;
1029
+ function keepListening(on) {
1030
+ keep = on;
1031
+ keepBtn.setAttribute('aria-pressed', String(on));
1032
+ keepBtn.classList.toggle('is-listening', on);
1033
+ [floatCard, layer].forEach((el) => el.classList.toggle('is-listening', on));
1034
+ if (!on) { if (keepRec) { const r = keepRec; keepRec = null; try { r.stop(); } catch (_) { /* already stopped */ } } return; }
1035
+ if (voice()) voice().cancel();
1036
+ const start = () => {
1037
+ if (!keep) return;
1038
+ const r = keepRec = new Recognition();
1039
+ r.lang = lang();
1040
+ r.continuous = true;
1041
+ r.interimResults = false;
1042
+ r.onresult = (ev) => {
1043
+ for (let i = ev.resultIndex; i < ev.results.length; i++) {
1044
+ if (!ev.results[i].isFinal) continue;
1045
+ const text = ev.results[i][0].transcript.trim();
1046
+ // Not while the assistant is speaking: it would hear itself.
1047
+ if (text && !(window.speechSynthesis && window.speechSynthesis.speaking)) { send(text); if (avatar) avatar.gesture('nod'); }
1048
+ }
1049
+ };
1050
+ r.onend = () => { if (keep && keepRec === r) setTimeout(start, 250); };
1051
+ r.onerror = (ev) => { if (ev.error === 'not-allowed' || ev.error === 'service-not-allowed') { keepListening(false); keepBtn.classList.add('is-blocked'); } };
1052
+ try { r.start(); } catch (_) { /* started already */ }
1053
+ };
1054
+ start();
1055
+ }
1056
+ keepBtn.addEventListener('click', () => keepListening(!keep));
1057
+ micBtn.addEventListener('pointerdown', (e) => { e.preventDefault(); listen(true); });
1058
+ ['pointerup', 'pointerleave', 'pointercancel'].forEach((ev) => micBtn.addEventListener(ev, () => listen(false)));
1059
+ micBtn.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) { e.preventDefault(); listen(true); } });
1060
+ micBtn.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') listen(false); });
1061
+ // The Space bar anywhere on the page, unless you are typing or pressing a button with it.
1062
+ const typing = (el) => el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON|A)$/.test(el.tagName) || el.tagName === 'DEEP-CHAT' || el.closest?.('.avatar-note, [contenteditable="true"]'));
1063
+ document.addEventListener('keydown', (e) => {
1064
+ if (e.code !== 'Space' || e.repeat || keep || root.classList.contains('assistant-closed') || typing(document.activeElement)) return;
1065
+ e.preventDefault();
1066
+ listen(true);
1067
+ });
1068
+ document.addEventListener('keyup', (e) => { if (e.code === 'Space' && rec) { e.preventDefault(); listen(false); } });
1069
+
1070
+ // ---- Quick replies: answer in one click ------------------------------------------------------
1071
+ function send(text) {
1072
+ const chat = document.getElementById('ws-deep-chat');
1073
+ if (chat && typeof chat.submitUserMessage === 'function') chat.submitUserMessage({ text });
1074
+ }
1075
+ let chipsTimer = null;
1076
+ function flashChips() {
1077
+ chips.hidden = false;
1078
+ clearTimeout(chipsTimer);
1079
+ chipsTimer = setTimeout(() => { if (!video && !chips.matches(':hover, :focus-within')) chips.hidden = true; }, 20000);
1080
+ }
1081
+ function paintChips() {
1082
+ const words = ['Yes', 'No', 'Next', 'Continue'];
1083
+ let html = words.map((w) => `<button type="button" class="avatar-chip" data-say="${w}">${w}</button>`).join('');
1084
+ if (video) {
1085
+ html += `<button type="button" class="avatar-chip avatar-chip-media" data-media="pause">${video.paused ? 'Play' : 'Pause'}</button>`
1086
+ + '<button type="button" class="avatar-chip avatar-chip-media" data-media="forward">+10 s</button>'
1087
+ + '<button type="button" class="avatar-chip avatar-chip-media" data-media="quieter">Quieter</button>'
1088
+ + `<button type="button" class="avatar-chip avatar-chip-media" data-media="loop" aria-pressed="${video.loop}">Loop</button>`;
1089
+ }
1090
+ chips.innerHTML = html;
1091
+ }
1092
+ chips.addEventListener('click', (e) => {
1093
+ const b = e.target.closest('button');
1094
+ if (!b) return;
1095
+ if (b.dataset.say) { send(b.dataset.say); chips.hidden = true; return; }
1096
+ if (!video) return;
1097
+ controlVideo(b.dataset.media === 'pause' ? 'toggle' : b.dataset.media);
1098
+ });
1099
+
1100
+ // ---- Showing and hiding ------------------------------------------------------------------------
1101
+ function show() {
1102
+ root.classList.add('assistant-avatar-on');
1103
+ pref('ws-assistant-avatar', '1');
1104
+ paintToggle(true);
1105
+ paintMute();
1106
+ popOut();
1107
+ mount(true);
1108
+ }
1109
+ // Hidden means silent: the voice stops, its windows close, and the scene is let go.
1110
+ function hide() {
1111
+ root.classList.remove('assistant-avatar-on');
1112
+ pref('ws-assistant-avatar', '0');
1113
+ paintToggle(false);
1114
+ if (voice()) voice().cancel();
1115
+ keepListening(false);
1116
+ Object.keys(windows).forEach(closeWindow);
1117
+ video = null;
1118
+ queue.forEach(clearTimeout);
1119
+ if (avatar) { avatar.dispose(); avatar = null; }
1120
+ stage.classList.remove('is-ready');
1121
+ popIn();
1122
+ }
1123
+
1124
+ toggle.addEventListener('click', () => (root.classList.contains('assistant-avatar-on') ? hide() : show()));
1125
+ mute.addEventListener('click', () => {
1126
+ const v = voice();
1127
+ if (!v) return;
1128
+ v.setOn(!v.isOn());
1129
+ paintMute();
1130
+ if (avatar) avatar.gesture(v.isOn() ? 'nod' : 'shrug');
1131
+ });
1132
+
1133
+ // ---- What the assistant does ------------------------------------------------------------------
1134
+ // The request says whether the avatar is shown, so the assistant only writes its lines when it is.
1135
+ window.wsAssistantAvatarOn = () => root.classList.contains('assistant-avatar-on');
1136
+ // ---- The agent's status, as the Claude Code CLI shows it: a spinner, a verb and the time so far -------
1137
+ // While a reply is on its way the bubble over the avatar cycles through what an agent does, and the
1138
+ // avatar acts each one out: any avatar, since the moves are shared. When the reply comes it says Done
1139
+ // with a thumbs up; when the request fails, Stopped with a shrug.
1140
+ const STATUSES = [
1141
+ ['Thinking', 'think'], ['Pondering', 'look'], ['Tempering', 'hammer'], ['Searching', 'scan'],
1142
+ ['Brewing', 'stir'], ['Coding', 'type'], ['Reading', 'scan'], ['Crafting', 'hammer'],
1143
+ ];
1144
+ const SPIN = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢'];
1145
+ const status = document.createElement('div');
1146
+ status.className = 'avatar-status';
1147
+ status.setAttribute('role', 'status');
1148
+ status.setAttribute('aria-live', 'polite');
1149
+ status.hidden = true;
1150
+ status.innerHTML = '<span class="avatar-status-spin" aria-hidden="true"></span><span class="avatar-status-verb"></span><span class="avatar-status-time"></span>';
1151
+ floatCard.appendChild(status);
1152
+ const statusSpin = status.querySelector('.avatar-status-spin');
1153
+ const statusVerb = status.querySelector('.avatar-status-verb');
1154
+ const statusTime = status.querySelector('.avatar-status-time');
1155
+ let statusTimer = null; let statusHide = null; let since = 0; let step = 0;
1156
+ function startStatus() {
1157
+ clearTimeout(statusHide); clearInterval(statusTimer);
1158
+ since = Date.now(); step = 0;
1159
+ status.hidden = false; status.classList.remove('is-done', 'is-stopped');
1160
+ let n = Math.floor(Math.random() * STATUSES.length); let tick = 0;
1161
+ const next = () => {
1162
+ const [verb, move] = STATUSES[n % STATUSES.length]; n += 1;
1163
+ statusVerb.textContent = `${verb}…`;
1164
+ if (avatar && !reduced()) avatar.gesture(move);
1165
+ };
1166
+ next();
1167
+ statusTimer = setInterval(() => {
1168
+ tick += 1;
1169
+ statusSpin.textContent = SPIN[(step += 1) % SPIN.length];
1170
+ statusTime.textContent = `${Math.round((Date.now() - since) / 1000)}s`;
1171
+ if (tick % 32 === 0) next(); // a new verb and its move every 4 s
1172
+ }, 125);
1173
+ }
1174
+ function stopStatus(failed) {
1175
+ clearInterval(statusTimer);
1176
+ if (status.hidden) return;
1177
+ statusSpin.textContent = failed ? '■' : '✓';
1178
+ statusVerb.textContent = failed ? 'Stopped' : 'Done';
1179
+ statusTime.textContent = `${Math.round((Date.now() - since) / 1000)}s`;
1180
+ status.classList.add(failed ? 'is-stopped' : 'is-done');
1181
+ if (avatar) avatar.gesture(failed ? 'shrug' : 'thumbsup');
1182
+ statusHide = setTimeout(() => { status.hidden = true; }, 3500);
1183
+ }
1184
+ document.addEventListener('ws-assistant-working', (e) => {
1185
+ if (!avatar) return;
1186
+ if (e.detail && e.detail.on) startStatus(); else stopStatus(Boolean(e.detail && e.detail.failed));
1187
+ });
1188
+ document.addEventListener('ws-assistant-reply', (e) => {
1189
+ if (!root.classList.contains('assistant-avatar-on')) return;
1190
+ const d = (e.detail && e.detail.directive) || {};
1191
+ if (avatar) avatar.setThinking(false);
1192
+ if (d.move) place(d.move);
1193
+ if (d.size) resize(d.size);
1194
+ lastActivity = Date.now();
1195
+ if (d.board) write(d.board);
1196
+ if (d.draw) draw(d.draw);
1197
+ if (d.show && d.show.video) showVideo(d.show.video);
1198
+ if (d.browse) browse(d.browse);
1199
+ for (const c of d.browseCommands || []) browserCommand(c);
1200
+ for (const c of d.videoCommands || []) controlVideo(c);
1201
+ if (d.search) search(d.search);
1202
+ const moves = d.gestures && d.gestures.length ? d.gestures
1203
+ : [d.navigate || d.open || d.browse ? 'point' : d.draw || d.board ? 'explain' : e.detail && e.detail.voice ? 'explain' : 'nod'];
1204
+ play(moves);
1205
+ flashChips();
1206
+ });
1207
+ document.addEventListener('ws-assistant-speech', (e) => {
1208
+ if (!avatar || !e.detail) return;
1209
+ if (e.detail.type === 'start') avatar.setSpeaking(true);
1210
+ else if (e.detail.type === 'word') { avatar.setSpeaking(true); avatar.word(e.detail.word); }
1211
+ else avatar.setSpeaking(false);
1212
+ });
1213
+ // Some voices never say where each word is: the mouth then keeps a gentle rhythm while they speak.
1214
+ setInterval(() => {
1215
+ if (avatar && window.speechSynthesis && window.speechSynthesis.speaking) avatar.word('abcde'.charAt(Math.floor(Math.random() * 5)));
1216
+ }, 220);
1217
+
1218
+ const on = root.classList.contains('assistant-avatar-on');
1219
+ paintToggle(on);
1220
+ paintMute();
1221
+ paintChips();
1222
+ if (on) { popOut(); mount(false); }
1223
+ })();