atris 3.71.3 → 3.72.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 (46) hide show
  1. package/atris/skills/campaigns/SKILL.md +1 -1
  2. package/bin/atris.js +7 -1
  3. package/commands/auth.js +29 -2
  4. package/commands/autoland.js +102 -2
  5. package/commands/campaign.js +86 -13
  6. package/commands/clean.js +60 -4
  7. package/commands/gm.js +1273 -7
  8. package/commands/member.js +28 -3
  9. package/commands/mission.js +72 -23
  10. package/commands/sync.js +2 -2
  11. package/commands/task.js +49 -13
  12. package/commands/zen.js +183 -0
  13. package/lib/auto-accept-certified.js +9 -4
  14. package/lib/campaign-cadence.js +15 -5
  15. package/lib/campaign-core.js +44 -17
  16. package/lib/campaign-loop.js +45 -14
  17. package/lib/campaign-reading.js +14 -1
  18. package/lib/campaign-rsi.js +28 -11
  19. package/lib/campaign-schedule.js +124 -0
  20. package/lib/campaign-store.js +34 -2
  21. package/lib/campaign-trace.js +40 -14
  22. package/lib/engine-resource.js +5 -1
  23. package/lib/gm-bet.js +2219 -0
  24. package/lib/gm-phone-page.js +1102 -0
  25. package/lib/gm-phone.js +682 -0
  26. package/lib/gm-season.js +1162 -0
  27. package/lib/gm-target.js +292 -0
  28. package/lib/job-code-verify.js +169 -0
  29. package/lib/known-commands.js +1 -1
  30. package/lib/member-autoland.js +105 -14
  31. package/lib/member-progress.js +2 -2
  32. package/lib/member-report.js +11 -1
  33. package/lib/member-self-next-job.js +6 -1
  34. package/lib/member-up.js +17 -8
  35. package/lib/merged-review-close.js +206 -0
  36. package/lib/mission-protected-lane.js +52 -4
  37. package/lib/qr.js +338 -0
  38. package/lib/site-publish.js +16 -2
  39. package/lib/site.js +17 -7
  40. package/lib/yes-lock.js +530 -0
  41. package/lib/zen-apply.js +619 -0
  42. package/lib/zen-workspace.js +121 -0
  43. package/lib/zen.js +409 -0
  44. package/package.json +1 -1
  45. package/utils/api.js +15 -2
  46. package/utils/auth.js +8 -7
