@worca/app 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3715 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1359 -214
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +423 -25
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1450 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1282 -193
@@ -0,0 +1,533 @@
1
+ // ui/public/team-metrics-view.mjs
2
+ // Team metrics page renderers (team-metrics-design.md §4.10; mockup boards 1–3). Pure DOM:
3
+ // every function takes an aggregate() result (src/shared/team-metrics/aggregate.mjs) and
4
+ // returns detached elements. app.js owns fetch, mounting and delegated events.
5
+ import { niceScale, roundedTopBar } from './stats-view.mjs';
6
+ import { safeHttpUrl } from '../../src/shared/team-metrics/aggregate.mjs';
7
+
8
+ const SVG_NS = 'http://www.w3.org/2000/svg';
9
+ const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
10
+ const MONTH_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
11
+ /** Series palette (mockup legend order): blue-ink, amber-ink, violet-ink, green-ink, red-ink, ink-3. */
12
+ export const SERIES_COLORS = ['var(--blue-ink)', 'var(--amber-ink)', 'var(--violet-ink)', 'var(--green-ink)', 'var(--red-ink)', 'var(--ink-3)'];
13
+ const RESULT_STACKS = [
14
+ { key: 'done', label: 'Done', color: 'var(--green-ink)' },
15
+ { key: 'failed', label: 'Failed', color: 'var(--red-ink)' },
16
+ { key: 'stopped', label: 'Stopped', color: 'var(--ink-3)' },
17
+ ];
18
+ const PREV_WORD = { 'this-month': 'month', 'last-month': 'month', quarter: 'quarter', year: 'year', custom: 'period' };
19
+
20
+ export const TM_FMT = {
21
+ usd: (n) => `$${(Number(n) || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
22
+ duration: (ms) => {
23
+ if (ms == null) return '—';
24
+ const s = Math.round(ms / 1000);
25
+ if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
26
+ return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
27
+ },
28
+ pct: (v) => (v == null ? '—' : `${Math.round(v * 100)}%`),
29
+ day: (iso) => { const d = new Date(iso); return `${MO[d.getUTCMonth()]} ${d.getUTCDate()}`; },
30
+ };
31
+
32
+ function h(doc, tag, cls, text) {
33
+ const n = doc.createElement(tag);
34
+ if (cls) n.className = cls;
35
+ if (text != null) n.textContent = text;
36
+ return n;
37
+ }
38
+ function s(doc, tag, attrs = {}) {
39
+ const n = doc.createElementNS(SVG_NS, tag);
40
+ for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
41
+ return n;
42
+ }
43
+
44
+ export function renderScopeOptions(select, scopes, selectedId, { doc = globalThis.document } = {}) {
45
+ select.replaceChildren();
46
+ const groups = [['Projects', scopes.projects || []], ['Workspaces', scopes.workspaces || []]];
47
+ let any = false;
48
+ for (const [label, items] of groups) {
49
+ if (!items.length) continue;
50
+ const g = h(doc, 'optgroup'); g.label = label;
51
+ for (const it of items) {
52
+ const o = h(doc, 'option', null, it.label);
53
+ o.value = it.id;
54
+ if (it.recordedIn) o.title = `recorded in ${it.recordedIn}`;
55
+ g.append(o);
56
+ }
57
+ select.append(g);
58
+ any = true;
59
+ }
60
+ if (!any) {
61
+ const o = h(doc, 'option', null, 'Nothing is recording yet'); o.value = '';
62
+ select.append(o);
63
+ }
64
+ select.disabled = !any;
65
+ select.value = any ? selectedId : '';
66
+ }
67
+
68
+ function ago(iso, now) {
69
+ const ms = now - Date.parse(iso);
70
+ if (!Number.isFinite(ms)) return null;
71
+ const m = Math.round(ms / 60_000);
72
+ if (m < 1) return 'just now';
73
+ if (m < 60) return `${m} min ago`;
74
+ const hrs = Math.round(m / 60);
75
+ return hrs < 48 ? `${hrs} h ago` : `${Math.round(hrs / 24)} d ago`;
76
+ }
77
+
78
+ /** Topbar right slot: "Synced 2 min ago | 3 runs pending push | Refresh | Push now". */
79
+ /**
80
+ * `busy` (or refresh.pending from a deferred read): a fetch from origin is running after the
81
+ * response — the dot becomes a small spinner, the text leads with "Checking origin…" and Refresh
82
+ * is held, until the fetch's `changed` event reloads the page (docs/team-metrics.md "Loading").
83
+ */
84
+ export function renderSyncChip({ sync = [], stats = {}, refresh = null, fetchError = null }, { doc = globalThis.document, now = Date.now(), busy = false } = {}) {
85
+ const checking = !!busy || !!refresh?.pending;
86
+ const chip = h(doc, 'div', `sync-chip-inner${checking ? ' is-busy' : ''}`);
87
+ const pending = sync.reduce((a, x) => a + (x.pending || 0), 0);
88
+ const lastError = sync.map((x) => x.lastError).find(Boolean) || null;
89
+ const pushHintText = sync.map((x) => x.hint).find(Boolean) || null;
90
+ // Newest of BOTH stamps: `lastSyncAt || fetchedAt` showed the last push even when a newer fetch
91
+ // had just run, so the chip could claim "Synced 2 h ago" right after a successful refresh.
92
+ const synced = sync.flatMap((x) => [x.lastSyncAt, x.fetchedAt]).filter(Boolean).sort().pop() || null;
93
+ const tone = lastError || fetchError ? 'red' : pending ? 'amber' : 'green';
94
+ if (checking) { const sp = h(doc, 'span', 'tm-busy-spin'); sp.setAttribute('aria-hidden', 'true'); chip.append(sp); }
95
+ else chip.append(h(doc, 'span', `dot ${tone}`));
96
+ const txt = h(doc, 'span', 'sync-text');
97
+ if (checking) chip.setAttribute('aria-busy', 'true');
98
+ if (checking) txt.append(h(doc, 'span', 'tm-checking', 'Checking origin…'), synced ? ' · ' : '');
99
+ if (checking && !synced) { /* the first fetch: nothing older to date */ }
100
+ else txt.append(synced ? (checking ? 'synced ' : 'Synced ') : 'Not synced yet');
101
+ if (synced) txt.append(h(doc, 'b', null, ago(synced, now)));
102
+ if (pending) { txt.append(' | '); txt.append(h(doc, 'b', null, String(pending)), ` run${pending === 1 ? '' : 's'} pending push`); }
103
+ if (stats.unknownV) txt.append(` | ${stats.unknownV} records need a newer Worca`);
104
+ // Only when the throttle actually skipped the fetch: a throttled refresh that still did its
105
+ // regular 60 s fetch serves fresh data, and "refresh again in N s" would be a lie.
106
+ if (refresh?.limited && !refresh.fetched) txt.append(` | refresh again in ${Math.ceil(refresh.retryInMs / 1000)} s`);
107
+ chip.append(txt);
108
+ const refreshBtn = h(doc, 'button', `btn-ghost btn-mini tm-refresh${checking ? ' busy' : ''}`, 'Refresh'); refreshBtn.type = 'button';
109
+ if (checking) refreshBtn.disabled = true;
110
+ chip.append(refreshBtn);
111
+ // "Push now" exists only while there is something to push: with an empty outbox
112
+ // the flush is a no-op, and a permanently visible (disabled) button read as an
113
+ // action that was somehow unavailable rather than one that was not needed.
114
+ if (pending > 0) {
115
+ const pushBtn = h(doc, 'button', 'btn-ghost btn-mini tm-push-now', 'Push now'); pushBtn.type = 'button';
116
+ chip.append(pushBtn);
117
+ }
118
+ const err = lastError || fetchError;
119
+ if (err) {
120
+ // §4.7: the stderr verbatim. <pre> + white-space:pre-wrap, so a multi-line remote: block is
121
+ // readable instead of collapsed to its first line.
122
+ const e = h(doc, 'pre', 'hint mono tm-sync-error', String(err).trim());
123
+ e.title = String(err);
124
+ chip.append(e);
125
+ }
126
+ // §4.7 also asks for the "exempt worca-metrics" hint next to the stderr, as on the Projects cell.
127
+ if (pushHintText) chip.append(h(doc, 'small', 'hint tm-sync-hint', pushHintText));
128
+ return chip;
129
+ }
130
+
131
+ function deltaChip(doc, text, title) {
132
+ if (text == null) return null;
133
+ const c = h(doc, 'span', 'stat-delta', text);
134
+ if (title) c.title = title;
135
+ return c;
136
+ }
137
+ const signedPct = (v) => (v == null ? null : `${v >= 0 ? '+' : '−'}${Math.abs(Math.round(v * 100))}%`);
138
+
139
+ /** The six mockup tile glyphs, path data verbatim from boards 1–2 (`svg.stat-ico`). */
140
+ const TILE_ICONS = Object.freeze({
141
+ spend: ['M12 3v18M17 7.5c0-1.9-2.2-3-5-3s-5 1.1-5 3 2.2 2.6 5 3 5 1.1 5 3-2.2 3-5 3-5-1.1-5-3'],
142
+ runs: ['M5 20v-7M12 20V4M19 20v-11'],
143
+ costPerRun: ['circle:12,12,8', 'M12 8v8M9.5 10.5h5'],
144
+ duration: ['circle:12,12,8', 'M12 8v4l3 2'],
145
+ autonomy: ['M4 12h4l2-5 4 10 2-5h4'],
146
+ cycles: ['M3 12a9 9 0 1 0 3-6.7', 'M3 4v4h4'],
147
+ });
148
+ function tileIcon(doc, key) {
149
+ const svg = s(doc, 'svg', { class: 'stat-ico', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.9, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' });
150
+ for (const d of TILE_ICONS[key] || []) {
151
+ if (d.startsWith('circle:')) { const [cx, cy, r] = d.slice(7).split(','); svg.append(s(doc, 'circle', { cx, cy, r })); }
152
+ else svg.append(s(doc, 'path', { d }));
153
+ }
154
+ return svg;
155
+ }
156
+
157
+ function tile(doc, { label, chip, value, sub, meterPct = null, icon = null }) {
158
+ const card = h(doc, 'section', 'card stat-tile');
159
+ const lab = h(doc, 'div', 'stat-label');
160
+ if (icon) lab.append(tileIcon(doc, icon));
161
+ lab.append(h(doc, 'span', null, label));
162
+ if (chip) lab.append(chip);
163
+ card.append(lab, h(doc, 'div', 'stat-value mono', value));
164
+ if (meterPct != null) {
165
+ const m = h(doc, 'span', 'stat-meter');
166
+ const f = h(doc, 'span', 'stat-meter-fill'); f.style.width = `${Math.max(0, Math.min(100, meterPct))}%`;
167
+ m.append(f); card.append(m);
168
+ }
169
+ const subEl = h(doc, 'small', 'stat-sub');
170
+ for (const part of sub) subEl.append(typeof part === 'string' ? part : h(doc, 'b', null, part.b));
171
+ card.append(subEl);
172
+ return card;
173
+ }
174
+
175
+ export function renderTmKpiRow(agg, { doc = globalThis.document, now = Date.now(), scopeKind = 'project' } = {}) {
176
+ const k = agg.kpis;
177
+ const d = agg.deltas;
178
+ const prevTitle = PREV_WORD[agg.range.range] ? `vs prev. ${PREV_WORD[agg.range.range]}` : null;
179
+ const withTitle = (t) => (t && prevTitle ? `${t} ${prevTitle}` : t);
180
+ const row = h(doc, 'div', 'stat-row tm-kpis');
181
+ const month = MONTH_FULL[new Date(now).getUTCMonth()];
182
+ // spendThisMonthUsd is computed over the current calendar month regardless of the selected
183
+ // range, so it is only meaningful when "now" actually falls inside that range — e.g. for
184
+ // range=last-month every record predates the current month and the sub-line would always
185
+ // read "$0.00 so far in <this month>" under a correct, non-zero Spend headline.
186
+ const nowInRange = (agg.range.startMs == null || now >= agg.range.startMs) && (agg.range.endMs == null || now < agg.range.endMs);
187
+ row.append(tile(doc, {
188
+ icon: 'spend', label: 'Spend', chip: deltaChip(doc, d && withTitle(signedPct(d.spendPct))), value: TM_FMT.usd(k.spendUsd),
189
+ sub: scopeKind === 'workspace'
190
+ ? ['attributed to the workspace, not to single projects']
191
+ : nowInRange ? [{ b: TM_FMT.usd(k.spendThisMonthUsd) }, ` so far in ${month}`] : [],
192
+ }));
193
+ row.append(tile(doc, {
194
+ icon: 'runs', label: 'Runs', chip: deltaChip(doc, d && signedPct(d.runsPct)), value: String(k.runs),
195
+ sub: [{ b: String(k.done) }, ` done · ${k.failed} failed · ${k.stopped} stopped · `, { b: TM_FMT.pct(k.successRate) }, ' success'],
196
+ }));
197
+ const costSub = ['median ', { b: TM_FMT.usd(k.costPerRunMedianUsd) }, ` · P90 ${TM_FMT.usd(k.costPerRunP90Usd)}`];
198
+ if (k.runsWithPr) costSub.push(` · with a PR ${TM_FMT.usd(k.costPerRunWithPrUsd)}`);
199
+ row.append(tile(doc, { icon: 'costPerRun', label: 'Cost per run', chip: deltaChip(doc, d && signedPct(d.costPerRunPct)), value: k.costPerRunUsd == null ? '—' : TM_FMT.usd(k.costPerRunUsd), sub: costSub }));
200
+ row.append(tile(doc, {
201
+ icon: 'duration', label: 'Duration', chip: deltaChip(doc, d && signedPct(d.durationPct)), value: TM_FMT.duration(k.durationMedianMs),
202
+ sub: ['median wall-clock · ', { b: TM_FMT.duration(k.machineMs) }, ' machine time in total'],
203
+ }));
204
+ row.append(tile(doc, {
205
+ icon: 'autonomy', label: 'Autonomy', chip: deltaChip(doc, d && d.autonomyPts != null ? `${d.autonomyPts >= 0 ? '+' : '−'}${Math.abs(d.autonomyPts)} pts` : null),
206
+ value: TM_FMT.pct(k.autonomy), meterPct: k.autonomy == null ? 0 : k.autonomy * 100,
207
+ sub: ['active ÷ wall-clock · ', { b: k.interventionsPerRun == null ? '—' : String(k.interventionsPerRun) }, ' interventions per run'],
208
+ }));
209
+ row.append(tile(doc, {
210
+ icon: 'cycles', label: 'Review cycles', chip: deltaChip(doc, d && d.reviewCycles != null ? `${d.reviewCycles >= 0 ? '+' : '−'}${Math.abs(d.reviewCycles)}` : null),
211
+ value: k.reviewCyclesMean == null ? '—' : String(k.reviewCyclesMean),
212
+ sub: ['mean implement→review loops · ', { b: TM_FMT.pct(k.convergeInOneRate) }, ' converge in one'],
213
+ }));
214
+ return row;
215
+ }
216
+
217
+ const CW = 560, CH = 240, L = 44, R = 12, T = 18, B = 26;
218
+ const PW = CW - L - R, PH = CH - T - B;
219
+
220
+ /** Stacked weekly column chart. stacks: [{key,label,color}], valueOf(pt,key) → number. */
221
+ export function renderStackedWeekChart({ title, weeks, stacks, valueOf, yFmt, tipFmt = yFmt, legendValue, hint = null, integer = false, headExtra = null }, { doc = globalThis.document } = {}) {
222
+ const card = h(doc, 'section', 'card chart-card');
223
+ const head = h(doc, 'div', 'card-head');
224
+ head.append(h(doc, 'h2', null, title));
225
+ if (headExtra) head.append(headExtra); // a control that belongs to THIS chart (e.g. Group by)
226
+ card.append(head);
227
+ const legend = h(doc, 'div', 'chart-legend');
228
+ for (const st of stacks) {
229
+ const item = h(doc, 'span', 'lg-item');
230
+ const sw = h(doc, 'span', 'lg-swatch'); sw.style.background = st.color;
231
+ item.append(sw, `${st.label} `, h(doc, 'b', 'mono', legendValue(st)));
232
+ legend.append(item);
233
+ }
234
+ card.append(legend);
235
+ if (!weeks.length) { card.append(h(doc, 'div', 'chart-empty hint', 'No runs in this range')); return card; }
236
+ const totals = weeks.map((pt) => stacks.reduce((a, st) => a + (valueOf(pt, st.key) || 0), 0));
237
+ const { step, top } = niceScale(Math.max(...totals, 0), integer);
238
+ const yOf = (v) => T + PH - (v / top) * PH;
239
+ const svg = s(doc, 'svg', { class: 'chart-svg', viewBox: `0 0 ${CW} ${CH}`, 'aria-label': title });
240
+ for (let v = 0; v <= top + 1e-9; v += step) {
241
+ svg.append(s(doc, 'line', { x1: L, y1: yOf(v), x2: CW - R, y2: yOf(v), stroke: v === 0 ? 'var(--line-2)' : 'var(--line)', 'stroke-width': 1 }));
242
+ const t = s(doc, 'text', { x: L - 6, y: yOf(v) + 3.5, 'text-anchor': 'end', 'font-size': 10.5, fill: 'var(--ink-3)' });
243
+ // class="ch-ytick" as in stats-view.mjs chartScaffold (:415-455) — the Stats stylesheet already
244
+ // styles that class, and without it the new charts render their ticks unstyled.
245
+ t.setAttribute('class', 'ch-ytick');
246
+ t.textContent = yFmt(v); svg.append(t);
247
+ }
248
+ const band = PW / weeks.length;
249
+ const every = weeks.length <= 10 ? 1 : Math.ceil(weeks.length / 8);
250
+ weeks.forEach((pt, i) => {
251
+ const bx = L + i * band;
252
+ const w = Math.max(1, Math.min(24, band - 4)); // band - 4 goes negative past ~126 weeks ("All time")
253
+ let acc = 0;
254
+ const tipLines = [];
255
+ for (const st of stacks) {
256
+ const v = valueOf(pt, st.key) || 0;
257
+ if (v <= 0) continue;
258
+ const y0 = yOf(acc + v), y1 = yOf(acc);
259
+ const isTop = stacks.slice(stacks.indexOf(st) + 1).every((o) => !(valueOf(pt, o.key) > 0));
260
+ svg.append(s(doc, 'path', { d: isTop ? roundedTopBar(bx + (band - w) / 2, y0, w, y1 - y0) : `M${bx + (band - w) / 2},${y1} v${y0 - y1} h${w} v${y1 - y0} Z`, fill: st.color }));
261
+ acc += v;
262
+ // TM_FMT.usd, not yFmt: the axis formatter rounds to whole dollars for tick labels, so a
263
+ // $4.12 week read "Auto: $4" and anything under $0.50 read "$0". Ticks keep yFmt.
264
+ tipLines.push(`${st.label}: ${tipFmt(v)}`);
265
+ }
266
+ if (i % every === 0 || i === weeks.length - 1) {
267
+ const t = s(doc, 'text', { x: bx + band / 2, y: CH - 8, 'text-anchor': 'middle', 'font-size': 10.5, fill: 'var(--ink-3)' });
268
+ t.textContent = TM_FMT.day(new Date(pt.weekStartMs).toISOString()); svg.append(t);
269
+ }
270
+ const hit = s(doc, 'rect', { class: 'ch-hit', x: bx, y: T, width: band, height: PH, tabindex: 0, role: 'img', 'aria-label': `Week of ${TM_FMT.day(new Date(pt.weekStartMs).toISOString())}: ${tipLines.join(', ') || 'none'}` });
271
+ hit.dataset.tip = `Week of ${TM_FMT.day(new Date(pt.weekStartMs).toISOString())}\n${tipLines.join('\n') || 'No runs'}`;
272
+ svg.append(hit);
273
+ });
274
+ const fig = h(doc, 'figure', 'chart-fig'); fig.append(svg); card.append(fig);
275
+ // Parity with stats-view.mjs chartScaffold (:415-455): an sr-only table is the accessible
276
+ // equivalent of the bars. Without it the whole series is unreachable to a screen reader, which
277
+ // would be a regression against the page this one is modelled on.
278
+ const srt = h(doc, 'table', 'sr-only');
279
+ const srh = h(doc, 'tr');
280
+ for (const c of ['Week', ...stacks.map((st) => st.label)]) srh.append(h(doc, 'th', null, c));
281
+ srt.append(srh);
282
+ for (const pt of weeks) {
283
+ const tr = h(doc, 'tr');
284
+ tr.append(h(doc, 'td', null, TM_FMT.day(new Date(pt.weekStartMs).toISOString())));
285
+ for (const st of stacks) tr.append(h(doc, 'td', null, tipFmt(valueOf(pt, st.key) || 0)));
286
+ srt.append(tr);
287
+ }
288
+ card.append(srt);
289
+ if (hint) card.append(h(doc, 'small', 'hint', hint));
290
+ return card;
291
+ }
292
+
293
+ const BREAKDOWN_SPECS = {
294
+ workflow: { title: 'By workflow', sub: 'share of spend', cols: [['label', 'Workflow'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['successRate', 'Success'], ['share', '']] },
295
+ source: { title: 'By ticket', sub: 'task source · top 5', limit: 5, cols: [['label', 'Source'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['cyclesMean', 'Cycles']] },
296
+ actor: { title: 'By actor', sub: 'who ran it', cols: [['label', 'Actor'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['successRate', 'Success']] },
297
+ // No spend column: a run's cost is not a per-project fact. Runs touched and files changed are;
298
+ // the bar is the share of runs in range that touched the project. Cost questions go through
299
+ // the row filter, which narrows every panel to the runs that touched it.
300
+ project: { title: 'By project touched', sub: 'click a project to see only the runs that touched it', cols: [['label', 'Project'], ['runs', 'Runs touched'], ['filesChanged', 'Files changed'], ['runShare', '']] },
301
+ models: { title: 'By model mix', sub: 'models seen in the run', cols: [['label', 'Models'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run']] },
302
+ };
303
+
304
+ function cellText(key, row) {
305
+ switch (key) {
306
+ case 'usd': case 'perRunUsd': return row[key] == null ? '—' : TM_FMT.usd(row[key]);
307
+ case 'successRate': return TM_FMT.pct(row[key]);
308
+ case 'cyclesMean': case 'filesChanged': return row[key] == null ? '—' : String(row[key]);
309
+ default: return String(row[key] ?? '—');
310
+ }
311
+ }
312
+
313
+ export function renderBreakdownTable(dim, rows, { doc = globalThis.document, sort = null, activeKey = null, homeSlug = null } = {}) {
314
+ const spec = BREAKDOWN_SPECS[dim];
315
+ const card = h(doc, 'section', 'card chart-card tm-breakdown');
316
+ const head = h(doc, 'div', 'card-head');
317
+ head.append(h(doc, 'h2', null, spec.title), h(doc, 'small', 'hint', spec.sub));
318
+ card.append(head);
319
+ const table = h(doc, 'table', 'tm-tbl');
320
+ table.dataset.dim = dim;
321
+ const thr = h(doc, 'tr');
322
+ for (const [key, label] of spec.cols) {
323
+ const th = h(doc, 'th', key === 'label' ? null : 'num', label);
324
+ if (label) { th.dataset.sort = key; th.setAttribute('role', 'button'); th.tabIndex = 0; }
325
+ if (sort && sort.key === key) th.setAttribute('aria-sort', sort.dir === 'asc' ? 'ascending' : 'descending');
326
+ thr.append(th);
327
+ }
328
+ const thead = h(doc, 'thead');
329
+ thead.append(thr);
330
+ table.append(thead);
331
+ let sorted = [...rows];
332
+ if (sort && sort.key) {
333
+ const dir = sort.dir === 'asc' ? 1 : -1;
334
+ sorted.sort((a, b) => (a.isNone - b.isNone) || (a[sort.key] > b[sort.key] ? dir : a[sort.key] < b[sort.key] ? -dir : 0));
335
+ }
336
+ if (spec.limit) sorted = sorted.filter((r) => !r.isNone).slice(0, spec.limit).concat(sorted.filter((r) => r.isNone));
337
+ const tbody = h(doc, 'tbody');
338
+ for (const r of sorted) {
339
+ const tr = h(doc, 'tr', `${r.isNone ? 'dim' : ''}${activeKey === r.key ? ' on' : ''}`.trim() || null);
340
+ tr.dataset.filterDim = dim;
341
+ tr.dataset.filterKey = r.key;
342
+ tr.tabIndex = 0;
343
+ for (const [key] of spec.cols) {
344
+ const td = h(doc, 'td', key === 'label' ? null : 'num mono');
345
+ if (key === 'label') {
346
+ td.append(h(doc, 'span', null, r.label));
347
+ if (r.sub) td.append(' ', h(doc, 'small', 'hint', r.sub));
348
+ if (dim === 'project' && homeSlug && r.key === homeSlug) td.append(' ', h(doc, 'span', 'badge violet', 'metrics home'));
349
+ } else if (key === 'share' || key === 'runShare') {
350
+ const bar = h(doc, 'span', 'tm-share'); const fill = h(doc, 'span', 'tm-share-fill');
351
+ fill.style.width = `${Math.round((r[key] || 0) * 100)}%`; bar.append(fill); td.append(bar);
352
+ if (key === 'runShare') td.title = `${Math.round((r[key] || 0) * 100)}% of the runs in range touched it`;
353
+ } else td.textContent = cellText(key, r);
354
+ // Some runs in the row predate per-project file counts: the number covers the others.
355
+ if (key === 'filesChanged' && r.filesUnknown > 0 && r.filesChanged != null) td.title = `${r.filesUnknown} older run${r.filesUnknown === 1 ? '' : 's'} without per-project file counts not included`;
356
+ tr.append(td);
357
+ }
358
+ tbody.append(tr);
359
+ }
360
+ if (!sorted.length) {
361
+ // `.hint{display:block}` (style.css:355) on a <td> takes the cell out of table layout and the
362
+ // row collapses. Keep the class on an inner <small>.
363
+ const tr = h(doc, 'tr'); const td = h(doc, 'td'); td.colSpan = spec.cols.length;
364
+ td.append(h(doc, 'small', 'hint', 'No runs in this range')); tr.append(td); tbody.append(tr);
365
+ }
366
+ table.append(tbody);
367
+ card.append(table);
368
+ return card;
369
+ }
370
+
371
+ export function renderRunsTable(runs, { doc = globalThis.document, total = runs.length, limit = 50 } = {}) {
372
+ const card = h(doc, 'section', 'card tm-runs');
373
+ const head = h(doc, 'div', 'card-head tm-runs-head');
374
+ const title = h(doc, 'h2', null, 'Runs ');
375
+ title.append(h(doc, 'small', 'hint', `${total} in range`));
376
+ const exp = h(doc, 'button', 'btn-ghost btn-mini tm-export', 'Export CSV'); exp.type = 'button'; exp.disabled = total === 0;
377
+ head.append(title, exp);
378
+ card.append(head);
379
+ if (!runs.length) { card.append(h(doc, 'div', 'chart-empty hint', 'No runs recorded in this range')); return card; }
380
+ const table = h(doc, 'table', 'tm-tbl tm-run-tbl');
381
+ const thead = h(doc, 'thead'); const thr = h(doc, 'tr');
382
+ for (const c of ['Title', 'Workflow', 'Result', 'Cost', 'Duration', 'Cycles', 'PR', 'Actor', 'Started']) thr.append(h(doc, 'th', null, c));
383
+ thead.append(thr); table.append(thead);
384
+ const tbody = h(doc, 'tbody');
385
+ for (const r of runs.slice(0, limit)) {
386
+ const tr = h(doc, 'tr');
387
+ tr.append(h(doc, 'td', 'tm-title', r.title));
388
+ tr.append(h(doc, 'td', null, r.workflow ?? '—'));
389
+ const res = h(doc, 'td'); res.append(h(doc, 'span', `badge ${r.result === 'done' ? 'green' : r.result === 'failed' ? 'red' : 'grey'}`, r.result)); tr.append(res);
390
+ tr.append(h(doc, 'td', 'mono num', TM_FMT.usd(r.usd)));
391
+ tr.append(h(doc, 'td', 'mono num', TM_FMT.duration(r.wallMs)));
392
+ tr.append(h(doc, 'td', 'mono num', r.reviewCycles == null ? '—' : String(r.reviewCycles)));
393
+ const pr = h(doc, 'td', 'mono');
394
+ const href = safeHttpUrl(r.pr?.url); // defence in depth: toRunRow already dropped non-http(s) URLs
395
+ if (href) { const a = h(doc, 'a', null, r.pr.number != null ? `#${r.pr.number}` : 'PR'); a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer'; pr.append(a); }
396
+ else pr.textContent = r.pr?.number != null ? `#${r.pr.number}` : '—';
397
+ tr.append(pr);
398
+ tr.append(h(doc, 'td', null, r.actor ?? '—'));
399
+ tr.append(h(doc, 'td', 'mono', TM_FMT.day(r.startedAt)));
400
+ tbody.append(tr);
401
+ }
402
+ table.append(tbody); card.append(table);
403
+ const shown = Math.min(limit, runs.length);
404
+ const foot = h(doc, 'div', 'tm-runs-foot');
405
+ foot.append(h(doc, 'small', 'hint', `Showing ${shown} of ${total} · newest first`));
406
+ // §4.9 asks for "every record in range". The mockup's first paint is capped for layout, so the
407
+ // remaining rows must stay REACHABLE on screen, not only through Export CSV.
408
+ if (shown < runs.length) {
409
+ const more = h(doc, 'button', 'btn-ghost btn-mini tm-show-all', `Show all ${runs.length} runs`);
410
+ more.type = 'button';
411
+ foot.append(more);
412
+ }
413
+ card.append(foot);
414
+ return card;
415
+ }
416
+
417
+ /**
418
+ * "Group by" lives in the Spend per week card head, not the page filter bar: it only
419
+ * changes that chart's stacking (Runs per week always stacks by result, the breakdown
420
+ * tables have fixed dimensions), so next to scope and range it read as a page setting
421
+ * it is not. `project` is offered for workspace scopes only — on a single project it is
422
+ * always one bar.
423
+ */
424
+ export function renderGroupBySelect(groupBy, scopeKind, { doc = globalThis.document } = {}) {
425
+ const label = h(doc, 'label', 'tm-group');
426
+ label.append('Group by ');
427
+ const wrap = h(doc, 'span', 'select-wrap');
428
+ const sel = h(doc, 'select', 'select select-sm');
429
+ sel.id = 'tm-group';
430
+ sel.setAttribute('aria-label', 'Stack Spend per week by');
431
+ // The same three for both scope kinds: spend is never split across the projects a run touched.
432
+ const opts = ['workflow', 'result', 'actor'];
433
+ for (const v of opts) { const o = h(doc, 'option', null, v); o.value = v; o.selected = v === groupBy; sel.append(o); }
434
+ wrap.append(sel);
435
+ label.append(wrap);
436
+ return label;
437
+ }
438
+
439
+ export function renderTeamMetricsBody(agg, { doc = globalThis.document, now = Date.now(), scopeKind = 'project', sort = {}, filter = {}, homeHint = null, runLimit = 50 } = {}) {
440
+ const wrap = h(doc, 'div', 'tm-body');
441
+ if (homeHint) wrap.append(homeHint);
442
+ wrap.append(renderTmKpiRow(agg, { doc, now, scopeKind }));
443
+ const charts = h(doc, 'div', 'charts-grid');
444
+ const stacks = agg.series.stackKeys.map((st, i) => ({ ...st, color: SERIES_COLORS[Math.min(i, SERIES_COLORS.length - 1)] }));
445
+ charts.append(renderStackedWeekChart({
446
+ title: 'Spend per week', weeks: agg.series.spend, stacks, valueOf: (pt, key) => pt.stacks[key],
447
+ yFmt: (v) => (v >= 1000 ? `$${(v / 1000).toFixed(1)}k` : `$${Math.round(v)}`), tipFmt: TM_FMT.usd, legendValue: (st) => TM_FMT.usd(st.totalUsd),
448
+ headExtra: renderGroupBySelect(agg.groupBy, scopeKind, { doc }),
449
+ }, { doc }));
450
+ const runTotals = Object.fromEntries(RESULT_STACKS.map((r) => [r.key, agg.series.runs.reduce((a, p) => a + (p[r.key] || 0), 0)]));
451
+ charts.append(renderStackedWeekChart({
452
+ title: 'Runs per week', weeks: agg.series.runs, stacks: RESULT_STACKS, valueOf: (pt, key) => pt[key],
453
+ yFmt: (v) => String(Math.round(v)), legendValue: (st) => String(runTotals[st.key]), integer: true,
454
+ }, { doc }));
455
+ wrap.append(charts);
456
+ const tables = h(doc, 'div', 'charts-grid tm-breakdowns');
457
+ const dims = scopeKind === 'workspace' ? ['project', 'workflow', 'source', 'actor', 'models'] : ['workflow', 'source', 'actor', 'models'];
458
+ for (const dim of dims) {
459
+ const rows = agg.breakdowns[dim];
460
+ if (!rows) continue;
461
+ tables.append(renderBreakdownTable(dim, rows, { doc, sort: sort[dim] || null, activeKey: filter[dim] ?? null, homeSlug: agg.homeSlug ?? null }));
462
+ }
463
+ wrap.append(tables);
464
+ wrap.append(renderRunsTable(agg.runs, { doc, total: agg.runs.length, limit: runLimit }));
465
+ return wrap;
466
+ }
467
+
468
+ /**
469
+ * The page's shape while its first payload loads: six KPI tiles, two chart cards, a breakdown
470
+ * grid and the runs card, each filled with shimmer bars instead of "Loading…", so the layout
471
+ * lands once and nothing jumps when the data arrives. Purely decorative (aria-hidden); the
472
+ * live region is #tm-body's aria-busy.
473
+ */
474
+ export function renderTmSkeleton({ doc = globalThis.document, scopeKind = 'project' } = {}) {
475
+ const wrap = h(doc, 'div', 'tm-body tm-skeleton');
476
+ wrap.setAttribute('aria-hidden', 'true');
477
+ const bar = (w, extra = '') => h(doc, 'span', `skel${w ? ` ${w}` : ''}${extra ? ` ${extra}` : ''}`);
478
+ const row = h(doc, 'div', 'stat-row tm-kpis');
479
+ for (let i = 0; i < 6; i++) {
480
+ const t = h(doc, 'section', 'card stat-tile');
481
+ t.append(h(doc, 'div', 'stat-label').appendChild(bar('w40')).parentNode, h(doc, 'div', 'stat-value').appendChild(bar('w70', 'skel-lg')).parentNode, h(doc, 'small', 'stat-sub').appendChild(bar('w60')).parentNode);
482
+ row.append(t);
483
+ }
484
+ wrap.append(row);
485
+ const charts = h(doc, 'div', 'charts-grid');
486
+ for (let i = 0; i < 2; i++) {
487
+ const c = h(doc, 'section', 'card chart-card');
488
+ const head = h(doc, 'div', 'card-head'); head.append(h(doc, 'h2').appendChild(bar('w25')).parentNode);
489
+ c.append(head, h(doc, 'div', 'skel skel-block'));
490
+ charts.append(c);
491
+ }
492
+ wrap.append(charts);
493
+ const tables = h(doc, 'div', 'charts-grid tm-breakdowns');
494
+ for (let i = 0; i < (scopeKind === 'workspace' ? 5 : 4); i++) {
495
+ const c = h(doc, 'section', 'card');
496
+ const head = h(doc, 'div', 'card-head'); head.append(h(doc, 'h2').appendChild(bar('w25')).parentNode);
497
+ c.append(head);
498
+ for (let r = 0; r < 3; r++) { const line = h(doc, 'div', 'skel-row'); line.append(bar('w60'), bar('w25')); c.append(line); }
499
+ tables.append(c);
500
+ }
501
+ wrap.append(tables);
502
+ const runs = h(doc, 'section', 'card tm-runs');
503
+ const rh = h(doc, 'div', 'card-head tm-runs-head'); rh.append(h(doc, 'h2').appendChild(bar('w25')).parentNode);
504
+ runs.append(rh);
505
+ for (let r = 0; r < 5; r++) { const line = h(doc, 'div', 'skel-row'); line.append(bar('w25'), bar('w40'), bar('w25')); runs.append(line); }
506
+ wrap.append(runs);
507
+ return wrap;
508
+ }
509
+
510
+ export function renderTmEmptyState({ doc = globalThis.document } = {}) {
511
+ const grid = h(doc, 'div', 'empty tm-empty');
512
+ const card = (step, title, body, href, label, primary) => {
513
+ const c = h(doc, 'section', 'card');
514
+ c.append(h(doc, 'div', 'tm-step', step), h(doc, 'h3', null, title));
515
+ const p = h(doc, 'p', 'hint');
516
+ for (const part of body) p.append(typeof part === 'string' ? part : h(doc, 'code', 'mono', part.code));
517
+ const a = h(doc, 'a', primary ? 'btn btn-primary btn-mini' : 'btn btn-ghost btn-mini', label); a.href = href;
518
+ c.append(p, a);
519
+ return c;
520
+ };
521
+ grid.append(
522
+ card('01 · PROJECT', 'Enable team metrics on a project',
523
+ ['Creates a ', { code: 'worca-metrics' }, " branch on the project's origin. Every finished run is pushed there as one small file, from every teammate's machine. Nothing is added to your code branches."], '#projects', 'Go to Projects', true),
524
+ card('02 · WORKSPACE', 'Pick a metrics home for a workspace',
525
+ ['Workspace runs touch several repositories, so their cost is attributed to the workspace and written to one member you choose. The choice lives on this machine and can be changed on the workspace card.'], '#workspaces', 'Go to Workspaces', false),
526
+ );
527
+ const foot = h(doc, 'small', 'hint tm-empty-foot', "Already enabled by a teammate? Worca checks each project's origin for the branch on start and every hour. ");
528
+ const check = h(doc, 'button', 'linkish tm-check-now', 'Check now'); check.type = 'button';
529
+ foot.append(check);
530
+ const out = h(doc, 'div');
531
+ out.append(grid, foot);
532
+ return out;
533
+ }
@@ -39,10 +39,11 @@ export function orbFrame(pts, t, { speed = ORB_SPEED, tilt = ORB_TILT } = {}) {
39
39
  }
