@worca/app 1.0.0 → 1.2.0-rc.1

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 (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,270 @@
1
+ // Bounded, DOM-free syntax highlighting helpers for the History diff viewer.
2
+
3
+ export const MAX_HIGHLIGHT_INPUT_BYTES = 100_000;
4
+ export const MAX_HIGHLIGHT_INPUT_ROWS = 3_000;
5
+ export const MAX_HIGHLIGHT_OUTPUT_CHARS = 4_000_000;
6
+ export const MAX_HIGHLIGHT_OUTPUT_ROWS = 3_000;
7
+ export const MAX_HIGHLIGHT_OUTPUT_SPANS = 20_000;
8
+ export const MAX_HIGHLIGHT_NESTING = 64;
9
+
10
+ const BY_EXT = Object.freeze({
11
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
12
+ ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
13
+ json: 'json', jsonc: 'json', json5: 'json',
14
+ css: 'css', scss: 'scss', sass: 'scss', less: 'less',
15
+ html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml', vue: 'xml',
16
+ md: 'markdown', markdown: 'markdown',
17
+ yml: 'yaml', yaml: 'yaml', toml: 'ini', ini: 'ini', properties: 'properties',
18
+ sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
19
+ py: 'python', rb: 'ruby', php: 'php', go: 'go', rs: 'rust',
20
+ java: 'java', kt: 'kotlin', kts: 'kotlin', swift: 'swift', scala: 'scala',
21
+ c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
22
+ cs: 'csharp', sql: 'sql', graphql: 'graphql', gql: 'graphql',
23
+ proto: 'protobuf', lua: 'lua', pl: 'perl', r: 'r', dart: 'dart',
24
+ ex: 'elixir', exs: 'elixir', erl: 'erlang', hs: 'haskell', clj: 'clojure',
25
+ diff: 'diff', patch: 'diff', nix: 'nix', gradle: 'gradle',
26
+ });
27
+
28
+ const BY_NAME = Object.freeze({
29
+ dockerfile: 'dockerfile', makefile: 'makefile', gemfile: 'ruby',
30
+ rakefile: 'ruby', vagrantfile: 'ruby', brewfile: 'ruby',
31
+ });
32
+
33
+ const codeUnitCompare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
34
+ export const SUPPORTED_LANGUAGE_IDS = Object.freeze(
35
+ [...new Set([...Object.values(BY_EXT), ...Object.values(BY_NAME)])].sort(codeUnitCompare),
36
+ );
37
+ const SUPPORTED = new Set(SUPPORTED_LANGUAGE_IDS);
38
+
39
+ export function langForPath(pathname) {
40
+ const base = String(pathname || '').split('/').pop() || '';
41
+ const nameKey = base.toLowerCase();
42
+ if (Object.hasOwn(BY_NAME, nameKey)) return BY_NAME[nameKey];
43
+ const dot = base.lastIndexOf('.');
44
+ if (dot <= 0) return null;
45
+ const extKey = base.slice(dot + 1).toLowerCase();
46
+ return Object.hasOwn(BY_EXT, extKey) ? BY_EXT[extKey] : null;
47
+ }
48
+
49
+ const HLJS_CLASS_RE = /^(?:hljs-[A-Za-z0-9_-]+(?: [A-Za-z0-9-]+_+)*|language-[A-Za-z0-9_-]+)$/;
50
+ const OPEN_RE = /<span class="([^"]+)">/y;
51
+ const ENTITY_RE = /&(amp|lt|gt|quot|#x27);/y;
52
+ const TEXT_RE = /[^<&\n\u0000]+/y;
53
+ const ENTITIES = Object.freeze({
54
+ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#x27;': "'",
55
+ });
56
+
57
+ function parseHighlightedRows(html) {
58
+ if (typeof html !== 'string' || html.length > MAX_HIGHLIGHT_OUTPUT_CHARS) return null;
59
+ const rows = [{ html: '', text: '' }];
60
+ const stack = [];
61
+ let i = 0;
62
+ let renderedSpans = 0;
63
+ let balancedChars = 0;
64
+
65
+ const row = () => rows[rows.length - 1];
66
+ const reopen = () => stack.map((cls) => `<span class="${cls}">`).join('');
67
+ const appendHtml = (value) => {
68
+ balancedChars += value.length;
69
+ if (balancedChars > MAX_HIGHLIGHT_OUTPUT_CHARS) return false;
70
+ row().html += value;
71
+ return true;
72
+ };
73
+
74
+ while (i < html.length) {
75
+ if (html.startsWith('</span>', i)) {
76
+ if (!stack.length || !appendHtml('</span>')) return null;
77
+ stack.pop();
78
+ i += 7;
79
+ continue;
80
+ }
81
+ if (html[i] === '<') {
82
+ OPEN_RE.lastIndex = i;
83
+ const match = OPEN_RE.exec(html);
84
+ if (!match || !HLJS_CLASS_RE.test(match[1])) return null;
85
+ renderedSpans += 1;
86
+ if (renderedSpans > MAX_HIGHLIGHT_OUTPUT_SPANS
87
+ || stack.length >= MAX_HIGHLIGHT_NESTING) return null;
88
+ if (!appendHtml(match[0])) return null;
89
+ stack.push(match[1]);
90
+ i += match[0].length;
91
+ continue;
92
+ }
93
+ if (html[i] === '\n') {
94
+ if (!appendHtml('</span>'.repeat(stack.length))) return null;
95
+ if (rows.length >= MAX_HIGHLIGHT_OUTPUT_ROWS) return null;
96
+ const opened = reopen();
97
+ renderedSpans += stack.length;
98
+ if (renderedSpans > MAX_HIGHLIGHT_OUTPUT_SPANS) return null;
99
+ balancedChars += opened.length;
100
+ if (balancedChars > MAX_HIGHLIGHT_OUTPUT_CHARS) return null;
101
+ rows.push({ html: opened, text: '' });
102
+ i += 1;
103
+ continue;
104
+ }
105
+ if (html[i] === '&') {
106
+ ENTITY_RE.lastIndex = i;
107
+ const match = ENTITY_RE.exec(html);
108
+ if (!match || !appendHtml(match[0])) return null;
109
+ row().text += ENTITIES[match[0]];
110
+ i += match[0].length;
111
+ continue;
112
+ }
113
+ TEXT_RE.lastIndex = i;
114
+ const match = TEXT_RE.exec(html);
115
+ if (!match) return null;
116
+ const serialized = match[0].replaceAll('\r', '&#13;');
117
+ if (!appendHtml(serialized)) return null;
118
+ row().text += match[0];
119
+ i += match[0].length;
120
+ }
121
+
122
+ if (stack.length) return null;
123
+ return { rows, balancedChars, renderedSpans };
124
+ }
125
+
126
+ export function rowsFromHtml(html) {
127
+ const parsed = parseHighlightedRows(html);
128
+ return parsed ? parsed.rows.map((row) => row.html) : null;
129
+ }
130
+
131
+ const encoder = new TextEncoder();
132
+
133
+ function sideInputs(hunk) {
134
+ const oldLines = [];
135
+ const newLines = [];
136
+ for (const line of hunk.lines || []) {
137
+ if (line.kind !== 'add') oldLines.push(line);
138
+ if (line.kind !== 'del') newLines.push(line);
139
+ }
140
+ return {
141
+ old: { lines: oldLines, text: oldLines.map((line) => line.text).join('\n') },
142
+ new: { lines: newLines, text: newLines.map((line) => line.text).join('\n') },
143
+ };
144
+ }
145
+
146
+ function validSideRange(start, count) {
147
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(count) || count < 0) return false;
148
+ if (count === 0) return start >= 0;
149
+ return start >= 1 && start <= Number.MAX_SAFE_INTEGER - (count - 1);
150
+ }
151
+
152
+ function eligibleSides(hunk) {
153
+ if (!validSideRange(hunk?.oldStart, hunk?.oldCount)
154
+ || !validSideRange(hunk?.newStart, hunk?.newCount)) return null;
155
+ const sides = sideInputs(hunk);
156
+ if (sides.old.lines.length !== hunk.oldCount
157
+ || sides.new.lines.length !== hunk.newCount) return null;
158
+ return sides;
159
+ }
160
+
161
+ function withinInputBudget(eligible) {
162
+ let rows = 0;
163
+ let bytes = 0;
164
+ for (const sides of eligible) {
165
+ for (const side of [sides.old, sides.new]) {
166
+ rows += side.lines.length;
167
+ bytes += encoder.encode(side.text).byteLength;
168
+ if (rows > MAX_HIGHLIGHT_INPUT_ROWS || bytes > MAX_HIGHLIGHT_INPUT_BYTES) return false;
169
+ }
170
+ }
171
+ return rows > 0;
172
+ }
173
+
174
+ function eligibleInputs(parsed) {
175
+ return (parsed?.hunks || []).map(eligibleSides).filter(Boolean);
176
+ }
177
+
178
+ export function canHighlightParsed(parsed) {
179
+ return withinInputBudget(eligibleInputs(parsed));
180
+ }
181
+
182
+ const newOutputBudget = () => ({
183
+ rawChars: 0, balancedChars: 0, rows: 0, spans: 0, exhausted: false,
184
+ });
185
+
186
+ function countOutputRows(html) {
187
+ let rows = 1;
188
+ for (let i = html.indexOf('\n'); i !== -1; i = html.indexOf('\n', i + 1)) rows += 1;
189
+ return rows;
190
+ }
191
+
192
+ function highlightEligibleHunk(hunk, { old, new: next }, lang, highlight, budget) {
193
+ const run = (side) => {
194
+ if (!side.lines.length) return [];
195
+ if (budget.exhausted) return null;
196
+ if (budget.rawChars >= MAX_HIGHLIGHT_OUTPUT_CHARS
197
+ || budget.balancedChars >= MAX_HIGHLIGHT_OUTPUT_CHARS
198
+ || budget.rows >= MAX_HIGHLIGHT_OUTPUT_ROWS
199
+ || budget.spans >= MAX_HIGHLIGHT_OUTPUT_SPANS) {
200
+ budget.exhausted = true;
201
+ return null;
202
+ }
203
+ const html = highlight(side.text, lang);
204
+ if (typeof html !== 'string') return null;
205
+ budget.rawChars += html.length;
206
+ if (budget.rawChars > MAX_HIGHLIGHT_OUTPUT_CHARS) {
207
+ budget.exhausted = true;
208
+ return null;
209
+ }
210
+ const outputRows = countOutputRows(html);
211
+ budget.rows += outputRows;
212
+ if (budget.rows > MAX_HIGHLIGHT_OUTPUT_ROWS) {
213
+ budget.exhausted = true;
214
+ return null;
215
+ }
216
+ const parsed = parseHighlightedRows(html);
217
+ if (!parsed || parsed.rows.length !== outputRows) return null;
218
+ budget.balancedChars += parsed.balancedChars;
219
+ budget.spans += parsed.renderedSpans;
220
+ if (budget.balancedChars > MAX_HIGHLIGHT_OUTPUT_CHARS
221
+ || budget.spans > MAX_HIGHLIGHT_OUTPUT_SPANS) {
222
+ budget.exhausted = true;
223
+ return null;
224
+ }
225
+ if (parsed.rows.length !== side.lines.length) return null;
226
+ if (parsed.rows.some((row, index) => row.text !== side.lines[index].text)) return null;
227
+ return parsed.rows;
228
+ };
229
+
230
+ try {
231
+ const oldRows = run(old);
232
+ if (oldRows === null) return false;
233
+ const newRows = run(next);
234
+ if (newRows === null) return false;
235
+ const staged = [];
236
+ old.lines.forEach((line, index) => {
237
+ if (line.kind === 'del') staged.push([line, oldRows[index].html]);
238
+ });
239
+ next.lines.forEach((line, index) => staged.push([line, newRows[index].html]));
240
+ for (const [line, html] of staged) line.html = html;
241
+ return staged.length > 0;
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ export function highlightHunk(hunk, lang, highlight) {
248
+ for (const line of hunk?.lines || []) delete line.html;
249
+ const sides = eligibleSides(hunk);
250
+ if (!sides || !SUPPORTED.has(lang) || typeof highlight !== 'function'
251
+ || !withinInputBudget([sides])) return false;
252
+ return highlightEligibleHunk(hunk, sides, lang, highlight, newOutputBudget());
253
+ }
254
+
255
+ export function highlightParsed(parsed, lang, highlight) {
256
+ for (const hunk of parsed?.hunks || []) {
257
+ for (const line of hunk.lines || []) delete line.html;
258
+ }
259
+ if (!SUPPORTED.has(lang) || typeof highlight !== 'function') return false;
260
+ const eligible = (parsed?.hunks || [])
261
+ .map((hunk) => ({ hunk, sides: eligibleSides(hunk) }))
262
+ .filter((item) => item.sides);
263
+ if (!withinInputBudget(eligible.map((item) => item.sides))) return false;
264
+ const budget = newOutputBudget();
265
+ let any = false;
266
+ for (const { hunk, sides } of eligible) {
267
+ any = highlightEligibleHunk(hunk, sides, lang, highlight, budget) || any;
268
+ }
269
+ return any;
270
+ }
@@ -0,0 +1,110 @@
1
+ // ui/public/thinking-orb.mjs — the dotted sphere that marks a live Ask turn.
2
+ // A factory, not a custom element: the panel hands in doc/win (spec §10.1), so
3
+ // the jsdom suites drive it with no globals and no canvas backend. Geometry is
4
+ // exported pure — the drawing loop is the only part a browser is needed for.
5
+
6
+ export const ORB_DOTS = 46;
7
+ export const ORB_TILT = 0.38; // fixed nod, no wobble
8
+ export const ORB_SPEED = 0.17; // turns per second
9
+
10
+ /** Fibonacci sphere: ORB_DOTS unit vectors, one even latitude band per dot. */
11
+ export function orbPoints(n = ORB_DOTS) {
12
+ const pts = [];
13
+ for (let i = 0; i < n; i++) {
14
+ const y = 1 - ((i + 0.5) / n) * 2;
15
+ const r = Math.sqrt(Math.max(1 - y * y, 0));
16
+ const th = i * Math.PI * (3 - Math.sqrt(5));
17
+ pts.push([Math.cos(th) * r, y, Math.sin(th) * r]);
18
+ }
19
+ return pts;
20
+ }
21
+
22
+ /**
23
+ * Spin `pts` by `t` seconds about Y, nod by `tilt` about X, and return them
24
+ * sorted back-to-front so the caller can paint straight down the array.
25
+ */
26
+ export function orbFrame(pts, t, { speed = ORB_SPEED, tilt = ORB_TILT } = {}) {
27
+ const a = t * speed * Math.PI * 2;
28
+ const ca = Math.cos(a), sa = Math.sin(a);
29
+ const cT = Math.cos(tilt), sT = Math.sin(tilt);
30
+ const out = [];
31
+ for (let i = 0; i < pts.length; i++) {
32
+ const p = pts[i];
33
+ const x = p[0] * ca + p[2] * sa;
34
+ const z0 = p[2] * ca - p[0] * sa;
35
+ out.push([x, p[1] * cT - z0 * sT, z0 * cT + p[1] * sT]);
36
+ }
37
+ out.sort((m, n) => m[2] - n[2]);
38
+ return out;
39
+ }
40
+
41
+ /**
42
+ * @returns {{el: Element, start: () => void, stop: () => void}} — `el` is a
43
+ * bare inline-block span. It survives being re-parented (the panel moves the
44
+ * one orb into each rebuilt live row) because the phase is `now - t0`, not a
45
+ * frame counter, so a move never rewinds the spin.
46
+ */
47
+ export function createThinkingOrb({ doc, win, size = 28.5, ink = '25,25,27' }) {
48
+ const el = doc.createElement('span');
49
+ el.className = 'ask-orb';
50
+ el.setAttribute('aria-hidden', 'true');
51
+ el.style.cssText = `display:inline-block;line-height:0;width:${size}px;height:${size}px`;
52
+
53
+ const raf = typeof win.requestAnimationFrame === 'function' ? win.requestAnimationFrame.bind(win) : null;
54
+ const caf = typeof win.cancelAnimationFrame === 'function' ? win.cancelAnimationFrame.bind(win) : null;
55
+ const clock = win.performance && typeof win.performance.now === 'function'
56
+ ? () => win.performance.now()
57
+ : () => Date.now();
58
+
59
+ // No rAF means nothing can ever animate, so skip the canvas entirely rather
60
+ // than paint one still frame — that also keeps jsdom's "getContext without
61
+ // the canvas npm package" warning out of every ask-panel suite.
62
+ let ctx = null;
63
+ if (raf) {
64
+ const dpr = Math.min(win.devicePixelRatio || 1, 2);
65
+ const cv = doc.createElement('canvas');
66
+ cv.width = Math.round(size * dpr);
67
+ cv.height = Math.round(size * dpr);
68
+ cv.style.cssText = `width:${size}px;height:${size}px;display:block;pointer-events:none`;
69
+ el.appendChild(cv);
70
+ try { ctx = cv.getContext('2d'); } catch { ctx = null; }
71
+ if (ctx) ctx.scale(dpr, dpr);
72
+ else cv.remove();
73
+ }
74
+
75
+ const pts = orbPoints();
76
+ const R = size * 0.40;
77
+ const c = size / 2;
78
+ let reduced = false;
79
+ try { reduced = !!(win.matchMedia && win.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch { reduced = false; }
80
+ const speed = ORB_SPEED * (reduced ? 0.3 : 1);
81
+ const t0 = clock();
82
+ let handle = null;
83
+
84
+ function draw() {
85
+ handle = raf(draw);
86
+ if (doc.hidden) return;
87
+ ctx.clearRect(0, 0, size, size);
88
+ const frame = orbFrame(pts, (clock() - t0) / 1000, { speed, tilt: ORB_TILT });
89
+ for (let k = 0; k < frame.length; k++) {
90
+ const [x, y, z] = frame[k];
91
+ const d = (z + 1) / 2; // 0 back … 1 front
92
+ ctx.beginPath();
93
+ ctx.arc(c + x * R, c - y * R, 0.5 + d * 1.05, 0, 6.2832);
94
+ ctx.fillStyle = `rgba(${ink},${(0.08 + d * 0.82).toFixed(3)})`;
95
+ ctx.fill();
96
+ }
97
+ }
98
+
99
+ function start() {
100
+ if (handle != null || !ctx || !raf) return;
101
+ handle = raf(draw);
102
+ }
103
+ function stop() {
104
+ if (handle != null && caf) caf(handle);
105
+ handle = null;
106
+ }
107
+
108
+ start();
109
+ return { el, start, stop };
110
+ }