@@ -0,0 +1,1102 @@
1
+ 'use strict';
2
+
3
+ // The one page gm phone serves. Plain html, css, and vanilla js, no build
4
+ // step. Every piece of text from the task list goes in with textContent,
5
+ // never as html. The nonce matches the page's content security policy.
6
+ //
7
+ // One theme, the homepage letter's (atrisos-web LetterHome.tsx, dark values
8
+ // from app/config/theme-css.ts .dark): the
9
+ // brand colors, the letter serif, and the lava orb's amber as the only
10
+ // accent. The orb is a port of the landing orb (StippleOrb.tsx, LavaOrb.tsx,
11
+ // orb.module.css, LetterOrb.tsx) with the homepage tap 1:1: the dots spiral
12
+ // into one bright point and the lava orb is born there and grows. Here it
13
+ // then settles small at the top and the card fades up. Tap again and the
14
+ // card dissolves, the lava melts back into the sphere, and the next bet plays.
15
+ // Bets wait in a queue on the Mac, so a tap rarely waits; when it does, the
16
+ // spiral follows the real progress. Once a card is up nothing moves. The decisions only the owner can make sit behind one
17
+ // quiet link at the bottom.
18
+
19
+ function phonePage(nonce) {
20
+ return `<!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="utf-8">
24
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
25
+ <meta name="referrer" content="no-referrer">
26
+ <meta name="theme-color" content="#141110">
27
+ <title>front office</title>
28
+ <style nonce="${nonce}">
29
+ :root {
30
+ --bg: #141110;
31
+ --text: #eae3d9;
32
+ --text-2: #a39b92;
33
+ --text-3: #9a9187;
34
+ --line: #3d332d;
35
+ --amber: rgb(255, 140, 66);
36
+ --amber-ink: #1a0f06;
37
+ --serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;
38
+ color-scheme: dark;
39
+ }
40
+ * { box-sizing: border-box; }
41
+ html, body { margin: 0; background: var(--bg); color: var(--text); }
42
+ body {
43
+ font: 17px/1.5 var(--serif);
44
+ min-height: 100vh;
45
+ min-height: 100svh;
46
+ -webkit-font-smoothing: antialiased;
47
+ }
48
+ button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
49
+ :focus-visible { outline: 1px solid var(--amber); outline-offset: 4px; }
50
+ [hidden] { display: none !important; }
51
+
52
+ /* ---- the screen: the orb in the middle, one line under it ---- */
53
+ .screen {
54
+ position: relative;
55
+ min-height: 100vh; min-height: 100svh;
56
+ max-width: 34rem; margin: 0 auto;
57
+ padding: max(24px, env(safe-area-inset-top)) 24px max(24px, env(safe-area-inset-bottom));
58
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
59
+ }
60
+ .stage { position: relative; width: min(72vmin, 520px); aspect-ratio: 1 / 1; }
61
+ .stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; touch-action: manipulation; cursor: pointer; }
62
+ .stage canvas:focus-visible { outline-offset: -8%; }
63
+ .line {
64
+ min-height: 3em; margin: 4px 0 0; max-width: 22em; text-align: center;
65
+ color: var(--text-3); font-size: 17px; line-height: 1.5; letter-spacing: -0.005em;
66
+ opacity: 1; transition: opacity 0.28s ease;
67
+ }
68
+ .line.out { opacity: 0; }
69
+ .line.bad { color: var(--text-2); }
70
+ .foot { position: absolute; left: 0; right: 0; bottom: max(20px, env(safe-area-inset-bottom)); text-align: center; }
71
+ .quiet { color: var(--text-3); font-size: 15px; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; }
72
+ .foot .quiet + .quiet { margin-left: 18px; }
73
+ .jobs { margin: 12px auto 0; max-width: 24em; text-align: left; color: var(--text-2); font-size: 14px; line-height: 1.35; }
74
+ .jobs p { margin: 0 0 8px; }
75
+ .jobs .meta { display: block; color: var(--text-3); font-size: 13px; }
76
+
77
+ /* the lava orb, LavaOrb variant sm: three drifting blobs, transform and opacity only */
78
+ .lava {
79
+ position: absolute; left: 0; top: 0; width: 280px; height: 280px; z-index: 5;
80
+ border-radius: 50%; overflow: hidden; pointer-events: none;
81
+ visibility: hidden; opacity: 0;
82
+ background: rgb(20, 18, 16);
83
+ box-shadow: 0 0 40px rgba(255, 140, 66, 0.1);
84
+ filter: saturate(1.3);
85
+ transform-origin: 50% 50%;
86
+ will-change: transform, opacity;
87
+ }
88
+ .lava .tx, .lava .ty { position: absolute; inset: 0; will-change: transform; }
89
+ .lava .blob { position: absolute; will-change: transform; }
90
+ /* one pass only: the lava drifts while it is born, never on a loop */
91
+ .lava.alive .tx { animation: sine-x var(--dur) ease-in-out var(--delay) 1 both; }
92
+ .lava.alive .ty { animation: sine-y var(--dur) ease-in-out var(--delay) 1 both; }
93
+ .lava.alive .blob { animation: spin var(--spin) linear 1 both; }
94
+ .lava.alive .blob.rev { animation-direction: reverse; }
95
+ .lava .b1 { width: 70%; height: 45%; left: 20%; top: 25%; background: rgba(255, 140, 66, 0.55); border-radius: 60% 40% 50% 70% / 50% 60% 40% 50%; filter: blur(24px); }
96
+ .lava .b2 { width: 55%; height: 60%; left: 35%; top: 40%; background: rgba(255, 100, 50, 0.45); border-radius: 40% 60% 70% 30% / 60% 40% 60% 40%; filter: blur(24px); }
97
+ .lava .b3 { width: 50%; height: 40%; left: 10%; top: 50%; background: rgba(255, 180, 100, 0.4); border-radius: 50% 70% 30% 60% / 40% 50% 60% 50%; filter: blur(16px); }
98
+ .lava .core { position: absolute; inset: 0; border-radius: 50%; background: radial-gradient(circle, rgba(255,232,170,0.95) 0%, rgba(251,150,44,0.6) 30%, rgba(251,130,36,0) 62%); }
99
+ .lava .dim { position: absolute; inset: 0; border-radius: 50%; background: var(--bg); opacity: 0; }
100
+ .lava.tap { pointer-events: auto; cursor: pointer; }
101
+ .lava.still .tx, .lava.still .ty, .lava.still .blob { animation-play-state: paused; }
102
+ @keyframes sine-x { 0%, 100% { transform: translate3d(calc(var(--amp) * -1), 0, 0); } 50% { transform: translate3d(var(--amp), 0, 0); } }
103
+ @keyframes sine-y { 0%, 100% { transform: translate3d(0, calc(var(--amp) * -1), 0); } 50% { transform: translate3d(0, var(--amp), 0); } }
104
+ @keyframes spin { from { transform: rotate(var(--rot0, 0deg)); } to { transform: rotate(calc(var(--rot0, 0deg) + 360deg)); } }
105
+
106
+ /* ---- the card: same dark page, the lava orb small on top ---- */
107
+ body.has-card .screen { justify-content: flex-start; padding-top: calc(max(16px, env(safe-area-inset-top)) + 76px); }
108
+ body.has-card .stage, body.has-card .line { display: none; }
109
+ body.has-card .foot { position: static; margin-top: 28px; }
110
+ .bet { width: 100%; opacity: 0; transform: translateY(20px); transition: opacity 0.45s ease-out, transform 0.55s cubic-bezier(0.22, 1, 0.36, 1); }
111
+ .bet.up { opacity: 1; transform: none; }
112
+ .bet.quick { transition-duration: 0.4s, 0.5s; }
113
+ /* leaving: a soft fade in place, never a cut */
114
+ .bet.leave { opacity: 0; transform: none; transition: opacity 0.4s ease-out; }
115
+ .bet h1 { margin: 0; font-size: clamp(23px, 6.2vw, 30px); line-height: 1.12; font-weight: 700; letter-spacing: -0.03em; text-align: left; }
116
+ .why { margin: 12px 0 0; color: var(--text-2); font-size: 15px; line-height: 1.4; }
117
+ .when { margin: 12px 0 0; color: var(--text-3); font-size: 14px; line-height: 1.4; }
118
+ .steps { margin: 12px 0 0; padding: 10px 0 0; list-style: none; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 6px; counter-reset: step; }
119
+ .steps li { position: relative; padding-left: 22px; counter-increment: step; color: var(--text-2); font-size: 14px; line-height: 1.35; }
120
+ .steps li::before { content: counter(step); position: absolute; left: 0; top: 0; color: var(--text-3); font-variant-numeric: tabular-nums; }
121
+ .tag { display: inline-flex; align-items: center; gap: 5px; margin-right: 8px; font-size: 13px; color: var(--text-3); }
122
+ .tag.you { color: var(--amber); }
123
+ .tag svg { width: 11px; height: 11px; }
124
+ .act { margin: 18px 0 0; display: flex; flex-direction: column; align-items: center; gap: 6px; }
125
+ .pill {
126
+ min-width: 12rem; height: 44px; padding: 0 28px; border-radius: 22px;
127
+ background: var(--amber); color: var(--amber-ink); font-size: 17px; font-weight: 600; letter-spacing: -0.01em;
128
+ }
129
+ .pill.armed { background: var(--text); color: var(--bg); }
130
+ .links { color: var(--text-3); font-size: 15px; }
131
+ .links button { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; min-height: 44px; padding: 0 6px; }
132
+ .mission { margin: 32px 0 0; padding: 16px 0 0; border-top: 1px solid var(--line); color: var(--text-2); text-align: center; }
133
+
134
+ /* ---- only you: the owner's decisions, same theme, opened from the bottom link ---- */
135
+ .sheet {
136
+ position: fixed; inset: 0; z-index: 20; overflow-y: auto; background: var(--bg);
137
+ padding: max(24px, env(safe-area-inset-top)) 24px max(24px, env(safe-area-inset-bottom));
138
+ }
139
+ .sheet .inner { max-width: 34rem; margin: 0 auto; display: flex; flex-direction: column; gap: 22px; }
140
+ .sheet .head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
141
+ .sheet .head h2 { margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em; }
142
+ .strip { color: var(--text-2); font-size: 16px; }
143
+ .bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; margin-top: 10px; }
144
+ .bar > div { height: 100%; background: var(--amber); width: 0; }
145
+ .card { border-top: 1px solid var(--line); padding-top: 20px; display: flex; flex-direction: column; gap: 14px; }
146
+ .count { color: var(--text-3); font-size: 13px; }
147
+ .say { font-size: 20px; line-height: 1.4; margin: 0; }
148
+ .choice { width: 100%; min-height: 52px; text-align: left; padding: 12px 16px; border-radius: 14px; border: 1px solid var(--line); color: var(--text); }
149
+ .choice .effect { display: block; font-size: 14px; color: var(--text-3); }
150
+ .choice.main { background: var(--amber); color: var(--amber-ink); border-color: var(--amber); font-weight: 600; }
151
+ .choice.main .effect { color: var(--amber-ink); opacity: 0.75; font-weight: 400; }
152
+ .choice.armed { background: var(--text); color: var(--bg); border-color: var(--text); }
153
+ .choice.armed .effect { color: var(--bg); opacity: 0.75; }
154
+ .choice.later { border: 0; min-height: 44px; text-align: center; color: var(--text-3); }
155
+ .job { border-top: 1px solid var(--line); padding-top: 14px; display: flex; flex-direction: column; gap: 10px; }
156
+ .job .meta { color: var(--text-3); font-size: 14px; }
157
+ .row { display: flex; gap: 10px; }
158
+ .row .choice { text-align: center; }
159
+ .reply { color: var(--text-2); font-size: 16px; }
160
+ .live { color: var(--text-3); font-size: 14px; }
161
+ .toast {
162
+ position: fixed; left: 16px; right: 16px; bottom: max(16px, env(safe-area-inset-bottom)); z-index: 30;
163
+ background: var(--text); color: var(--bg); border-radius: 14px; padding: 10px 16px;
164
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
165
+ }
166
+ .toast button { min-height: 44px; padding: 0 12px; color: var(--bg); text-decoration: underline; }
167
+
168
+ @media (prefers-reduced-motion: reduce) {
169
+ .lava .tx, .lava .ty, .lava .blob { animation: none !important; }
170
+ .bet { transform: none; transition: opacity 0.2s linear; }
171
+ .line { transition: none; }
172
+ }
173
+ </style>
174
+ </head>
175
+ <body>
176
+ <main class="screen" id="screen">
177
+ <div class="stage" id="stage">
178
+ <canvas id="orb" tabindex="0" role="button" aria-label="the atris orb. tap to think of one new bet for the business"></canvas>
179
+ </div>
180
+ <p class="line" id="line" aria-live="polite">tap to think</p>
181
+ <article class="bet" id="bet" hidden></article>
182
+ <div class="foot" id="foot"><button type="button" class="quiet" id="open-only" hidden></button><button type="button" class="quiet" id="working" hidden aria-expanded="false"></button><div class="jobs" id="jobs" hidden></div></div>
183
+ </main>
184
+ <div class="lava" id="lava" role="button" tabindex="-1" aria-label="the atris orb. tap for the next bet">
185
+ <div class="tx" style-vars="15%,8.4s,-2.1s"><div class="ty" style-vars="12%,10.1s,-5.1s"><div class="blob b1" spin-vars="36s,0deg"></div></div></div>
186
+ <div class="tx" style-vars="18%,12.6s,-7.2s"><div class="ty" style-vars="15%,7.2s,-4.7s"><div class="blob b2 rev" spin-vars="48s,45deg"></div></div></div>
187
+ <div class="tx" style-vars="12%,7.2s,-6.4s"><div class="ty" style-vars="18%,10.1s,-9.9s"><div class="blob b3" spin-vars="57.6s,90deg"></div></div></div>
188
+ <div class="core" id="lava-core"></div>
189
+ <div class="dim" id="lava-dim"></div>
190
+ </div>
191
+ <section class="sheet" id="sheet" hidden aria-label="only you">
192
+ <div class="inner">
193
+ <div class="head"><h2>only you</h2><button type="button" class="quiet" id="close-only">done</button></div>
194
+ <div><div class="strip" id="target">loading</div><div class="bar"><div id="bar"></div></div></div>
195
+ <div id="main"></div>
196
+ <div id="reply" class="reply" hidden></div>
197
+ <div class="live" id="live"></div>
198
+ </div>
199
+ </section>
200
+ <div class="toast" id="toast" hidden><span id="toast-text"></span><button id="toast-undo" type="button">undo</button></div>
201
+ <script nonce="${nonce}">
202
+ (function () {
203
+ var token = new URLSearchParams(location.search).get('t') || '';
204
+ var last = null;
205
+ var view = 'card';
206
+ var armed = null;
207
+ var pendingReply = null;
208
+ var undoTimer = null;
209
+ var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
210
+
211
+ function el(tag, cls, text) {
212
+ var node = document.createElement(tag);
213
+ if (cls) node.className = cls;
214
+ if (text != null) node.textContent = text;
215
+ return node;
216
+ }
217
+
218
+ function api(path, body) {
219
+ var opts = { headers: { 'x-gm-token': token } };
220
+ if (body) {
221
+ opts.method = 'POST';
222
+ opts.headers['content-type'] = 'application/json';
223
+ opts.body = JSON.stringify(body);
224
+ }
225
+ return fetch(path + (body ? '' : '?t=' + encodeURIComponent(token)), opts)
226
+ .then(function (r) { return r.json().catch(function () { return { ok: false }; }).then(function (j) { j.http = r.status; return j; }); });
227
+ }
228
+
229
+ // ---- the one line under the orb: fades out, changes, fades in ------------------
230
+ var line = (function () {
231
+ var node = document.getElementById('line');
232
+ var want = null, busy = false;
233
+ function next() {
234
+ if (busy || want == null) return;
235
+ var w = want; want = null;
236
+ if (w.text === node.textContent && w.bad === node.classList.contains('bad')) return;
237
+ if (reduced) { node.textContent = w.text; node.classList.toggle('bad', w.bad); return; }
238
+ busy = true;
239
+ node.classList.add('out');
240
+ setTimeout(function () {
241
+ node.textContent = w.text;
242
+ node.classList.toggle('bad', w.bad);
243
+ node.classList.remove('out');
244
+ // each line stays long enough to read before the next one
245
+ setTimeout(function () { busy = false; next(); }, 650);
246
+ }, 280);
247
+ }
248
+ return { set: function (text, bad) { want = { text: text, bad: Boolean(bad) }; next(); } };
249
+ })();
250
+
251
+ // ---- the orb: StippleOrb (tone dark) and LetterOrb's tap, ported 1:1 -----------
252
+ // StippleOrb: the pull runs per dot over 0.75s after a delay (outer dots
253
+ // first), the bright point arrives from 0.3s to 1.0s, and the lava is born
254
+ // at 1.05s. LetterOrb: the lava grows for GROW_MS from 30px toward the
255
+ // farthest corner (at most MAX_SCALE), with the same ease and smooth curves.
256
+ // Here the grown lava then settles small at the top and the card fades up.
257
+ var orb = (function () {
258
+ var canvas = document.getElementById('orb');
259
+ var lava = document.getElementById('lava');
260
+ var core = document.getElementById('lava-core');
261
+ var dim = document.getElementById('lava-dim');
262
+ var ctx = canvas.getContext('2d');
263
+ var SPRING = 0.07, DAMP = 0.8;
264
+ var GROW_MS = 1050, MAX_SCALE = 3.2;
265
+ var SETTLE_MS = 900;
266
+ // a tap on a card: the card fades for LEAVE_MS while the small orb glides
267
+ // to the centre for GLIDE_MS, then the sphere grows back out of it and
268
+ // holds for BEAT_MS before the next reveal starts from it
269
+ var LEAVE_MS = 400, GLIDE_MS = 520, BEAT_MS = 340, BRIDGE_MS = 340;
270
+ // the fast reveal stays contained: the lava grows to BLOOM of the smaller
271
+ // window side and never washes the page
272
+ var FAST_GROW_MS = 460, FAST_SETTLE_MS = 440, BLOOM = 0.72;
273
+ // Two speeds, chosen by truth. A bet already waiting in the queue plays
274
+ // the same curves at FAST of their length (tap to card in about two seconds, unhurried).
275
+ // A bet still being made plays the full landing length, and the spiral
276
+ // holds on the real progress until it passes.
277
+ var FAST = 0.4;
278
+ var pace = 1;
279
+ var bridgeAt = 0;
280
+ var REST = 56;
281
+ var dpr = Math.min(window.devicePixelRatio || 1, 2);
282
+ var count = window.innerWidth < 768 ? 5500 : 11000;
283
+ var w = 0, h = 0, R = 0, cx = 0, cy = 0;
284
+ var dots = [];
285
+ // idle: the sphere drifts. pull: the dots spiral in by age. card: still.
286
+ var mode = 'idle';
287
+ var angle = 0;
288
+ var age = -1, ageTo = 0, timed = false, fired = false;
289
+ var raf = 0, lastT = 0;
290
+ var onBorn = null;
291
+ var base = 280, origin = { x: 0, y: 0 };
292
+ var light = { x: -0.5, y: -0.6 };
293
+ var lz = Math.sqrt(1 - light.x * light.x - light.y * light.y);
294
+ var ease = function (t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
295
+ var smooth = function (a, b, v) { var t = Math.min(1, Math.max(0, (v - a) / (b - a))); return t * t * (3 - 2 * t); };
296
+
297
+ // the lava blobs take their drift numbers from the markup
298
+ Array.prototype.forEach.call(lava.querySelectorAll('[style-vars]'), function (n) {
299
+ var v = n.getAttribute('style-vars').split(',');
300
+ n.style.setProperty('--amp', v[0]); n.style.setProperty('--dur', v[1]); n.style.setProperty('--delay', v[2]);
301
+ });
302
+ Array.prototype.forEach.call(lava.querySelectorAll('[spin-vars]'), function (n) {
303
+ var v = n.getAttribute('spin-vars').split(',');
304
+ n.style.setProperty('--spin', v[0]); n.style.setProperty('--rot0', v[1]);
305
+ });
306
+
307
+ function measure() {
308
+ w = canvas.clientWidth; h = canvas.clientHeight;
309
+ canvas.width = w * dpr; canvas.height = h * dpr;
310
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
311
+ R = Math.min(w, h) * 0.42; cx = w / 2; cy = h / 2;
312
+ }
313
+
314
+ function seed() {
315
+ // random points on a sphere (normalised gaussians), so it reads as stipple
316
+ var gauss = function () { return Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random()); };
317
+ dots = [];
318
+ for (var i = 0; i < count; i++) {
319
+ var gx = gauss(), gy = gauss(), gz = gauss();
320
+ var n = Math.hypot(gx, gy, gz) || 1;
321
+ dots.push({ x: gx / n, y: gy / n, z: gz / n, sx: cx, sy: cy, vx: 0, vy: 0, r: 0.4 + Math.random() * 0.7, bx: 0, by: 0, dl: 0 });
322
+ }
323
+ }
324
+
325
+ // StippleOrb startBoom: each dot remembers where it starts, and the outer
326
+ // dots go first. The start is the sphere itself, so a page that opens
327
+ // mid-thought still spirals from the whole orb.
328
+ // fromHere: start from where the dots are now (a quick re-tap, mid re-form)
329
+ function startPull(fromHere) {
330
+ var cosA = Math.cos(angle), sinA = Math.sin(angle);
331
+ for (var i = 0; i < dots.length; i++) {
332
+ var d = dots[i];
333
+ if (fromHere) { d.bx = d.sx; d.by = d.sy; } else { d.bx = cx + (d.x * cosA + d.z * sinA) * R; d.by = cy + d.y * R; }
334
+ var len = Math.hypot(d.bx - cx, d.by - cy);
335
+ d.dl = (1 - Math.min(1, len / R)) * 0.28 + Math.random() * 0.05;
336
+ }
337
+ age = 0; ageTo = 0; fired = false;
338
+ mode = 'pull';
339
+ }
340
+
341
+ function frame(now) {
342
+ var dt = Math.min(0.05, (now - (lastT || now)) / 1000);
343
+ lastT = now;
344
+ ctx.clearRect(0, 0, w, h);
345
+ if (!reduced && mode === 'idle') angle += dt * 0.15;
346
+ if (mode === 'pull') {
347
+ // ready: the age runs on the clock, as on the homepage. Waiting: it
348
+ // follows the real progress and stops short of the point.
349
+ if (timed) age += dt / pace;
350
+ else age += (ageTo - age) * 0.05;
351
+ }
352
+ var cosA = Math.cos(angle), sinA = Math.sin(angle);
353
+ for (var i = 0; i < dots.length; i++) {
354
+ var d = dots[i];
355
+ var fade = 1, shrink = 1;
356
+ var rx = d.x * cosA + d.z * sinA;
357
+ var rz = -d.x * sinA + d.z * cosA;
358
+ if (mode === 'idle') {
359
+ d.vx += (cx + rx * R - d.sx) * SPRING;
360
+ d.vy += (cy + d.y * R - d.sy) * SPRING;
361
+ d.vx *= DAMP; d.vy *= DAMP;
362
+ d.sx += d.vx; d.sy += d.vy;
363
+ if (reduced) { d.sx = cx + rx * R; d.sy = cy + d.y * R; }
364
+ } else {
365
+ var t = Math.min(1, Math.max(0, (age - d.dl) / 0.75));
366
+ var e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
367
+ var ox = (d.bx - cx) * (1 - e), oy = (d.by - cy) * (1 - e);
368
+ var turn = e * 1.5;
369
+ d.sx = cx + ox * Math.cos(turn) - oy * Math.sin(turn);
370
+ d.sy = cy + ox * Math.sin(turn) + oy * Math.cos(turn);
371
+ fade = 1 - Math.pow(Math.max(0, (e - 0.8) / 0.2), 1.5);
372
+ shrink = 1 - 0.35 * e;
373
+ }
374
+ // front dots are lit; the back half is a dim ghost. amber in shadow, cream where lit.
375
+ var lit = rx * light.x + d.y * light.y + rz * lz;
376
+ var front = rz > 0;
377
+ var l = Math.max(0, lit);
378
+ var a = (front ? 0.16 + Math.pow(l, 1.3) * 0.84 : 0.05 + l * 0.08) * fade;
379
+ if (a <= 0.003) continue;
380
+ ctx.fillStyle = l > 0.93 ? 'rgba(253, 196, 84, ' + a.toFixed(3) + ')' : 'rgba(251, 130, 36, ' + a.toFixed(3) + ')';
381
+ ctx.beginPath();
382
+ ctx.arc(d.sx, d.sy, d.r * (front ? 0.6 + l * 0.7 : 0.6) * shrink, 0, Math.PI * 2);
383
+ ctx.fill();
384
+ }
385
+ if (mode === 'pull') {
386
+ // where the dots land: one bright point that swells until the lava is born
387
+ var arrived = Math.min(1, Math.max(0, (age - 0.3) / 0.7));
388
+ if (arrived > 0) point(arrived, timed ? 0.88 + 0.12 * Math.sin(age * 5) : 1);
389
+ if (timed && !fired && age > 1.05) { fired = true; grow(); }
390
+ } else if (bridgeAt) {
391
+ // the melted lava's bright point fades as the sphere grows out of it
392
+ var left = 1 - (now - bridgeAt) / BRIDGE_MS;
393
+ if (left > 0) point(left, 1); else bridgeAt = 0;
394
+ }
395
+ if (mode === 'card' || (reduced && mode === 'idle')) { raf = 0; return; }
396
+ raf = requestAnimationFrame(frame);
397
+ }
398
+
399
+ // one bright point at the centre with its glow, at strength arrived
400
+ function point(arrived, breath) {
401
+ var coreR = (2 + 5 * arrived) * breath;
402
+ var glowR = (10 + 46 * arrived) * breath;
403
+ var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR);
404
+ g.addColorStop(0, 'rgba(255, 232, 170, ' + (0.9 * arrived).toFixed(3) + ')');
405
+ g.addColorStop(0.35, 'rgba(251, 150, 44, ' + (0.4 * arrived).toFixed(3) + ')');
406
+ g.addColorStop(1, 'rgba(251, 130, 36, 0)');
407
+ ctx.fillStyle = g;
408
+ ctx.beginPath(); ctx.arc(cx, cy, glowR, 0, Math.PI * 2); ctx.fill();
409
+ ctx.fillStyle = 'rgba(255, 244, 214, ' + arrived.toFixed(3) + ')';
410
+ ctx.beginPath(); ctx.arc(cx, cy, coreR, 0, Math.PI * 2); ctx.fill();
411
+ }
412
+
413
+ function start() { if (!raf) { lastT = 0; raf = requestAnimationFrame(frame); } }
414
+ function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; }
415
+
416
+ function place(s, x, y) { lava.style.transform = 'translate(' + (x - base / 2) + 'px, ' + (y - base / 2) + 'px) scale(' + s + ')'; }
417
+ function restPoint() {
418
+ var top = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-top')) || 0;
419
+ return { x: document.documentElement.clientWidth / 2, y: window.scrollY + Math.max(16, top) + 8 + REST / 2 };
420
+ }
421
+
422
+ // LetterOrb grow, then the settle: the lava is born at the point, grows
423
+ // toward the farthest corner as the page dims to its own dark, then
424
+ // shrinks back to a small orb at the top. After that nothing moves.
425
+ // A queued bet (the fast pace) blooms contained: the lava grows to BLOOM
426
+ // of the smaller window side, the page never dims, and it settles.
427
+ function grow() {
428
+ var rect = canvas.getBoundingClientRect();
429
+ var contained = pace < 1;
430
+ var growMs = contained ? FAST_GROW_MS : (GROW_MS * pace);
431
+ var settleMs = contained ? FAST_SETTLE_MS : (SETTLE_MS * pace);
432
+ var vx = rect.left + cx, vy = rect.top + cy;
433
+ var reach = contained
434
+ ? BLOOM * Math.min(window.innerWidth, window.innerHeight) / 2
435
+ : Math.hypot(Math.max(vx, window.innerWidth - vx), Math.max(vy, window.innerHeight - vy)) * 1.08;
436
+ base = Math.round(Math.min(1000, Math.max(280, (2 * reach) / MAX_SCALE)));
437
+ var sMax = (2 * reach) / base;
438
+ var sMin = 30 / base;
439
+ var rest = REST / base;
440
+ lava.style.width = base + 'px';
441
+ lava.style.height = base + 'px';
442
+ // fixed while it grows past the screen, so the page never scrolls sideways
443
+ window.scrollTo(0, 0);
444
+ origin = { x: vx, y: vy };
445
+ lava.style.position = 'fixed';
446
+ lava.style.visibility = 'visible';
447
+ lava.classList.remove('still', 'alive', 'tap');
448
+ void lava.offsetWidth;
449
+ lava.classList.add('alive');
450
+ var t0 = performance.now();
451
+ var step = function (now) {
452
+ var t = Math.min(1, (now - t0) / growMs);
453
+ place(sMin + (sMax - sMin) * ease(t), origin.x, origin.y);
454
+ lava.style.opacity = String(smooth(0.02, 0.22, t));
455
+ core.style.opacity = String(1 - smooth(0.05, 0.4, t));
456
+ dim.style.opacity = contained ? '0' : String(smooth(0.88, 1, t));
457
+ if (t < 1) { requestAnimationFrame(step); return; }
458
+ mode = 'card';
459
+ stop();
460
+ ctx.clearRect(0, 0, w, h);
461
+ canvas.style.visibility = 'hidden';
462
+ if (onBorn) onBorn('cover');
463
+ var p = restPoint();
464
+ var s0 = performance.now();
465
+ var settle = function (n2) {
466
+ var u = ease(Math.min(1, (n2 - s0) / settleMs));
467
+ place(sMax + (rest - sMax) * u, origin.x + (p.x - origin.x) * u, origin.y + (p.y - origin.y) * u);
468
+ dim.style.opacity = contained ? '0' : String(1 - smooth(0.35, 0.85, u));
469
+ if (u < 1) { requestAnimationFrame(settle); return; }
470
+ settled();
471
+ };
472
+ requestAnimationFrame(settle);
473
+ };
474
+ requestAnimationFrame(step);
475
+ }
476
+
477
+ // the small lava orb at the top, still, and itself the next tap
478
+ function settled() {
479
+ var p = restPoint();
480
+ if (base < 280) base = 280;
481
+ lava.style.width = base + 'px';
482
+ lava.style.height = base + 'px';
483
+ // at rest it scrolls with the card
484
+ lava.style.position = 'absolute';
485
+ place(REST / base, p.x, p.y);
486
+ lava.style.visibility = 'visible';
487
+ lava.style.opacity = '1';
488
+ core.style.opacity = '0';
489
+ dim.style.opacity = '0';
490
+ lava.classList.add('still', 'tap');
491
+ mode = 'card';
492
+ stop();
493
+ canvas.style.visibility = 'hidden';
494
+ lava.tabIndex = 0;
495
+ if (onBorn) { var cb = onBorn; onBorn = null; cb('settled'); }
496
+ }
497
+
498
+ // a tap on a card: the card fades in place while the small lava orb
499
+ // glides down to where the sphere sits (to), shrinking to a bright point.
500
+ // swap() takes the card off the page once it has faded. At the centre the
501
+ // lava melts and the sphere grows back out of the point, holds for a
502
+ // beat so it reads, then calls back. Reduced motion: no travel.
503
+ function melt(cb, to, swap) {
504
+ lava.classList.remove('tap', 'alive');
505
+ lava.tabIndex = -1;
506
+ var swapped = false;
507
+ var takeOff = function () { if (!swapped) { swapped = true; swap(); } };
508
+ var reform = function () {
509
+ takeOff();
510
+ canvas.style.visibility = 'visible';
511
+ measure();
512
+ lava.style.visibility = 'hidden';
513
+ lava.style.opacity = '0';
514
+ for (var i = 0; i < dots.length; i++) { dots[i].sx = cx; dots[i].sy = cy; dots[i].vx = 0; dots[i].vy = 0; }
515
+ age = -1; timed = false; fired = false;
516
+ mode = 'idle';
517
+ bridgeAt = reduced ? 0 : performance.now();
518
+ start();
519
+ setTimeout(cb, reduced ? 0 : BEAT_MS);
520
+ };
521
+ if (reduced || lava.style.visibility !== 'visible') { reform(); return; }
522
+ var r = lava.getBoundingClientRect();
523
+ var from = { x: r.left + r.width / 2, y: r.top + r.height / 2, s: r.width / base };
524
+ // fixed while it travels, so the card's scroll and the swap never move it
525
+ lava.style.position = 'fixed';
526
+ place(from.s, from.x, from.y);
527
+ var sEnd = 30 / base;
528
+ var t0 = performance.now();
529
+ var step = function (now) {
530
+ var k = Math.min(1, (now - t0) / GLIDE_MS);
531
+ var t = ease(k);
532
+ if (now - t0 >= LEAVE_MS) takeOff();
533
+ place(from.s + (sEnd - from.s) * t, from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
534
+ core.style.opacity = String(smooth(0.3, 0.9, k));
535
+ lava.style.opacity = String(1 - smooth(0.7, 1, k));
536
+ if (k < 1) { requestAnimationFrame(step); return; }
537
+ reform();
538
+ };
539
+ requestAnimationFrame(step);
540
+ }
541
+
542
+ measure();
543
+ seed();
544
+ start();
545
+
546
+ return {
547
+ // the bet is ready: the homepage tap, on the clock
548
+ play: function (cb, fast) {
549
+ onBorn = cb;
550
+ if (reduced) { settled(); return; }
551
+ pace = fast ? FAST : 1;
552
+ if (mode !== 'pull') startPull(fast);
553
+ timed = true;
554
+ start();
555
+ },
556
+ // no bet yet: the spiral follows the real progress
557
+ wait: function () {
558
+ pace = 1;
559
+ if (mode !== 'pull') { startPull(false); timed = false; }
560
+ start();
561
+ },
562
+ progress: function (p) {
563
+ if (!timed) ageTo = Math.max(ageTo, 0.62 * Math.min(1, p));
564
+ },
565
+ // generation failed: the dots relax back to the sphere
566
+ relax: function () {
567
+ age = -1; timed = false; fired = false;
568
+ mode = 'idle';
569
+ start();
570
+ },
571
+ melt: melt,
572
+ leaveMs: reduced ? 200 : LEAVE_MS,
573
+ // a bet is already up when the page loads: the settled orb, no motion
574
+ settled: function () { stop(); onBorn = null; settled(); },
575
+ moving: function () { return mode === 'pull'; },
576
+ };
577
+ })();
578
+
579
+ // ---- the bet ------------------------------------------------------------------
580
+ var bet = { status: null, polling: false, armed: false, drawn: null, busy: false, last: null };
581
+ // how long "builder has it" (or any ack) stays before the next card
582
+ var AFTER_MS = 1500;
583
+
584
+ function buzz() { if (navigator.vibrate) { try { navigator.vibrate(10); } catch (e) { /* not on this device */ } } }
585
+
586
+ function lockIcon() {
587
+ var ns = 'http://www.w3.org/2000/svg';
588
+ var svg = document.createElementNS(ns, 'svg');
589
+ svg.setAttribute('viewBox', '0 0 12 12');
590
+ svg.setAttribute('aria-hidden', 'true');
591
+ var body = document.createElementNS(ns, 'rect');
592
+ body.setAttribute('x', '2'); body.setAttribute('y', '5.5'); body.setAttribute('width', '8'); body.setAttribute('height', '5.5'); body.setAttribute('rx', '1');
593
+ body.setAttribute('fill', 'currentColor');
594
+ var hoop = document.createElementNS(ns, 'path');
595
+ hoop.setAttribute('d', 'M4 5.5V4a2 2 0 0 1 4 0v1.5');
596
+ hoop.setAttribute('fill', 'none'); hoop.setAttribute('stroke', 'currentColor'); hoop.setAttribute('stroke-width', '1.3');
597
+ svg.appendChild(hoop); svg.appendChild(body);
598
+ return svg;
599
+ }
600
+
601
+ // real progress from the server's events: each source read, then the
602
+ // words the model has written as it works
603
+ function progressOf(events) {
604
+ var p = 0;
605
+ events.forEach(function (e) {
606
+ if (e.kind === 'source' || e.kind === 'skip') p = Math.max(p, Math.min(0.4, p + 0.4 / 6));
607
+ else if (e.kind === 'model') p = Math.max(p, 0.45);
608
+ else if (e.kind === 'thinking') p = Math.max(p, 0.5 + 0.45 * (1 - Math.exp(-(Number(e.n) || 0) / 250)));
609
+ else if (e.kind === 'skeptic') p = Math.max(p, 0.9);
610
+ else if (e.kind === 'done') p = 1;
611
+ });
612
+ return p;
613
+ }
614
+
615
+ var SOURCE_WORDS = {
616
+ now: 'reading your notes',
617
+ briefs: 'reading the latest research',
618
+ season: 'reading the target for this week',
619
+ ledger: 'reading how past bets did',
620
+ team: 'reading who is on the team'
621
+ };
622
+
623
+ // the newest event, in plain words for the one line under the orb
624
+ function sayEvent(e) {
625
+ if (!e) return 'thinking of the next bet';
626
+ if (e.kind === 'source') {
627
+ // business numbers show as they are; the team's plumbing (the review
628
+ // queue, waiting jobs, merges, releases) never reaches this line
629
+ if (e.key === 'installs' || e.key === 'installs_best') return e.line;
630
+ return SOURCE_WORDS[e.key] || 'reading what is true right now';
631
+ }
632
+ if (e.kind === 'skip') return 'reading what is true right now';
633
+ if (e.kind === 'model') return e.line.indexOf('again') >= 0 ? 'asking again, sharper' : 'thinking of one new bet';
634
+ if (e.kind === 'thinking') return e.line;
635
+ if (e.kind === 'skeptic') return 'a second look: does it make sense';
636
+ if (e.kind === 'score') return 'checking how past bets did';
637
+ if (e.kind === 'reject') return 'the first idea did not hold up, asking again';
638
+ if (e.kind === 'done' || e.kind === 'check') return 'got one';
639
+ return e.line;
640
+ }
641
+
642
+ function failLine(error) {
643
+ var e = String(error || '');
644
+ if (/longer than/.test(e)) return 'the thinking took too long. tap to try again';
645
+ if (/could not start|exited with code|empty answer|the model said/.test(e)) return 'could not reach the thinking model. tap to try again';
646
+ return 'couldn\\'t land on a good bet. tap to try again';
647
+ }
648
+
649
+ // after a yes, a nice, or a send back, the next card comes the way a re-tap brings it
650
+ function moveOn(ack) {
651
+ var box = document.getElementById('bet');
652
+ var act = box.querySelector('.act');
653
+ if (act) act.replaceWith(el('p', 'mission', ack));
654
+ setTimeout(function () { tapOrb(false); }, AFTER_MS);
655
+ }
656
+
657
+ function armedTap(go, label, armedLabel, run) {
658
+ go.addEventListener('click', function () {
659
+ buzz();
660
+ if (!bet.armed) {
661
+ bet.armed = true; go.textContent = armedLabel; go.className = 'pill armed';
662
+ setTimeout(function () { if (bet.armed) { bet.armed = false; go.textContent = label; go.className = 'pill'; } }, 5000);
663
+ return;
664
+ }
665
+ bet.armed = false;
666
+ run();
667
+ });
668
+ }
669
+
670
+ // a line that ends like a sentence
671
+ function sentence(t) {
672
+ var s = String(t || '').trim();
673
+ return s && !/[.!?]$/.test(s) ? s + '.' : s;
674
+ }
675
+
676
+ var MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
677
+ // "we'll know by nov 1. about 30 hours of agent time, $0."
678
+ function whenLine(b) {
679
+ var m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(String(b.check_date || ''));
680
+ var day = m ? MONTHS[Number(m[2]) - 1] + ' ' + Number(m[3]) : b.check_date;
681
+ return [day ? "we'll know by " + day : '', b.cost].filter(Boolean).map(sentence).join(' ');
682
+ }
683
+
684
+ function drawJobCard(s) {
685
+ var box = document.getElementById('bet');
686
+ box.textContent = '';
687
+ var j = s.job;
688
+ var act = el('div', 'act');
689
+ if (s.kind === 'yes') {
690
+ box.appendChild(el('h1', null, j.ask));
691
+ box.appendChild(el('p', 'why', sentence(j.owner + ' is working on ' + j.title) + ' this step waits for your yes.'));
692
+ // the yes covers exactly this command, so the owner sees it
693
+ if (j.command) box.appendChild(el('p', 'when', 'your yes lets this run once: ' + j.command));
694
+ var yes = el('button', 'pill', 'yes');
695
+ yes.type = 'button';
696
+ yes.setAttribute('aria-label', 'yes. tells ' + j.owner + ' to go ahead');
697
+ armedTap(yes, 'yes', 'tap again to say yes', function () {
698
+ api('/bet/yes', { confirm: true }).then(function (r) { moveOn(r.ack || r.message || 'sent'); });
699
+ });
700
+ act.appendChild(yes);
701
+ act.appendChild(el('div', 'links', 'or tap the orb for the next one'));
702
+ box.appendChild(act);
703
+ return;
704
+ }
705
+ box.appendChild(el('h1', null, j.title));
706
+ var head = j.failed ? (j.halted ? 'this stopped before it was done.' : 'this did not pass.') : 'this is done.';
707
+ box.appendChild(el('p', 'why', [head].concat(j.lines.map(sentence)).join(' ')));
708
+ box.appendChild(el('p', 'when', [j.check, j.review, j.proof].map(sentence).join(' ')));
709
+ var ok = el('button', 'pill', 'nice');
710
+ ok.type = 'button';
711
+ ok.addEventListener('click', function () {
712
+ buzz();
713
+ api('/bet/nice', {}).then(function (r) { moveOn(r.ack || 'nice.'); });
714
+ });
715
+ act.appendChild(ok);
716
+ if (j.failed) {
717
+ var links = el('div', 'links');
718
+ var back = el('button', null, 'send back');
719
+ back.type = 'button';
720
+ armedTap(back, 'send back', 'tap again to send it back', function () {
721
+ api('/bet/sendback', { confirm: true }).then(function (r) { moveOn(r.ack || r.message || 'sent back'); });
722
+ });
723
+ links.appendChild(back);
724
+ act.appendChild(links);
725
+ }
726
+ box.appendChild(act);
727
+ }
728
+
729
+ function drawBetCard(s) {
730
+ if (s.kind && s.kind !== 'bet') { drawJobCard(s); return; }
731
+ var box = document.getElementById('bet');
732
+ box.textContent = '';
733
+ var b = s.bet;
734
+ box.appendChild(el('h1', null, b.title));
735
+ // what is going on and what we expect, as sentences with the numbers inside
736
+ box.appendChild(el('p', 'why', [b.why_now, b.expect].map(sentence).join(' ')));
737
+ var ol = el('ol', 'steps');
738
+ b.steps.forEach(function (st) {
739
+ var li = el('li');
740
+ var tag = el('span', 'tag ' + (st.who === 'you' ? 'you' : 'team'));
741
+ if (st.who === 'you') tag.appendChild(lockIcon());
742
+ tag.appendChild(document.createTextNode(st.who === 'you' ? 'your yes' : (st.member || 'team')));
743
+ li.appendChild(tag);
744
+ li.appendChild(document.createTextNode(st.text));
745
+ ol.appendChild(li);
746
+ });
747
+ box.appendChild(ol);
748
+ box.appendChild(el('p', 'when', whenLine(b)));
749
+ if (s.mission) { box.appendChild(el('p', 'mission', s.mission.line)); return; }
750
+ if (s.status === 'starting') { box.appendChild(el('p', 'mission', 'starting the mission')); return; }
751
+ var act = el('div', 'act');
752
+ var go = el('button', 'pill' + (bet.armed ? ' armed' : ''), bet.armed ? 'tap again to start' : 'do it');
753
+ go.type = 'button';
754
+ go.setAttribute('aria-label', bet.armed ? 'tap again to start the team on this bet' : 'do it. starts the team on the steps; your yes steps wait for you');
755
+ go.addEventListener('click', function () {
756
+ buzz();
757
+ if (!bet.armed) {
758
+ bet.armed = true; drawBetCard(s);
759
+ setTimeout(function () { if (bet.armed) { bet.armed = false; if (bet.last) drawBetCard(bet.last); } }, 5000);
760
+ return;
761
+ }
762
+ bet.armed = false;
763
+ s.status = 'starting'; drawBetCard(s);
764
+ api('/bet/do', { confirm: true }).then(function (r) {
765
+ loadBet(); refresh();
766
+ if (r && r.ok) setTimeout(function () { tapOrb(false); }, AFTER_MS);
767
+ });
768
+ });
769
+ act.appendChild(go);
770
+ var links = el('div', 'links');
771
+ var sharp = el('button', null, 'sharper idea');
772
+ sharp.type = 'button';
773
+ sharp.addEventListener('click', function () { tapOrb(true); });
774
+ var skip = el('button', null, 'not now');
775
+ skip.type = 'button';
776
+ skip.addEventListener('click', function () {
777
+ buzz();
778
+ api('/bet/skip', {}).then(function () {
779
+ dissolve(function () { bet.status = 'skipped'; line.set('not now. tap when you want another', false); });
780
+ });
781
+ });
782
+ links.appendChild(sharp);
783
+ links.appendChild(document.createTextNode(' · '));
784
+ links.appendChild(skip);
785
+ act.appendChild(links);
786
+ box.appendChild(act);
787
+ }
788
+
789
+ // the time from the tap to the card fully on screen, kept on the page so
790
+ // the speed can be measured
791
+ function measured() {
792
+ if (bet.tapAt) document.body.setAttribute('data-reveal-ms', String(Math.round(performance.now() - bet.tapAt)));
793
+ bet.tapAt = 0;
794
+ }
795
+
796
+ function showCard(s, animate, fast) {
797
+ var box = document.getElementById('bet');
798
+ bet.last = s;
799
+ bet.drawn = s.id;
800
+ drawBetCard(s);
801
+ document.body.classList.add('has-card');
802
+ box.hidden = false;
803
+ box.classList.remove('leave');
804
+ box.classList.toggle('quick', Boolean(fast));
805
+ if (!animate) { box.classList.add('up'); measured(); return; }
806
+ box.classList.remove('up');
807
+ var done = function (e) { if (e.propertyName === 'opacity') { box.removeEventListener('transitionend', done); measured(); } };
808
+ box.addEventListener('transitionend', done);
809
+ void box.offsetWidth;
810
+ requestAnimationFrame(function () { box.classList.add('up'); });
811
+ }
812
+
813
+ // where the sphere sits once the card is gone, read before anything moves,
814
+ // in page coordinates (the screen's once the page is back at the top)
815
+ function sphereSpot() {
816
+ var box = document.getElementById('bet');
817
+ var y0 = window.scrollY, was = box.hidden;
818
+ document.body.classList.remove('has-card');
819
+ box.hidden = true;
820
+ var r = document.getElementById('orb').getBoundingClientRect();
821
+ var spot = { x: r.left + r.width / 2, y: r.top + window.scrollY + r.height / 2 };
822
+ document.body.classList.add('has-card');
823
+ box.hidden = was;
824
+ if (window.scrollY !== y0) window.scrollTo(0, y0);
825
+ return spot;
826
+ }
827
+
828
+ // the card fades out in place while the small orb glides down and becomes
829
+ // the sphere again; the card leaves the page once it has faded
830
+ function dissolve(then) {
831
+ var box = document.getElementById('bet');
832
+ var to = sphereSpot();
833
+ box.classList.remove('quick', 'up');
834
+ box.classList.add('leave');
835
+ var swap = function () {
836
+ box.hidden = true;
837
+ box.classList.remove('leave');
838
+ document.body.classList.remove('has-card');
839
+ window.scrollTo(0, 0);
840
+ };
841
+ if (reduced) { setTimeout(function () { orb.melt(then, to, swap); }, orb.leaveMs); return; }
842
+ orb.melt(then, to, swap);
843
+ }
844
+
845
+ // what the page does with the server's answer once the sphere is ready
846
+ // fast: the bet was waiting in the queue; otherwise it was just made
847
+ function reveal(s, fast) {
848
+ bet.status = s.status;
849
+ if ((s.bet || s.job) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting')) {
850
+ line.set('', false);
851
+ orb.play(function (phase) { if (phase === 'settled') { bet.busy = false; showCard(s, true, fast); } }, fast);
852
+ return;
853
+ }
854
+ if (s.status === 'thinking') {
855
+ orb.wait();
856
+ orb.progress(progressOf(s.events));
857
+ line.set(sayEvent(s.events[s.events.length - 1]), false);
858
+ poll();
859
+ return;
860
+ }
861
+ if (s.status === 'failed') {
862
+ bet.busy = false;
863
+ orb.relax();
864
+ line.set(failLine(s.error), true);
865
+ return;
866
+ }
867
+ bet.busy = false;
868
+ line.set('tap to think', false);
869
+ }
870
+
871
+ // one tap: buzz at once, ask the server for the next bet, and play the
872
+ // choreography; a card on screen dissolves first
873
+ function tapOrb(sharper) {
874
+ if (bet.busy) return;
875
+ buzz();
876
+ bet.busy = true;
877
+ bet.armed = false;
878
+ bet.tapAt = performance.now();
879
+ document.body.removeAttribute('data-reveal-ms');
880
+ var answer = api('/bet/think', { sharper: Boolean(sharper) });
881
+ var ready = new Promise(function (resolve) {
882
+ if (document.body.classList.contains('has-card')) dissolve(resolve);
883
+ else resolve();
884
+ });
885
+ Promise.all([answer, ready]).then(function (both) {
886
+ var s = both[0];
887
+ if (s.http !== 200) { bet.busy = false; orb.relax(); line.set('that did not go through. tap to try again', true); return; }
888
+ reveal(s, true);
889
+ });
890
+ }
891
+
892
+ function loadBet() {
893
+ return api('/bet').then(function (s) {
894
+ if (s.http !== 200) return;
895
+ var was = bet.status;
896
+ bet.status = s.status;
897
+ if (s.status === 'thinking') {
898
+ orb.progress(progressOf(s.events));
899
+ line.set(sayEvent(s.events[s.events.length - 1]), false);
900
+ poll();
901
+ return;
902
+ }
903
+ if (was === 'thinking') { reveal(s); return; }
904
+ if ((s.bet || s.job) && bet.drawn === s.id) { bet.last = s; drawBetCard(s); if (s.status === 'starting') poll(); }
905
+ });
906
+ }
907
+
908
+ function poll() {
909
+ if (bet.polling) return;
910
+ bet.polling = true;
911
+ setTimeout(function () { bet.polling = false; loadBet(); }, 400);
912
+ }
913
+
914
+ // first look: a bet already on screen shows settled, a bet being made
915
+ // shows its progress, anything else is the sphere
916
+ function firstLook() {
917
+ api('/bet').then(function (s) {
918
+ if (s.http !== 200) return;
919
+ bet.status = s.status;
920
+ if ((s.bet || s.job) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting')) {
921
+ orb.settled();
922
+ showCard(s, false);
923
+ return;
924
+ }
925
+ if (s.status === 'thinking') { bet.busy = true; reveal(s); return; }
926
+ if (s.status === 'failed') { line.set(failLine(s.error), true); return; }
927
+ line.set(s.status === 'skipped' ? 'not now. tap when you want another' : 'tap to think', false);
928
+ });
929
+ }
930
+
931
+ var canvas = document.getElementById('orb');
932
+ var lavaTap = document.getElementById('lava');
933
+ [canvas, lavaTap].forEach(function (node) {
934
+ node.addEventListener('click', function () { tapOrb(false); });
935
+ node.addEventListener('keydown', function (e) {
936
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tapOrb(false); }
937
+ });
938
+ });
939
+
940
+ // ---- only you: the decision deck, behind one quiet link ---------------------------
941
+
942
+ var sheet = document.getElementById('sheet');
943
+ document.getElementById('open-only').addEventListener('click', function () { sheet.hidden = false; draw(); });
944
+ document.getElementById('working').addEventListener('click', function () {
945
+ var list = document.getElementById('jobs');
946
+ list.hidden = !list.hidden;
947
+ this.setAttribute('aria-expanded', list.hidden ? 'false' : 'true');
948
+ });
949
+ document.getElementById('close-only').addEventListener('click', function () { sheet.hidden = true; armed = null; view = 'card'; });
950
+
951
+ function say(text, done) {
952
+ var reply = document.getElementById('reply');
953
+ reply.hidden = !text;
954
+ reply.textContent = text || '';
955
+ }
956
+
957
+ function choiceButton(card, choice, main) {
958
+ var b = el('button', 'choice' + (main ? ' main' : (choice.id === 'later' ? ' later' : '')));
959
+ b.type = 'button';
960
+ var label = el('span', null, choice.label);
961
+ b.appendChild(label);
962
+ if (choice.effect) b.appendChild(el('span', 'effect', choice.effect));
963
+ var key = card.id + ':' + choice.id;
964
+ if (armed && armed.key === key) {
965
+ b.className = 'choice armed';
966
+ label.textContent = 'tap again to confirm';
967
+ }
968
+ b.addEventListener('click', function () { tap(card, choice, key); });
969
+ return b;
970
+ }
971
+
972
+ function tap(card, choice, key) {
973
+ if (choice.view === 'oldest') { view = 'oldest'; draw(); return; }
974
+ if (choice.confirm && !(armed && armed.key === key && Date.now() < armed.until)) {
975
+ armed = { key: key, until: Date.now() + 5000 };
976
+ draw();
977
+ setTimeout(function () { if (armed && armed.key === key && Date.now() >= armed.until) { armed = null; draw(); } }, 5100);
978
+ return;
979
+ }
980
+ armed = null;
981
+ if (choice.ack) say(choice.ack, false);
982
+ api('/action', { card: card.id, choice: choice.id, confirm: Boolean(choice.confirm) }).then(function (res) {
983
+ if (!res.ok) { say(res.message || 'that did not go through', false); return refresh(); }
984
+ if (choice.id === 'later') { say('', false); view = 'card'; }
985
+ if (res.undo) showUndo(res.ack, res.undo);
986
+ else if (res.id) pendingReply = res.id;
987
+ if (choice.id !== 'later' && !/^(approve|sendback):/.test(choice.id)) view = 'card';
988
+ refresh();
989
+ });
990
+ }
991
+
992
+ function showUndo(text, undo) {
993
+ var toast = document.getElementById('toast');
994
+ document.getElementById('toast-text').textContent = text;
995
+ toast.hidden = false;
996
+ var b = document.getElementById('toast-undo');
997
+ b.onclick = function () {
998
+ toast.hidden = true;
999
+ api('/undo', { id: undo.id }).then(function (res) { say(res.ack || res.message || '', false); refresh(); });
1000
+ };
1001
+ clearTimeout(undoTimer);
1002
+ undoTimer = setTimeout(function () { toast.hidden = true; }, undo.ms);
1003
+ }
1004
+
1005
+ function drawCard(main, s) {
1006
+ var card = s.card;
1007
+ var box = el('section', 'card');
1008
+ box.appendChild(el('div', 'count', 'decision ' + (s.decided + 1) + ' of ' + Math.min(s.card_limit, s.decided + s.cards_left)));
1009
+ if (view === 'oldest' && card.jobs) {
1010
+ box.appendChild(el('p', 'say', 'the oldest jobs waiting on review'));
1011
+ card.jobs.forEach(function (job) {
1012
+ var j = el('div', 'job');
1013
+ j.appendChild(el('div', null, job.title));
1014
+ j.appendChild(el('div', 'meta', job.member + ', waiting ' + job.age + ', ' + job.ref));
1015
+ var row = el('div', 'row');
1016
+ row.appendChild(choiceButton(card, { id: 'approve:' + job.ref, label: 'approve', confirm: true, ack: job.member + ': thanks, landing it' }, false));
1017
+ row.appendChild(choiceButton(card, { id: 'sendback:' + job.ref, label: 'send back', confirm: true, ack: job.member + ': on it, taking another pass' }, false));
1018
+ j.appendChild(row);
1019
+ box.appendChild(j);
1020
+ });
1021
+ var back = el('button', 'choice later', 'back');
1022
+ back.type = 'button';
1023
+ back.addEventListener('click', function () { view = 'card'; armed = null; draw(); });
1024
+ box.appendChild(back);
1025
+ } else {
1026
+ box.appendChild(el('p', 'say', card.text));
1027
+ card.choices.forEach(function (c, i) { box.appendChild(choiceButton(card, c, i === 0 && c.id !== 'later')); });
1028
+ var cmds = [];
1029
+ card.choices.forEach(function (c) { if (/^yes-/.test(card.id) && c.commands) cmds = cmds.concat(c.commands); });
1030
+ if (cmds.length) box.appendChild(el('div', 'count', 'yes runs: ' + cmds.join('; ')));
1031
+ }
1032
+ main.appendChild(box);
1033
+ }
1034
+
1035
+ function drawClear(main, s) {
1036
+ var box = el('section', 'card');
1037
+ box.appendChild(el('p', 'say', s.decided >= s.card_limit ? 'that is five decisions. the rest can wait.' : (s.decided ? 'you have been through everything for now.' : 'nothing needs you right now.')));
1038
+ var running = s.all_clear.still_running;
1039
+ box.appendChild(el('div', 'count', running.length ? 'still running: ' + running.join(', ') : 'nothing is running right now'));
1040
+ main.appendChild(box);
1041
+ }
1042
+
1043
+ function drawWorking(jobs) {
1044
+ var btn = document.getElementById('working');
1045
+ var list = document.getElementById('jobs');
1046
+ btn.hidden = !jobs.length;
1047
+ btn.textContent = jobs.length + ' working';
1048
+ if (!jobs.length) { list.hidden = true; btn.setAttribute('aria-expanded', 'false'); return; }
1049
+ list.textContent = '';
1050
+ jobs.forEach(function (j) {
1051
+ var row = el('p', null, j.title);
1052
+ row.appendChild(el('span', 'meta', j.owner + ' has it' + (j.running_for ? ', running ' + j.running_for : '')));
1053
+ list.appendChild(row);
1054
+ });
1055
+ }
1056
+
1057
+ function draw() {
1058
+ var s = last;
1059
+ if (!s) return;
1060
+ var link = document.getElementById('open-only');
1061
+ var n = s.cards_left;
1062
+ link.hidden = !n && sheet.hidden;
1063
+ link.textContent = n === 1 ? '1 thing only you can do' : n + ' things only you can do';
1064
+ drawWorking(s.bet && s.bet.working ? s.bet.working : []);
1065
+ document.getElementById('target').textContent = s.strip.line;
1066
+ var pct = s.strip.goal ? Math.min(100, Math.round(100 * s.strip.landed / s.strip.goal)) : 0;
1067
+ document.getElementById('bar').style.width = pct + '%';
1068
+ if (sheet.hidden) return;
1069
+ var main = document.getElementById('main');
1070
+ main.textContent = '';
1071
+ if (s.error) main.appendChild(el('p', 'count', 'could not read the task list: ' + s.error));
1072
+ if (s.card && !(view === 'oldest' && !s.card.jobs)) drawCard(main, s);
1073
+ else drawClear(main, s);
1074
+ if (pendingReply) {
1075
+ var a = s.actions.filter(function (x) { return x.id === pendingReply; })[0];
1076
+ if (a) say(a.line, a.status === 'done');
1077
+ }
1078
+ document.getElementById('live').textContent = s.live;
1079
+ }
1080
+
1081
+ function refresh() {
1082
+ return api('/state').then(function (s) {
1083
+ if (s.http !== 200) { line.set('this link stopped working. start gm phone again on the mac.', true); return; }
1084
+ if (!s.card || s.card.id !== 'review') view = 'card';
1085
+ last = s;
1086
+ draw();
1087
+ }).catch(function () {
1088
+ document.getElementById('live').textContent = 'cannot reach the mac right now. retrying.';
1089
+ });
1090
+ }
1091
+
1092
+ firstLook();
1093
+ refresh();
1094
+ setInterval(refresh, 5000);
1095
+ })();
1096
+ </script>
1097
+ </body>
1098
+ </html>
1099
+ `;
1100
+ }
1101
+
1102
+ module.exports = { phonePage };