40
40
 
41
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.
42
+ * @returns {{el: Element, start: () => void, stop: () => void, morphTo: (f: number, ms?: number) => void, morph: () => number}}
43
+ * — `el` is a bare inline-block span. It survives being re-parented (the panel
44
+ * moves the one orb into each rebuilt live row) because the phase is `now - t0`,
45
+ * not a frame counter, so a move never rewinds the spin. `morphTo` drives the
46
+ * 0..1 morph factor the launcher pill uses to grow the sphere out of its mark.
46
47
  */
47
48
  export function createThinkingOrb({ doc, win, size = 28.5, ink = '25,25,27' }) {
48
49
  const el = doc.createElement('span');
@@ -72,6 +73,20 @@ export function createThinkingOrb({ doc, win, size = 28.5, ink = '25,25,27' }) {
72
73
  else cv.remove();
73
74
  }
74
75
 
76
+ // The ink follows the theme (spec D16): the resolved body colour is the --ink
77
+ // token in whatever scheme is active. Read on every start() and on the theme
78
+ // event app.js dispatches; keep the previous value whenever nothing parses
79
+ // (jsdom, a keyword, no stylesheet).
80
+ let inkRgb = ink;
81
+ function refreshInk() {
82
+ try {
83
+ const c = win.getComputedStyle ? win.getComputedStyle(doc.body).color : '';
84
+ const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(c || '');
85
+ if (m) inkRgb = `${m[1]},${m[2]},${m[3]}`;
86
+ } catch { /* keep inkRgb */ }
87
+ }
88
+ if (typeof doc.addEventListener === 'function') doc.addEventListener('worca:theme', refreshInk);
89
+
75
90
  const pts = orbPoints();
76
91
  const R = size * 0.40;
77
92
  const c = size / 2;
@@ -81,22 +96,45 @@ export function createThinkingOrb({ doc, win, size = 28.5, ink = '25,25,27' }) {
81
96
  const t0 = clock();
82
97
  let handle = null;
83
98
 
99
+ // Morph factor 0..1, scaling the sphere radius and every dot's alpha: at 0
100
+ // the dots sit invisible on the centre, at 1 the sphere is whole. The
101
+ // launcher pill grows the orb out of its mark with morphTo(1, ms) and sinks
102
+ // it back with morphTo(0, ms); the tween runs on the draw clock (ease-out
103
+ // cubic, from wherever the factor is), so no caller ticks it. Reduced motion
104
+ // snaps — the CSS half of that morph is an instant swap there too. The
105
+ // transcript's orb never calls it and paints at 1.
106
+ let morph = { from: 1, to: 1, t0: 0, dur: 0 };
107
+ function morphFactor(t) {
108
+ if (morph.dur <= 0) return morph.to;
109
+ const p = Math.min(1, Math.max(0, (t - morph.t0) / morph.dur));
110
+ return morph.from + (morph.to - morph.from) * (1 - (1 - p) ** 3);
111
+ }
112
+ function morphTo(target, durationMs = 0) {
113
+ const t = clock();
114
+ const to = Math.min(1, Math.max(0, Number(target) || 0));
115
+ morph = { from: morphFactor(t), to, t0: t, dur: reduced ? 0 : Math.max(0, Number(durationMs) || 0) };
116
+ }
117
+
84
118
  function draw() {
85
119
  handle = raf(draw);
86
120
  if (doc.hidden) return;
87
121
  ctx.clearRect(0, 0, size, size);
88
- const frame = orbFrame(pts, (clock() - t0) / 1000, { speed, tilt: ORB_TILT });
122
+ const t = clock();
123
+ const f = morphFactor(t);
124
+ const r = R * f;
125
+ const frame = orbFrame(pts, (t - t0) / 1000, { speed, tilt: ORB_TILT });
89
126
  for (let k = 0; k < frame.length; k++) {
90
127
  const [x, y, z] = frame[k];
91
128
  const d = (z + 1) / 2; // 0 back … 1 front
92
129
  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)})`;
130
+ ctx.arc(c + x * r, c - y * r, 0.5 + d * 1.05, 0, 6.2832);
131
+ ctx.fillStyle = `rgba(${inkRgb},${((0.08 + d * 0.82) * f).toFixed(3)})`;
95
132
  ctx.fill();
96
133
  }
97
134
  }
98
135
 
99
136
  function start() {
137
+ refreshInk();
100
138
  if (handle != null || !ctx || !raf) return;
101
139
  handle = raf(draw);
102
140
  }
@@ -106,5 +144,5 @@ export function createThinkingOrb({ doc, win, size = 28.5, ink = '25,25,27' }) {
106
144
  }
107
145
 
108
146
  start();
109
- return { el, start, stop };
147
+ return { el, start, stop, ink: () => inkRgb, morphTo, morph: () => morphFactor(clock()) };
110
148
  }