atris 3.74.4 → 3.74.6

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 (124) hide show
  1. package/FOR_AGENTS.md +12 -12
  2. package/atris/AGENTS.md +7 -7
  3. package/atris/CLAUDE.md +10 -10
  4. package/atris/policies/atris-backend.md +1 -1
  5. package/atris/policies/atris-design.md +71 -71
  6. package/atris/policies/design-seed.md +28 -28
  7. package/atris/policies/writing.md +1 -1
  8. package/atris/skills/apps/SKILL.md +3 -3
  9. package/atris/skills/atris-feedback/SKILL.md +2 -2
  10. package/atris/skills/autopilot/SKILL.md +10 -10
  11. package/atris/skills/blocks/SKILL.md +16 -16
  12. package/atris/skills/calendar/SKILL.md +1 -1
  13. package/atris/skills/clawhub/atris/SKILL.md +4 -4
  14. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
  15. package/atris/skills/create-app/SKILL.md +18 -18
  16. package/atris/skills/create-member/SKILL.md +11 -11
  17. package/atris/skills/design/SKILL.md +1 -1
  18. package/atris/skills/drive/SKILL.md +25 -25
  19. package/atris/skills/email-agent/SKILL.md +11 -11
  20. package/atris/skills/endgame/SKILL.md +33 -33
  21. package/atris/skills/magic-inbox/SKILL.md +15 -15
  22. package/atris/skills/notion/SKILL.md +1 -1
  23. package/atris/skills/ramp/SKILL.md +7 -7
  24. package/atris/skills/research-search/SKILL.md +7 -7
  25. package/atris/skills/slides/SKILL.md +7 -7
  26. package/atris/skills/tidy/SKILL.md +11 -11
  27. package/atris/skills/wake/SKILL.md +11 -11
  28. package/atris/skills/writing/SKILL.md +5 -5
  29. package/atris/team/_template/SOUL.md +3 -3
  30. package/atris/team/brainstormer/MEMBER.md +2 -2
  31. package/atris/team/launcher/MEMBER.md +2 -2
  32. package/atris/team/mission-lead/MISSION.md +7 -7
  33. package/atris/team/opus-overnight/MISSION.md +16 -16
  34. package/atris/team/opus-overnight/STEERING.md +5 -5
  35. package/atris.md +9 -0
  36. package/bin/atris.js +16 -3
  37. package/commands/campaign.js +17 -2
  38. package/commands/card.js +19 -12
  39. package/commands/engine.js +92 -17
  40. package/commands/gm.js +30 -0
  41. package/commands/idea.js +263 -0
  42. package/commands/init.js +11 -2
  43. package/commands/land.js +39 -18
  44. package/commands/mission.js +4 -3
  45. package/commands/ninety-nine.js +288 -0
  46. package/commands/one-lap.js +46 -27
  47. package/commands/reel.js +12 -7
  48. package/commands/sync.js +10 -2
  49. package/commands/task.js +4 -2
  50. package/commands/xp.js +68 -15
  51. package/commands/zen.js +10 -3
  52. package/lib/campaign-keep.js +23 -8
  53. package/lib/campaign-loop.js +24 -5
  54. package/lib/career-xp-lock.js +61 -0
  55. package/lib/career-xp-root.js +61 -0
  56. package/lib/claude-boot-block.js +7 -4
  57. package/lib/engine-registry.js +139 -1
  58. package/lib/fleet.js +11 -2
  59. package/lib/gm-bet.js +734 -42
  60. package/lib/gm-card.js +431 -0
  61. package/lib/gm-phone-page.js +486 -717
  62. package/lib/gm-phone.js +33 -34
  63. package/lib/gm-season.js +14 -0
  64. package/lib/headless-chrome.js +39 -0
  65. package/lib/idea-gate.js +33 -0
  66. package/lib/idea-inbox.js +220 -0
  67. package/lib/idea-ledger.js +196 -0
  68. package/lib/idea-tick.js +384 -0
  69. package/lib/known-commands.js +1 -1
  70. package/lib/member-engine.js +1 -0
  71. package/lib/ninety-nine-sync.js +156 -0
  72. package/lib/ninety-nine.js +247 -0
  73. package/lib/opening-lines.js +94 -0
  74. package/lib/roster-markdown.js +39 -1
  75. package/lib/roster-models.js +1 -1
  76. package/lib/roster-swap.js +353 -0
  77. package/lib/spawn-env.js +29 -2
  78. package/lib/task-db.js +8 -4
  79. package/lib/wish-audit.js +14 -0
  80. package/lib/yes-lock.js +365 -256
  81. package/lib/zen-apply.js +6 -3
  82. package/lib/zen.js +28 -1
  83. package/package.json +1 -1
  84. package/templates/business-starter/CLAUDE.md +7 -7
  85. package/templates/business-starter/MAP.md +1 -1
  86. package/templates/business-starter/TODO.md +1 -1
  87. package/templates/business-starter/atris.md +2 -2
  88. package/templates/business-starter/context/README.md +6 -6
  89. package/templates/business-starter/context/live-workspace.md +1 -1
  90. package/templates/business-starter/goals.md +1 -1
  91. package/templates/business-starter/instructions.md +1 -1
  92. package/templates/business-starter/memory.md +4 -4
  93. package/templates/business-starter/persona.md +3 -3
  94. package/templates/business-starter/policies/LESSONS.md +1 -1
  95. package/templates/business-starter/reports/README.md +1 -1
  96. package/templates/business-starter/skills/README.md +1 -1
  97. package/templates/business-starter/team/README.md +1 -1
  98. package/templates/business-starter/team/START_HERE.md +1 -1
  99. package/templates/business-starter/team/_template/SOUL.md +3 -3
  100. package/templates/business-starter/team/comms/SOUL.md +2 -2
  101. package/templates/business-starter/team/ops/SOUL.md +4 -4
  102. package/templates/business-starter/team/research/SOUL.md +3 -3
  103. package/templates/business-starter/wiki/index.md +1 -1
  104. package/templates/research-canonical/CLAUDE.md +7 -7
  105. package/templates/research-canonical/MAP.md +1 -1
  106. package/templates/research-canonical/TODO.md +1 -1
  107. package/templates/research-canonical/atris.md +2 -2
  108. package/templates/research-canonical/context/README.md +8 -8
  109. package/templates/research-canonical/context/live-workspace.md +1 -1
  110. package/templates/research-canonical/goals.md +2 -2
  111. package/templates/research-canonical/instructions.md +1 -1
  112. package/templates/research-canonical/memory.md +4 -4
  113. package/templates/research-canonical/persona.md +1 -1
  114. package/templates/research-canonical/policies/LESSONS.md +1 -1
  115. package/templates/research-canonical/reports/README.md +1 -1
  116. package/templates/research-canonical/skills/README.md +1 -1
  117. package/templates/research-canonical/team/README.md +1 -1
  118. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  119. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  120. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  121. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  122. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  123. package/templates/research-canonical/wiki/index.md +1 -1
  124. package/utils/auth.js +1 -0
@@ -71,19 +71,38 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
71
71
  .line.out { opacity: 0; }
72
72
  .line.bad { color: var(--text-2); }
73
73
  .foot { position: absolute; left: 0; right: 0; bottom: max(20px, env(safe-area-inset-bottom)); text-align: center; }
74
+ /* the yeses still waiting: a small line beside the team, never a card on its own */
75
+ #needs { margin-right: 20px; color: var(--amber); }
74
76
  .quiet { color: var(--text-2); font-size: 17px; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; min-height: 44px; }
75
- /* the team: one row per member, who, what, the status, and the time */
76
- .team { position: fixed; inset: 0; z-index: 20; overflow-y: auto; background: var(--bg); padding: max(24px, env(safe-area-inset-top)) 24px max(24px, env(safe-area-inset-bottom)); }
77
- .team .inner { max-width: 34rem; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
78
- .team .head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
79
- .team h1 { margin: 0; font-size: clamp(23px, 6.2vw, 30px); line-height: 1.12; font-weight: 700; letter-spacing: -0.03em; }
80
- .team .row { width: 100%; text-align: left; display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 14px; color: var(--text); }
81
- .team .who { font-size: 20px; font-weight: 700; }
82
- .team .st { font-size: 17px; color: var(--text-2); text-align: right; }
83
- .team .st.working { color: var(--text); }
84
- .team .st.waiting-for-your-yes { color: var(--amber); }
85
- .team .on { grid-column: 1 / -1; font-size: 18px; line-height: 1.35; }
86
- .team .time { grid-column: 1 / -1; font-size: 17px; color: var(--text-2); }
77
+ /* a card with a table (the team, one job) is wider, and at phone width the
78
+ table scrolls sideways with the name column held in place; a line between
79
+ every column and every row, and no column narrower than its words */
80
+ body.wide .screen { max-width: 64rem; }
81
+ .grid { margin: 14px 0 0; overflow-x: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; border: 1px solid var(--line); border-radius: 14px; }
82
+ .bet table { width: 100%; min-width: 44rem; border-collapse: separate; border-spacing: 0; font-size: 17px; line-height: 1.35; }
83
+ .bet table.k-deck { min-width: 56rem; }
84
+ .bet th, .bet td { padding: 10px 14px; text-align: left; vertical-align: top; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
85
+ .bet th:last-child, .bet td:last-child { border-right: 0; }
86
+ .bet tbody tr:last-child td { border-bottom: 0; }
87
+ .bet th { color: var(--text-2); font-weight: 400; white-space: nowrap; }
88
+ .bet tr.row { cursor: pointer; }
89
+ @media (hover: hover) { .bet tr.row:hover td { background: #1c1816; } }
90
+ .bet td.member { position: sticky; left: 0; z-index: 1; background: var(--bg); white-space: nowrap; }
91
+ .bet td.member button { font-size: 18px; font-weight: 700; text-align: left; min-height: 32px; }
92
+ .bet td.role { min-width: 9rem; color: var(--text-2); }
93
+ .bet td.ovr { white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
94
+ .bet td.engine, .bet td.done { white-space: nowrap; }
95
+ .bet td.engine button { text-align: left; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; min-height: 32px; }
96
+ .bet td.status { min-width: 16rem; color: var(--text-2); }
97
+ .bet td.status.working { color: var(--text); }
98
+ .bet td.status.waiting-for-your-yes, .bet td.status.needs-you { color: var(--amber); }
99
+ .bet td.work { min-width: 14rem; }
100
+ /* a job deck card: its place in the deck and its status above the title */
101
+ .bet .count, .bet .label, .bet .time { display: block; font-size: 17px; color: var(--text-2); }
102
+ .bet .label.needs-you { color: var(--amber); }
103
+ .bet .time { margin: 10px 0 0; }
104
+ .jobline { margin: 10px 0 0; color: var(--text-2); font-size: 17px; }
105
+ .thread { margin: 12px 0 0; }
87
106
  .sayline { margin: 10px 0 0; display: flex; flex-direction: column; align-items: center; gap: 8px; }
88
107
  .sayline .thread, .sayline .reply { width: 100%; text-align: left; margin: 0; }
89
108
  .unsent { margin: 0; text-align: center; }
@@ -129,6 +148,10 @@ body.has-card .foot { position: static; margin-top: 28px; }
129
148
  .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); }
130
149
  .bet.up { opacity: 1; transform: none; }
131
150
  .bet.quick { transition-duration: 0.4s, 0.5s; }
151
+ /* after an act the next card fades up as the sphere settles, so the whole motion is about a
152
+ second, and its title drifts only a few pixels into the spot the last title had */
153
+ .bet.acted { transform: translateY(4px); transition-duration: 0.28s, 0.36s; }
154
+ .bet.acted.up { transform: none; }
132
155
  /* card to card: a crossfade in place. the old card is a still copy that
133
156
  fades over the box while the new card fades in under it from a small
134
157
  drift, its title landing where the old title was; the box eases to the
@@ -193,7 +216,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
193
216
  </div>
194
217
  <p class="line" id="line" aria-live="polite">tap to think</p>
195
218
  <article class="bet" id="bet" hidden></article>
196
- <div class="foot" id="foot"><button type="button" class="quiet" id="working" aria-haspopup="dialog">the team</button></div>
219
+ <div class="foot" id="foot"><button type="button" class="quiet" id="needs" hidden></button><button type="button" class="quiet" id="working">the team</button></div>
197
220
  </main>
198
221
  <div class="lava" id="lava" role="button" tabindex="-1" aria-label="the atris orb. tap to go back to the big orb">
199
222
  <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>
@@ -202,7 +225,6 @@ body.has-card .foot { position: static; margin-top: 28px; }
202
225
  <div class="core" id="lava-core"></div>
203
226
  <div class="dim" id="lava-dim"></div>
204
227
  </div>
205
- <section class="team" id="team" hidden role="dialog" aria-label="the team"><div class="inner" id="team-inner"></div></section>
206
228
  <script nonce="${nonce}">
207
229
  (function () {
208
230
  var token = new URLSearchParams(location.search).get('t') || '';
@@ -223,7 +245,15 @@ body.has-card .foot { position: static; margin-top: 28px; }
223
245
  opts.body = JSON.stringify(body);
224
246
  }
225
247
  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; }); });
248
+ .then(function (r) { return r.json().catch(function () { return { ok: false }; }).then(function (j) { j.http = r.status; if (typeof j.needs_you === 'number') drawNeeds(j.needs_you); return j; }); });
249
+ }
250
+
251
+ // The yeses still waiting past the card on screen: one small line, and a
252
+ // tap opens the job deck with the ones that need the owner first.
253
+ function drawNeeds(n) {
254
+ var btn = document.getElementById('needs');
255
+ btn.hidden = !n;
256
+ btn.textContent = n ? (n === 1 ? '1 needs you' : n + ' need you') : '';
227
257
  }
228
258
 
229
259
  // ---- the one line under the orb: fades out, changes, fades in ------------------
@@ -274,6 +304,11 @@ body.has-card .foot { position: static; margin-top: 28px; }
274
304
  // the fast reveal stays contained: the lava grows to BLOOM of the smaller
275
305
  // window side and never washes the page
276
306
  var FAST_GROW_MS = 460, FAST_SETTLE_MS = 440, BLOOM = 0.72;
307
+ // the owner acted on a card: one motion of about a second when the answer
308
+ // is in. The card fades while the small orb glides into the sphere, the
309
+ // dots spiral in at ACT_PACE, and the next card blooms where they land.
310
+ var ACT_LEAVE_MS = 160, ACT_GLIDE_MS = 180, ACT_BEAT_MS = 100, ACT_PACE = 0.24, ACT_GROW_MS = 180, ACT_SETTLE_MS = 260;
311
+ var quick = false;
277
312
  // Two speeds, chosen by truth. A bet already waiting in the queue plays
278
313
  // the same curves at FAST of their length (tap to card in about two seconds, unhurried).
279
314
  // A bet still being made plays the full landing length, and the spiral
@@ -463,8 +498,8 @@ body.has-card .foot { position: static; margin-top: 28px; }
463
498
  function grow() {
464
499
  var rect = canvas.getBoundingClientRect();
465
500
  var contained = pace < 1;
466
- var growMs = contained ? FAST_GROW_MS : (GROW_MS * pace);
467
- var settleMs = contained ? FAST_SETTLE_MS : (SETTLE_MS * pace);
501
+ var growMs = quick ? ACT_GROW_MS : contained ? FAST_GROW_MS : (GROW_MS * pace);
502
+ var settleMs = quick ? ACT_SETTLE_MS : contained ? FAST_SETTLE_MS : (SETTLE_MS * pace);
468
503
  var vx = rect.left + cx, vy = rect.top + cy;
469
504
  var reach = contained
470
505
  ? BLOOM * Math.min(window.innerWidth, window.innerHeight) / 2
@@ -536,7 +571,9 @@ body.has-card .foot { position: static; margin-top: 28px; }
536
571
  // swap() takes the card off the page once it has faded. At the centre the
537
572
  // lava melts and the sphere grows back out of the point, holds for a
538
573
  // beat so it reads, then calls back. Reduced motion: no travel.
539
- function melt(cb, to, swap) {
574
+ // fast: the owner acted, so the glide is the short one
575
+ function melt(cb, to, swap, fast) {
576
+ var leaveMs = fast ? ACT_LEAVE_MS : LEAVE_MS, glideMs = fast ? ACT_GLIDE_MS : GLIDE_MS, beatMs = fast ? ACT_BEAT_MS : BEAT_MS;
540
577
  lava.classList.remove('tap', 'alive');
541
578
  lava.tabIndex = -1;
542
579
  var swapped = false;
@@ -553,7 +590,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
553
590
  mode = 'idle';
554
591
  bridgeAt = reduced ? 0 : performance.now();
555
592
  start();
556
- setTimeout(cb, reduced ? 0 : BEAT_MS);
593
+ setTimeout(cb, reduced ? 0 : beatMs);
557
594
  };
558
595
  if (reduced || lava.style.visibility !== 'visible') { reform(); return; }
559
596
  var r = lava.getBoundingClientRect();
@@ -564,9 +601,9 @@ body.has-card .foot { position: static; margin-top: 28px; }
564
601
  var sEnd = 30 / base;
565
602
  var t0 = performance.now();
566
603
  var step = function (now) {
567
- var k = Math.min(1, (now - t0) / GLIDE_MS);
604
+ var k = Math.min(1, (now - t0) / glideMs);
568
605
  var t = ease(k);
569
- if (now - t0 >= LEAVE_MS) takeOff();
606
+ if (now - t0 >= leaveMs) takeOff();
570
607
  place(from.s + (sEnd - from.s) * t, from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
571
608
  core.style.opacity = String(smooth(0.3, 0.9, k));
572
609
  lava.style.opacity = String(1 - smooth(0.7, 1, k));
@@ -612,11 +649,14 @@ body.has-card .foot { position: static; margin-top: 28px; }
612
649
 
613
650
  return {
614
651
  // the bet is ready: the homepage tap, on the clock
615
- play: function (cb, fast) {
652
+ // act: the owner acted on a card, so it is the one quick motion
653
+ play: function (cb, fast, act) {
616
654
  onBorn = cb;
655
+ quick = Boolean(act);
617
656
  if (reduced) { settled(); return; }
618
- pace = fast ? FAST : 1;
619
- if (mode !== 'pull') startPull(fast);
657
+ pace = act ? ACT_PACE : fast ? FAST : 1;
658
+ // after an act the dots spiral in from where the re-formed sphere is
659
+ if (mode !== 'pull') startPull(fast || Boolean(act));
620
660
  timed = true;
621
661
  start();
622
662
  },
@@ -643,181 +683,191 @@ body.has-card .foot { position: static; margin-top: 28px; }
643
683
  };
644
684
  })();
645
685
 
646
- // ---- the bet ------------------------------------------------------------------
647
- var bet = { status: null, polling: false, armed: false, drawn: null, busy: false, last: null, watching: null };
648
- // how long "builder has it" (or any ack) stays before the next card
686
+ // ---- the card: one renderer, one way to act --------------------------------------
687
+ // The page draws exactly the card the Mac served, with draw(card), and
688
+ // nothing else draws a card. Its options and its box come only from that
689
+ // card, and nothing from the card before survives a draw. Every tap posts
690
+ // the card's id and the option's id, or the words typed in its box, to
691
+ // /card; the Mac refuses anything else, and its answer is the next card.
692
+ var bet = { shown: null, watching: null, tapAt: 0 };
693
+ // while an act or a turn is under way the card takes no taps; the page
694
+ // marks it on the body too, so anyone reading the page can tell
695
+ var busyNow = false;
696
+ Object.defineProperty(bet, 'busy', { get: function () { return busyNow; }, set: function (v) { busyNow = Boolean(v); document.body.toggleAttribute('data-busy', busyNow); } });
697
+ // how long a line like "builder has it" stays under the orb before the next card
649
698
  var AFTER_MS = 1500;
699
+ // a tapped option holds as chosen this long before the motion starts
700
+ var HOLD_MS = 150;
701
+ // what turns in place instead of the spiral: flipping the deck, more
702
+ // models, and who a swap is for are the same card moving, not an answer
703
+ var IN_PLACE = ['flip', 'more', 'scope'];
650
704
 
651
705
  function buzz() { if (navigator.vibrate) { try { navigator.vibrate(10); } catch (e) { /* not on this device */ } } }
652
706
 
653
- // after a yes or a nice, the next card comes the way a re-tap brings it
654
- function moveOn(ack) {
655
- var box = document.getElementById('bet');
656
- box.style.minHeight = box.offsetHeight + 'px';
657
- var act = box.querySelector('.act');
658
- var line = box.querySelector('.sayline');
659
- if (line) line.remove();
660
- if (act) act.replaceWith(el('p', 'mission', ack));
661
- else box.appendChild(el('p', 'mission', ack));
662
- setTimeout(function () { tapOrb(false); }, AFTER_MS);
663
- }
664
-
665
- function armedTap(go, label, armedLabel, run) {
666
- go.addEventListener('click', function () {
667
- buzz();
668
- if (!bet.armed) {
669
- bet.armed = true; go.textContent = armedLabel; go.className = 'pill armed';
670
- setTimeout(function () { if (bet.armed) { bet.armed = false; go.textContent = label; go.className = 'pill'; } }, 5000);
671
- return;
707
+ // a failed fetch reads like a refusal, so nothing ever hangs
708
+ function offline() { return { ok: false, http: 0, message: 'that did not reach the mac. try again.' }; }
709
+
710
+ function box() { return document.getElementById('bet'); }
711
+
712
+ // ---- draw: the one place a card becomes the page -----------------------------------
713
+ function draw(card) {
714
+ var b = box();
715
+ b.textContent = '';
716
+ b.setAttribute('data-card', card.id);
717
+ b.setAttribute('data-kind', card.kind);
718
+ document.body.classList.toggle('wide', Boolean(card.table));
719
+ if (card.count) b.appendChild(el('span', 'count', card.count));
720
+ if (card.label) b.appendChild(el('span', 'label ' + card.label.replace(/ /g, '-'), card.label));
721
+ var title = b.appendChild(el('h1', null, card.title));
722
+ // a reply of more than one line reads at the body size, in the same spot
723
+ if (card.kind === 'reply') {
724
+ var lines = document.createRange();
725
+ lines.selectNodeContents(title);
726
+ if (lines.getClientRects().length > 1) title.classList.add('said');
727
+ }
728
+ if (card.body) b.appendChild(el('p', 'why', card.body));
729
+ // a reply keeps the title's spot; the turns so far sit quietly under it
730
+ if (card.thread && card.thread.length) {
731
+ var thread = b.appendChild(el('div', 'thread'));
732
+ card.thread.forEach(function (t) { thread.appendChild(el('p', 'turn ' + (t.who === 'owner' ? 'you' : 'orb'), t.text)); });
733
+ }
734
+ // who it is and what they do; the rating and the model live on the team table
735
+ if (card.job && !card.table) b.appendChild(el('p', 'jobline', [card.job.member, card.job.role].filter(Boolean).join(' · ')));
736
+ var inCells = card.options.filter(function (o) { return o.cell; });
737
+ if (card.table) b.appendChild(drawTable(card, inCells));
738
+ if (card.time) b.appendChild(el('span', 'time', card.time));
739
+ if (card.line) b.appendChild(el('p', 'mission', card.line));
740
+ // the options in the card's own order; quiet ones next to each other share one line
741
+ var rest = card.options.filter(function (o) { return !o.cell; });
742
+ var quiet = function (o) { return !o.style || o.style === 'quiet'; };
743
+ var act = el('div', 'act' + (rest.some(function (o) { return o.style === 'choice' || o.style === 'option'; }) ? ' answers' : ''));
744
+ var links = null;
745
+ var group = null;
746
+ rest.forEach(function (o) {
747
+ if (!quiet(o)) { links = null; act.appendChild(optionButton(o)); return; }
748
+ // a group of quiet options shares one line, headed by its words
749
+ if (links && (o.group || null) === group) links.appendChild(document.createTextNode(' · '));
750
+ else {
751
+ links = act.appendChild(el('div', 'links'));
752
+ group = o.group || null;
753
+ if (group) links.appendChild(document.createTextNode(group + ': '));
672
754
  }
673
- bet.armed = false;
674
- run();
755
+ links.appendChild(optionButton(o));
675
756
  });
757
+ // a quiet line under the options, empty until something does not go through
758
+ act.appendChild(el('p', 'links note'));
759
+ b.appendChild(act);
760
+ if (card.input) b.appendChild(inputLine(card.input));
676
761
  }
677
762
 
678
- // a line that ends like a sentence
679
- function sentence(t) {
680
- var s = String(t || '').trim();
681
- return s && !/[.!?]$/.test(s) ? s + '.' : s;
682
- }
683
-
684
- // a yes the owner owes, or a finished business move: what is going on,
685
- // then why it matters, both written by the server
686
- function drawJobCard(s) {
687
- var box = document.getElementById('bet');
688
- box.textContent = '';
689
- var j = s.job;
690
- box.appendChild(el('h1', null, j.what));
691
- if (j.why) box.appendChild(el('p', 'why', j.why));
692
- var act = el('div', 'act');
693
- if (s.kind === 'yes') {
694
- var yes = el('button', 'pill', 'yes');
695
- yes.type = 'button';
696
- armedTap(yes, 'yes', 'tap again to say yes', function () {
697
- api('/bet/yes', { confirm: true }).then(function (r) { moveOn(r.ack || r.message || 'sent'); });
698
- });
699
- act.appendChild(yes);
700
- } else if (s.kind === 'wait') {
701
- // one calm line: the idea turns in on its own once it is written
702
- watchWait();
703
- box.appendChild(sayBox());
704
- return;
705
- } else {
706
- var ok = el('button', 'pill', 'nice');
707
- ok.type = 'button';
708
- ok.addEventListener('click', function () {
709
- buzz();
710
- api('/bet/nice', {}).then(function (r) { moveOn(r.ack || 'nice.'); });
763
+ // a table: one row per line, a line between every cell; an option with a
764
+ // cell is drawn in that cell, and a tap anywhere on its row opens the
765
+ // row's first option
766
+ function drawTable(card, inCells) {
767
+ var t = card.table;
768
+ var grid = el('div', 'grid');
769
+ var table = grid.appendChild(el('table', 'k-' + card.kind));
770
+ var head = el('tr');
771
+ t.columns.forEach(function (c) { head.appendChild(el('th', c, c)).scope = 'col'; });
772
+ table.appendChild(el('thead')).appendChild(head);
773
+ var body = table.appendChild(el('tbody'));
774
+ t.rows.forEach(function (row, i) {
775
+ var mine = inCells.filter(function (o) { return o.cell.row === i; });
776
+ var tr = body.appendChild(el('tr', mine.length ? 'row' : null));
777
+ row.forEach(function (cell, j) {
778
+ var col = t.columns[j];
779
+ var td = tr.appendChild(el('td', col + (col === 'status' && t.status ? ' ' + String(t.status[i] || '').replace(/ /g, '-') : '')));
780
+ var o = mine.filter(function (x) { return x.cell.column === col; })[0];
781
+ if (o) {
782
+ var btn = td.appendChild(optionButton(o, true));
783
+ btn.addEventListener('click', function (e) { e.stopPropagation(); });
784
+ } else td.textContent = cell;
711
785
  });
712
- act.appendChild(ok);
713
- }
714
- box.appendChild(act);
715
- box.appendChild(sayBox());
716
- }
717
-
718
- // While the calm line shows, look every WAIT_LOOK_MS for the idea; once the
719
- // server has one ready, it is asked for the way a tap does and turns in
720
- // with one crossfade. No tap, and nothing moves on the page while it
721
- // waits. It also waits while the owner types there (the box has the
722
- // cursor or any words), so it comes in after they send, or once they
723
- // clear the box and leave it; and on the big orb, it waits for their tap.
724
- var WAIT_LOOK_MS = 1500;
725
- function typingOnCard() {
726
- var input = document.getElementById('bet').querySelector('.field input');
727
- return Boolean(input && (input === document.activeElement || input.value.trim()));
728
- }
729
- function watchWait() {
730
- if (bet.watching) return;
731
- var quiet = function () { return !bet.busy && document.body.classList.contains('has-card') && !typingOnCard(); };
732
- bet.watching = setInterval(function () {
733
- if (!bet.last || bet.last.kind !== 'wait') { clearInterval(bet.watching); bet.watching = null; return; }
734
- if (!quiet()) return;
735
- api('/bet').then(function (s) {
736
- if (s.http !== 200 || !quiet() || !bet.last || bet.last.kind !== 'wait') return;
737
- if (s.id !== bet.last.id && s.kind !== 'wait' && isCard(s)) { bet.status = s.status; turn(s); return; }
738
- if (s.kind === 'wait' && s.queue > 0) think(false, 0);
739
- }).catch(function () { /* the next look tries again */ });
740
- }, WAIT_LOOK_MS);
786
+ if (mine.length) tr.addEventListener('click', function () { var first = tr.querySelector('button'); if (first) first.click(); });
787
+ });
788
+ return grid;
741
789
  }
742
790
 
743
- // no bet was ready: one question, then what is going on and why the answer matters
744
- function drawQuestionCard(s) {
745
- var box = document.getElementById('bet');
746
- box.textContent = '';
747
- var q = s.question;
748
- box.appendChild(el('h1', null, q.ask));
749
- if (q.line) box.appendChild(el('p', 'why', q.line));
750
- var act = el('div', 'act answers');
751
- q.choices.forEach(function (c) {
752
- var b = el('button', 'choice', c.label);
753
- b.type = 'button';
754
- b.addEventListener('click', function () {
791
+ // One option, as the card says: big or quiet, a second tap when it asks
792
+ // for one, a box when it takes words. bare: no class of its own (in a cell).
793
+ function optionButton(o, bare) {
794
+ var cls = bare ? null : o.style === 'primary' ? 'pill' : o.style === 'choice' ? 'choice' : o.style === 'option' ? 'option' : null;
795
+ var btn = el('button', cls);
796
+ btn.type = 'button';
797
+ btn.setAttribute('data-option', o.id);
798
+ if (o.style === 'option' || (o.detail && o.style === 'choice')) {
799
+ btn.appendChild(el('span', 't', o.label));
800
+ if (o.detail) btn.appendChild(el('span', 'w', o.detail));
801
+ } else btn.textContent = o.label;
802
+ if (o.chosen) btn.classList.add('chosen');
803
+ if (o.type) btn.classList.add('other');
804
+ if (o.action === 'none') { btn.disabled = true; return btn; }
805
+ if (o.confirm) {
806
+ // the first tap arms this one button; the second sends; it goes quiet after five seconds
807
+ var armed = false, quiet = null;
808
+ btn.addEventListener('click', function () {
755
809
  if (bet.busy) return;
756
810
  buzz();
757
- // typed words are read first, so a question back is explained and nothing is saved
758
- if (c.other) { typeHere(b, c.label, 60, function (text, field) { say(field, { text: text }); }); return; }
759
- answer(b, { choice: c.id });
811
+ if (!armed) {
812
+ armed = true; btn.textContent = o.confirm; btn.classList.add('armed');
813
+ quiet = setTimeout(function () { armed = false; btn.textContent = o.label; btn.classList.remove('armed'); }, 5000);
814
+ return;
815
+ }
816
+ clearTimeout(quiet);
817
+ armed = false;
818
+ send(btn, { option_id: o.id, confirm: true }, o);
760
819
  });
761
- if (c.other) b.classList.add('other');
762
- act.appendChild(b);
820
+ return btn;
821
+ }
822
+ btn.addEventListener('click', function () {
823
+ if (bet.busy) return;
824
+ buzz();
825
+ if (o.type) { typeHere(btn, o.type.placeholder || o.label, o.type.max || 280, function (text, field) { send(field, { option_id: o.id, text: text }, o); }); return; }
826
+ send(btn, { option_id: o.id }, o);
763
827
  });
764
- // a quiet line under the answers, empty until an answer does not go through
765
- act.appendChild(el('div', 'links'));
766
- box.appendChild(act);
767
- // its own something else box is the one place to type
768
- if (!q.choices.some(function (c) { return c.other; })) box.appendChild(sayBox());
828
+ return btn;
769
829
  }
770
830
 
771
- // ---- answering in place: the eyes stay in one spot ----------------------------
772
- // A tapped answer, or the words typed in its place, holds that spot as
773
- // chosen for HOLD_MS while the rest of the card dims and stays put. The
774
- // next card is asked for during the hold and turns in once it is over.
775
- var HOLD_MS = 400;
776
-
777
- // a failed fetch reads like a refusal, so a hold never hangs
778
- function offline() { return { ok: false, http: 0 }; }
831
+ // the card's box: a quiet line that turns into the field in its own spot
832
+ function inputLine(input) {
833
+ var wrap = el('div', 'links sayline');
834
+ var open = wrap.appendChild(el('button', 'quiet', input.label || 'or say something'));
835
+ open.type = 'button';
836
+ open.addEventListener('click', function () {
837
+ if (bet.busy) return;
838
+ typeHere(open, input.placeholder || 'say something', input.max || 280, function (text, field) { send(field, { text: text }, null); });
839
+ });
840
+ return wrap;
841
+ }
779
842
 
780
- // every answer row of the card dims but the one in use; no spot, none
843
+ // ---- answering in place: the eyes stay in one spot ---------------------------------
844
+ // every row of the card dims but the one in use; no spot, none
781
845
  function dimOthers(spot) {
782
- var box = document.getElementById('bet');
783
- box.querySelectorAll('.act > *, .options > *, .sayline, :scope > .links').forEach(function (n) {
784
- n.classList.toggle('muted', Boolean(spot) && n !== spot && !n.contains(spot));
846
+ box().querySelectorAll('.act > *, .act .links > button, .sayline, .grid').forEach(function (n) {
847
+ n.classList.toggle('muted', Boolean(spot) && n !== spot && !n.contains(spot) && !spot.contains(n));
785
848
  });
786
849
  }
787
850
 
788
- // the spot holds as chosen and the card takes no taps; returns when it
789
- // started, which is also where the time to the next card counts from
851
+ // the spot holds as chosen and the card takes no taps; returns when it started
790
852
  function hold(spot) {
791
- var box = document.getElementById('bet');
792
853
  dimOthers(spot);
793
854
  spot.classList.add('chosen');
794
- box.classList.add('holding');
855
+ box().classList.add('holding');
795
856
  var input = spot.querySelector('input');
796
857
  if (input) { input.readOnly = true; input.blur(); }
797
858
  bet.busy = true;
798
- bet.armed = false;
799
859
  bet.tapAt = performance.now();
800
860
  document.body.removeAttribute('data-reveal-ms');
801
861
  return bet.tapAt;
802
862
  }
803
863
 
804
- // it did not go through: the card is as it was
805
- function unhold() {
806
- var box = document.getElementById('bet');
807
- box.classList.remove('holding');
808
- dimOthers(null);
809
- box.querySelectorAll('.chosen').forEach(function (n) { n.classList.remove('chosen'); });
810
- bet.busy = false;
811
- }
812
-
813
- // resolves once the hold that started at heldAt has run HOLD_MS
814
864
  function afterHold(heldAt) {
815
865
  var left = heldAt ? heldAt + HOLD_MS - performance.now() : 0;
816
866
  return new Promise(function (resolve) { setTimeout(resolve, Math.max(0, left)); });
817
867
  }
818
868
 
819
869
  // The tapped button becomes the field at its own size and spot, with the
820
- // cursor already in it, and the other answers dim. Enter or send hands
870
+ // cursor already in it, and the other options dim. Enter or send hands
821
871
  // the words to sent(text, field). Left empty, it turns back into the button.
822
872
  function typeHere(btn, placeholder, maxLength, sent) {
823
873
  var field = el('div', btn.classList.contains('choice') ? 'choice field' : 'field');
@@ -829,18 +879,17 @@ body.has-card .foot { position: static; margin-top: 28px; }
829
879
  input.placeholder = placeholder;
830
880
  input.enterKeyHint = 'send';
831
881
  input.setAttribute('aria-label', placeholder);
832
- var send = el('button', 'send', 'send');
833
- send.type = 'button';
882
+ var go = el('button', 'send', 'send');
883
+ go.type = 'button';
834
884
  var submit = function () {
835
885
  var text = input.value.trim();
836
886
  if (text && !bet.busy) sent(text, field);
837
887
  };
838
888
  // a press on send keeps the cursor in the field, so it never closes first
839
- send.addEventListener('mousedown', function (e) { e.preventDefault(); });
840
- send.addEventListener('click', function () { buzz(); submit(); });
889
+ go.addEventListener('mousedown', function (e) { e.preventDefault(); });
890
+ go.addEventListener('click', function () { buzz(); submit(); });
841
891
  input.addEventListener('keydown', function (e) {
842
892
  if (e.key === 'Enter') { e.preventDefault(); submit(); }
843
- if (e.key === 'Escape') { input.value = ''; input.blur(); }
844
893
  });
845
894
  input.addEventListener('blur', function () {
846
895
  if (input.value.trim() || field.classList.contains('chosen') || !field.isConnected) return;
@@ -848,370 +897,130 @@ body.has-card .foot { position: static; margin-top: 28px; }
848
897
  dimOthers(null);
849
898
  });
850
899
  field.appendChild(input);
851
- field.appendChild(send);
900
+ field.appendChild(go);
852
901
  btn.replaceWith(field);
853
902
  dimOthers(field);
854
903
  input.focus();
904
+ return field;
855
905
  }
856
906
 
857
- // An answer to a question holds as chosen while it saves. If it does not
858
- // go through, the card comes back as it was, the words still in the
859
- // field, and why takes the hint's line under the answers.
860
- function answer(spot, body) {
861
- body.card = shownCard();
862
- var heldAt = hold(spot);
863
- api('/bet/answer', body).catch(offline).then(function (r) {
864
- if (r.ok) { think(false, heldAt); return; }
865
- unhold();
866
- var hint = document.getElementById('bet').querySelector('.act .links');
867
- if (hint) hint.textContent = r.message || 'that did not go through';
868
- var input = spot.querySelector('input');
869
- if (input) { input.readOnly = false; dimOthers(spot); input.focus(); }
870
- });
871
- }
872
-
873
- // the card the owner is looking at, named with every answer and every
874
- // word said, so the server acts on that card or on nothing
875
- function shownCard() { return bet.last && bet.last.id ? bet.last.id : ''; }
876
-
877
- // Words said on a card hold their spot as chosen while the server reads
878
- // them. An answer or an instruction brings the next card; a question
879
- // back or a second tap it needs turns in as the orb's reply, in the
880
- // title's spot. If it did not send, the words stay where they were.
881
- // One id per typed send: made when these words are first sent from this
882
- // spot, the same on every retry of them, and new when the words change,
883
- // so a send whose reply was lost is never sent twice. getRandomValues
907
+ // One id per send: made when these words are first sent from this card's
908
+ // option or box, the same on every retry of them there, and new for any
909
+ // other card, option, or words, so a send whose answer was lost is never
910
+ // sent twice and the same words to someone else always go. getRandomValues
884
911
  // works on the plain http of the wifi link, where randomUUID does not.
885
- function sendId(spot, text) {
886
- if (spot.sendText !== text) {
912
+ var sent = { key: null, id: null };
913
+ function sendId(key) {
914
+ if (sent.key !== key) {
887
915
  var n = new Uint32Array(2);
888
916
  crypto.getRandomValues(n);
889
- spot.sendText = text;
890
- spot.sendId = ('000000' + n[0].toString(36)).slice(-7) + ('000000' + n[1].toString(36)).slice(-7);
917
+ sent.key = key;
918
+ sent.id = ('000000' + n[0].toString(36)).slice(-7) + ('000000' + n[1].toString(36)).slice(-7);
891
919
  }
892
- return spot.sendId;
893
- }
894
-
895
- // while the words are read, one quiet ellipsis in the held spot: where
896
- // send was in a box, or after a tapped reply. Nowhere else.
897
- function reading(spot) {
898
- var send = spot.querySelector('.send');
899
- if (send) { send.textContent = '\u2026'; send.classList.add('reading'); return; }
900
- spot.appendChild(el('span', 'reading', ' \u2026'));
901
- }
902
- function doneReading(spot) {
903
- var send = spot.querySelector('.send.reading');
904
- if (send) { send.textContent = 'send'; send.classList.remove('reading'); }
905
- var dots = spot.querySelector('span.reading');
906
- if (dots) dots.remove();
920
+ return sent.id;
907
921
  }
908
922
 
909
- function say(spot, body) {
910
- body.card = shownCard();
911
- body.send = sendId(spot, body.text);
923
+ // ---- acting: one post, then one motion to the next card ------------------------------
924
+ // The option holds as chosen for HOLD_MS. Then the card fades while the
925
+ // small orb glides into the sphere; once the answer is in, the dots
926
+ // spiral in and the next card blooms where they land. An answer that is
927
+ // late leaves the sphere in its idle drift until it lands. A line from the
928
+ // Mac (who has it now) shows under the sphere while it waits. When it did
929
+ // not go through, the same card comes back with the words still in its
930
+ // box and one line why. Flipping the deck turns the card in place.
931
+ function send(spot, body, option) {
932
+ if (!bet.shown) return;
933
+ var from = bet.shown;
934
+ body.card_id = from.id;
935
+ if (body.text) body.send = sendId([from.id, option ? option.id : 'the box', body.text].join(' | '));
912
936
  var heldAt = hold(spot);
913
- reading(spot);
914
- api('/bet/say', body).catch(offline).then(function (r) {
915
- if (r.ok && !r.stay) { think(false, heldAt); return; }
916
- if (!r.ok && r.reason !== 'needs_confirm') { unsent(spot, r.message || 'that did not send. tap send to try again'); return; }
917
- afterHold(heldAt).then(function () { crossfade(function () { drawReply(r, body.text); }); });
918
- });
919
- }
920
-
921
- // It did not send: the card is as it was, the words still in the box with
922
- // the cursor in it, and one short line under it says why, so Enter (or
923
- // the same tap) tries again. Left empty, the box turns back into the
924
- // button and the line goes with it.
925
- function unsent(spot, why) {
926
- doneReading(spot);
927
- unhold();
928
- var note = spot.nextElementSibling;
929
- var input = spot.querySelector('input');
930
- if (!note || !note.classList.contains('unsent')) {
931
- note = el('p', 'links unsent');
932
- spot.after(note);
933
- if (input) input.addEventListener('blur', function () { if (!spot.isConnected) note.remove(); });
934
- }
935
- note.textContent = why;
936
- if (input) { input.readOnly = false; dimOthers(spot); input.focus(); }
937
- }
938
-
939
- // one reply to tap: it holds as chosen and is said
940
- function sayChoice(label, body) {
941
- var b = el('button', 'choice', label);
942
- b.type = 'button';
943
- b.addEventListener('click', function () {
944
- if (bet.busy) return;
945
- buzz();
946
- say(b, body);
947
- });
948
- return b;
949
- }
950
-
951
- // the orb's reply is the title, with the card's own answer right under it
952
- // (do it, yes, nice, or the answers), then up to three suggested replies;
953
- // starting a member who is not running takes one more tap, or it is kept
954
- // as a note. The say line takes anything else.
955
- function drawReply(r, said) {
956
- var box = document.getElementById('bet');
957
- // a reply never takes the card's own answer away: the card is drawn
958
- // once more, and its row is kept, buttons and all
959
- var own = null;
960
- if (bet.last) { drawBetCard(bet.last); own = box.querySelector(':scope > .act, :scope > .options'); }
961
- box.textContent = '';
962
- bet.drawn = null;
963
- var title = el('h1', null, r.stay ? r.reply : (r.message || 'that did not go through'));
964
- box.appendChild(title);
965
- // more than one line reads at the body size, in the same spot
966
- var lines = document.createRange();
967
- lines.selectNodeContents(title);
968
- if (lines.getClientRects().length > 1) title.classList.add('said');
969
- if (own) box.appendChild(own);
970
- var act = el('div', 'act answers');
971
- var have = own ? Array.prototype.map.call(own.querySelectorAll('button'), function (b) { return b.textContent.trim().toLowerCase(); }) : [];
972
- if (r.stay) (r.suggestions || []).forEach(function (sug) {
973
- if (have.indexOf(String(sug).trim().toLowerCase()) < 0) act.appendChild(sayChoice(sug, { text: sug }));
974
- });
975
- if (r.reason === 'needs_confirm') {
976
- act.appendChild(sayChoice('start it', { text: said, confirm: true }));
977
- act.appendChild(sayChoice('keep it as a note instead', { text: said, note: true }));
937
+ var answer = api('/act', body).catch(offline);
938
+ if (option && IN_PLACE.indexOf(option.action) >= 0) {
939
+ Promise.all([answer, afterHold(heldAt)]).then(function (both) {
940
+ var r = both[0];
941
+ if (!r.ok || !r.card) { bet.busy = false; unsent(from, r, body.text, option); return; }
942
+ turnTo(r.card);
943
+ });
944
+ return;
978
945
  }
979
- box.appendChild(act);
980
- if (!own || !own.querySelector('.other')) box.appendChild(sayBox());
946
+ afterHold(heldAt).then(function () { flow(answer, from, body.text, option); });
981
947
  }
982
948
 
983
- // the owner's own number, once a week: one box and one tap to save
984
- function drawReportCard(s) {
985
- var box = document.getElementById('bet');
986
- box.textContent = '';
987
- box.appendChild(el('h1', null, s.report.what));
988
- box.appendChild(el('p', 'why', s.report.why));
989
- var act = el('div', 'act answers');
990
- var input = el('input', 'say');
991
- input.type = 'number';
992
- input.inputMode = 'decimal';
993
- input.setAttribute('aria-label', s.report.what);
994
- var save = el('button', 'pill', 'save');
995
- save.type = 'button';
996
- var go = function () {
997
- if (!String(input.value).trim()) return;
998
- save.disabled = true;
999
- api('/bet/report', { value: input.value }).then(function (r) {
1000
- if (r.ok) { moveOn(r.ack); return; }
1001
- save.disabled = false;
1002
- input.value = '';
1003
- input.placeholder = r.message || 'that did not go through';
1004
- });
949
+ function flow(answer, from, typed, option) {
950
+ var landed = null;
951
+ var sphere = false;
952
+ var lineAt = 0;
953
+ var land = function () {
954
+ if (!landed || !sphere) return;
955
+ var r = landed;
956
+ landed = null;
957
+ var wait = r.ack && lineAt ? Math.max(0, AFTER_MS - (performance.now() - lineAt)) : 0;
958
+ setTimeout(function () {
959
+ // nothing more to show: the sphere stays, with its line
960
+ if (r.ok && (r.home || !r.card)) { bet.busy = false; bet.shown = null; line.set(r.home ? 'not now. tap when you want another' : 'tap to think', false); return; }
961
+ // the card the Mac has on screen now is the one that comes back; a
962
+ // refusal comes back on it with one line why
963
+ var then = r.card || from;
964
+ if (!then) { bet.busy = false; line.set('that did not go through. tap to try again', true); return; }
965
+ if (!r.ok) { spiralTo(then, function () { unsent(then, r, typed, then.id === (from && from.id) ? option : null); }); return; }
966
+ spiralTo(then, null);
967
+ }, wait);
1005
968
  };
1006
- save.addEventListener('click', go);
1007
- input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } });
1008
- act.appendChild(input);
1009
- act.appendChild(save);
1010
- // a quiet way out: moves on, saves nothing, and is not asked again today
1011
- var links = el('div', 'links');
1012
- var later = el('button', null, 'not now');
1013
- later.type = 'button';
1014
- later.addEventListener('click', function () { if (bet.busy) return; buzz(); think(false, 0); });
1015
- links.appendChild(later);
1016
- act.appendChild(links);
1017
- box.appendChild(act);
1018
- box.appendChild(sayBox());
1019
- }
1020
-
1021
- // A conversation in one box. While the words are read the reply spot shows
1022
- // a quiet ellipsis; a question back is answered in place, with the turns so
1023
- // far above it and up to three suggested replies to tap. Starting a member
1024
- // who is not running takes a second tap, or it is kept as a note.
1025
- // post(body) sends it; done(ack) runs when something real came of it.
1026
- function convo(post, done, placeholder) {
1027
- var wrap = el('div', 'links sayline');
1028
- var thread = el('div', 'thread');
1029
- var reply = el('p', 'why reply');
1030
- reply.hidden = true;
1031
- var input = el('input', 'say');
1032
- input.type = 'text';
1033
- input.maxLength = 280;
1034
- input.setAttribute('aria-label', placeholder);
1035
- input.placeholder = placeholder;
1036
- var send = el('button', 'pill', 'send');
1037
- send.type = 'button';
1038
- var extra = el('div', 'links');
1039
- var pending = null;
1040
- // an armed second tap belongs to the words it was asked about: any edit clears it
1041
- function disarm() {
1042
- pending = null;
1043
- send.textContent = 'send';
1044
- send.className = 'pill';
1045
- extra.textContent = '';
1046
- reply.textContent = '';
1047
- reply.hidden = true;
1048
- }
1049
- function drawThread(turns) {
1050
- thread.textContent = '';
1051
- turns.forEach(function (t) { thread.appendChild(el('p', 'turn ' + (t.who === 'owner' ? 'you' : 'orb'), t.text)); });
1052
- }
1053
- function go(more, said) {
1054
- var text = said || input.value.trim();
1055
- if (!text) return;
1056
- send.disabled = true;
1057
- reply.hidden = false;
1058
- reply.textContent = '\u2026';
1059
- extra.textContent = '';
1060
- // the same words carry the same id on every try, so a retry never sends twice
1061
- var body = { text: text, send: sendId(wrap, text) };
1062
- if (more) Object.keys(more).forEach(function (k) { body[k] = more[k]; });
1063
- post(body).catch(offline).then(function (r) {
1064
- if (r.ok && !r.stay) { done(r.ack); return; }
1065
- send.disabled = false;
1066
- if (r.stay) { reply.textContent = r.reply;
1067
- input.value = '';
1068
- drawThread(r.thread.slice(0, -1));
1069
- (r.suggestions || []).forEach(function (sug) {
1070
- var b = el('button', 'quiet', sug);
1071
- b.type = 'button';
1072
- b.addEventListener('click', function () { buzz(); go(null, sug); });
1073
- extra.appendChild(b);
1074
- });
1075
- return;
1076
- }
1077
- if (r.reason === 'needs_confirm') {
1078
- pending = text;
1079
- reply.textContent = r.message;
1080
- send.textContent = 'tap again';
1081
- send.className = 'pill armed';
1082
- var keep = el('button', 'quiet', 'keep it as a note instead');
1083
- keep.type = 'button';
1084
- keep.addEventListener('click', function () { disarm(); go({ note: true }); });
1085
- extra.appendChild(keep);
1086
- return;
1087
- }
1088
- // it did not send: the words are in the box, send works again, and
1089
- // one short line says so
1090
- if (said && !input.value) input.value = said;
1091
- reply.textContent = r.message || 'that did not send. tap send to try again';
1092
- input.focus();
1093
- });
1094
- }
1095
- send.addEventListener('click', function () {
1096
- buzz();
1097
- if (pending) { disarm(); go({ confirm: true }); return; }
1098
- go();
969
+ answer.then(function (r) {
970
+ landed = r;
971
+ if (r.ok && r.ack) { lineAt = performance.now(); line.set(r.ack, false); }
972
+ land();
1099
973
  });
1100
- input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); send.click(); } });
1101
- input.addEventListener('input', function () { if (pending) disarm(); });
1102
- wrap.appendChild(thread);
1103
- wrap.appendChild(reply);
1104
- wrap.appendChild(input);
1105
- wrap.appendChild(send);
1106
- wrap.appendChild(extra);
1107
- return wrap;
974
+ leave(function () { sphere = true; land(); }, true);
1108
975
  }
1109
976
 
1110
- // a quiet line under every card: tap it and the line becomes the box, in
1111
- // its own spot. on a question it is the answer, on an idea a note to
1112
- // rework it, on a yes or a finished job a word to that member; the server
1113
- // reads it first, and a question back is answered
1114
- function sayBox() {
1115
- var wrap = el('div', 'links sayline');
1116
- var open = el('button', 'quiet', 'or say something');
1117
- open.type = 'button';
1118
- open.addEventListener('click', function () {
1119
- if (bet.busy) return;
1120
- typeHere(open, 'say something', 280, function (text, field) { say(field, { text: text }); });
1121
- });
1122
- wrap.appendChild(open);
1123
- return wrap;
977
+ // the card fades in place while the small orb glides down and becomes the
978
+ // sphere; the card leaves the page once it has faded
979
+ function leave(then, fast) {
980
+ var b = box();
981
+ var to = sphereSpot();
982
+ b.classList.remove('quick', 'acted', 'up');
983
+ b.classList.add('leave');
984
+ var swap = function () {
985
+ b.style.minHeight = '';
986
+ b.hidden = true;
987
+ b.classList.remove('leave', 'holding');
988
+ document.body.classList.remove('has-card', 'wide');
989
+ window.scrollTo(0, 0);
990
+ };
991
+ if (reduced) { swap(); orb.melt(then, to, function () {}, fast); return; }
992
+ orb.melt(then, to, swap, fast);
1124
993
  }
1125
994
 
1126
- // two or three ideas side by side: one tap picks one and opens its full card
1127
- function drawChoiceCard(s) {
1128
- var box = document.getElementById('bet');
1129
- box.textContent = '';
1130
- box.appendChild(el('h1', null, 'which one next?'));
1131
- var list = el('div', 'options');
1132
- s.choice.options.forEach(function (o) {
1133
- var b = el('button', 'option');
1134
- b.type = 'button';
1135
- b.appendChild(el('span', 't', o.title));
1136
- b.appendChild(el('span', 'w', o.why));
1137
- b.addEventListener('click', function () {
1138
- if (bet.busy) return;
1139
- buzz();
1140
- var heldAt = hold(b);
1141
- api('/bet/pick', { id: o.id }).then(function (r) {
1142
- if (r.http === 200 && r.bet) { afterHold(heldAt).then(function () { turn(r); }); return; }
1143
- think(false, heldAt);
1144
- }, function () { think(false, heldAt); });
1145
- });
1146
- list.appendChild(b);
1147
- });
1148
- box.appendChild(list);
1149
- box.appendChild(sayBox());
995
+ // from the sphere: the dots spiral in and the card blooms where they land
996
+ function spiralTo(card, after) {
997
+ line.set('', false);
998
+ if (reduced) { orb.settled(); showCard(card, false); bet.busy = false; if (after) after(); return; }
999
+ orb.play(function (phase) {
1000
+ // the card blooms where the dots land; what comes with it (a line why, the words back in their box) comes with it
1001
+ if (phase === 'cover') { showCard(card, true, true, true); if (after) after(); }
1002
+ if (phase === 'settled') bet.busy = false;
1003
+ }, true, true);
1150
1004
  }
1151
1005
 
1152
- function drawBetCard(s) {
1153
- if (s.kind === 'report') { drawReportCard(s); return; }
1154
- if (s.kind === 'choice') { drawChoiceCard(s); return; }
1155
- if (s.kind === 'question') { drawQuestionCard(s); return; }
1156
- if (s.kind && s.kind !== 'bet') { drawJobCard(s); return; }
1157
- var box = document.getElementById('bet');
1158
- box.textContent = '';
1159
- var b = s.bet;
1160
- box.appendChild(el('h1', null, b.title));
1161
- // why it matters: what is going on and what we expect, in the same big type
1162
- box.appendChild(el('p', 'why', [b.why_now, b.expect].map(sentence).join(' ')));
1163
- if (s.mission) { box.appendChild(el('p', 'mission', s.mission.line)); return; }
1164
- if (s.status === 'starting') { box.appendChild(el('p', 'mission', 'starting now')); return; }
1165
- var act = el('div', 'act');
1166
- var go = el('button', 'pill' + (bet.armed ? ' armed' : ''), bet.armed ? 'tap again to start' : 'do it');
1167
- go.type = 'button';
1168
- go.setAttribute('aria-label', bet.armed ? 'tap again to start the team on this idea' : 'do it. starts the team on the steps; your yes steps wait for you');
1169
- go.addEventListener('click', function () {
1170
- buzz();
1171
- if (!bet.armed) {
1172
- bet.armed = true; drawBetCard(s);
1173
- setTimeout(function () { if (bet.armed) { bet.armed = false; if (bet.last) drawBetCard(bet.last); } }, 5000);
1174
- return;
1175
- }
1176
- bet.armed = false;
1177
- s.status = 'starting'; s.unstarted = null; drawBetCard(s);
1178
- api('/bet/do', { confirm: true }).catch(offline).then(function (r) {
1179
- if (!r || !r.ok) {
1180
- // it did not get going: the card comes back as it was, do it and
1181
- // all, with one line why, and the page follows this card again
1182
- s.status = 'ready';
1183
- s.unstarted = (r && r.message) || 'that did not start. tap do it to try again';
1184
- bet.drawn = s.id;
1185
- drawBetCard(s);
1186
- refresh();
1187
- return;
1188
- }
1189
- loadBet(); refresh();
1190
- setTimeout(function () { tapOrb(false); }, AFTER_MS);
1191
- });
1192
- });
1193
- act.appendChild(go);
1194
- if (s.unstarted) act.appendChild(el('p', 'links unsent', s.unstarted));
1195
- var links = el('div', 'links');
1196
- var sharp = el('button', null, 'sharper idea');
1197
- sharp.type = 'button';
1198
- sharp.addEventListener('click', function () { tapOrb(true); });
1199
- var skip = el('button', null, 'not now');
1200
- skip.type = 'button';
1201
- skip.addEventListener('click', function () {
1202
- buzz();
1203
- api('/bet/skip', {}).then(function () {
1204
- dissolve(function () { bet.status = 'skipped'; line.set('not now. tap when you want another', false); });
1205
- });
1206
- });
1207
- links.appendChild(sharp);
1208
- links.appendChild(document.createTextNode(' · '));
1209
- links.appendChild(skip);
1210
- act.appendChild(links);
1211
- box.appendChild(act);
1212
- box.appendChild(sayBox());
1006
+ // It did not go through: this card as it was, one line why under its
1007
+ // options, and typed words back in their box with the cursor in it.
1008
+ function unsent(card, r, typed, option) {
1009
+ if (bet.shown !== card || box().getAttribute('data-card') !== card.id) showCard(card, false);
1010
+ box().classList.remove('holding');
1011
+ var note = box().querySelector('.act .note');
1012
+ // the Mac's own refusals are plain words; anything else reads as one plain line
1013
+ if (note) note.textContent = (r && r.http === 409 && r.message) || (r && r.http === 0 && r.message) || 'that did not go through. try again.';
1014
+ if (!typed) return;
1015
+ // the words go back where they were typed, or to this card's own box when the card is a new one
1016
+ var spot = option ? box().querySelector('[data-option="' + option.id + '"]') : box().querySelector('.sayline .quiet');
1017
+ if (!spot) return;
1018
+ if (!option && !card.input) return;
1019
+ var field = typeHere(spot, (option && option.type && option.type.placeholder) || (card.input && card.input.placeholder) || 'say something', 280, function (text, f) { send(f, option ? { option_id: option.id, text: text } : { text: text }, option); });
1020
+ field.querySelector('input').value = typed;
1213
1021
  }
1214
1022
 
1023
+ // ---- showing a card ---------------------------------------------------------------------
1215
1024
  // the time from the tap to the card fully on screen, kept on the page so
1216
1025
  // the speed can be measured
1217
1026
  function measured() {
@@ -1219,301 +1028,261 @@ body.has-card .foot { position: static; margin-top: 28px; }
1219
1028
  bet.tapAt = 0;
1220
1029
  }
1221
1030
 
1222
- function showCard(s, animate, fast) {
1223
- var box = document.getElementById('bet');
1224
- bet.last = s;
1225
- bet.drawn = s.id;
1226
- drawBetCard(s);
1031
+ // fast: the quick fade from the big orb; act: the short one after an act
1032
+ function showCard(card, animate, fast, act) {
1033
+ var b = box();
1034
+ bet.shown = card;
1035
+ // where it starts from is set first: drawing measures the title, and a
1036
+ // measure is a frame the card would otherwise start from somewhere else
1037
+ b.classList.remove('leave', 'holding');
1038
+ b.classList.toggle('quick', Boolean(fast) && !act);
1039
+ b.classList.toggle('acted', Boolean(act));
1040
+ b.classList.toggle('up', !animate);
1041
+ // on the page before it is drawn, so a long title can tell it runs past one line
1227
1042
  document.body.classList.add('has-card');
1228
- box.hidden = false;
1229
- box.classList.remove('leave', 'holding');
1230
- box.classList.toggle('quick', Boolean(fast));
1231
- if (!animate) { box.classList.add('up'); measured(); return; }
1232
- box.classList.remove('up');
1233
- var done = function (e) { if (e.propertyName === 'opacity') { box.removeEventListener('transitionend', done); measured(); } };
1234
- box.addEventListener('transitionend', done);
1235
- void box.offsetWidth;
1236
- requestAnimationFrame(function () { box.classList.add('up'); });
1043
+ b.hidden = false;
1044
+ draw(card);
1045
+ if (card.watch) watchWait(); else stopWatch();
1046
+ if (!animate) { measured(); return; }
1047
+ var done = function (e) { if (e.propertyName === 'opacity') { b.removeEventListener('transitionend', done); measured(); } };
1048
+ b.addEventListener('transitionend', done);
1049
+ void b.offsetWidth;
1050
+ requestAnimationFrame(function () { b.classList.add('up'); });
1237
1051
  }
1238
1052
 
1239
1053
  // where the sphere sits once the card is gone, read before anything moves,
1240
1054
  // in page coordinates (the screen's once the page is back at the top)
1241
1055
  function sphereSpot() {
1242
- var box = document.getElementById('bet');
1243
- var y0 = window.scrollY, was = box.hidden;
1244
- document.body.classList.remove('has-card');
1245
- box.hidden = true;
1056
+ var b = box();
1057
+ var y0 = window.scrollY, was = b.hidden, wide = document.body.classList.contains('wide');
1058
+ document.body.classList.remove('has-card', 'wide');
1059
+ b.hidden = true;
1246
1060
  var r = document.getElementById('orb').getBoundingClientRect();
1247
1061
  var spot = { x: r.left + r.width / 2, y: r.top + window.scrollY + r.height / 2 };
1248
1062
  document.body.classList.add('has-card');
1249
- box.hidden = was;
1063
+ if (wide) document.body.classList.add('wide');
1064
+ b.hidden = was;
1250
1065
  if (window.scrollY !== y0) window.scrollTo(0, y0);
1251
1066
  return spot;
1252
1067
  }
1253
1068
 
1254
- // the card fades out in place while the small orb glides down and becomes
1255
- // the sphere again; the card leaves the page once it has faded
1256
- function dissolve(then) {
1257
- var box = document.getElementById('bet');
1258
- var to = sphereSpot();
1259
- box.classList.remove('quick', 'up');
1260
- box.classList.add('leave');
1261
- var swap = function () {
1262
- box.style.minHeight = '';
1263
- box.hidden = true;
1264
- box.classList.remove('leave');
1265
- document.body.classList.remove('has-card');
1266
- window.scrollTo(0, 0);
1267
- };
1268
- if (reduced) { setTimeout(function () { orb.melt(then, to, swap); }, orb.leaveMs); return; }
1269
- orb.melt(then, to, swap);
1270
- }
1271
-
1272
- // what the page does with the server's answer once the sphere is ready.
1273
- // Every tap comes back with a card, so the orb plays straight into it.
1274
- function reveal(s, fast) {
1275
- bet.status = s.status;
1276
- if ((s.bet || s.job || s.question || s.choice || s.report) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting')) {
1277
- line.set('', false);
1278
- orb.play(function (phase) { if (phase === 'settled') { bet.busy = false; showCard(s, true, fast); } }, fast);
1279
- return;
1280
- }
1281
- bet.busy = false;
1282
- orb.relax();
1283
- line.set('tap to think', false);
1284
- }
1285
-
1286
- function isCard(s) {
1287
- return Boolean((s.bet || s.job || s.question || s.choice || s.report) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting'));
1288
- }
1289
-
1290
- // Card to card: a crossfade in place. The small orb stays where it is.
1291
- // A still copy of the old card fades over the box while the new card,
1292
- // drawn under it, fades in from DRIFT below over TURN_IN_MS, starting
1293
- // 80ms in, so its title lands where the old title was. The box eases to
1294
- // its new height. No layout jump.
1069
+ // Card to card in place: a still copy of the old card fades over the box
1070
+ // while the new card, drawn under it, fades in from DRIFT below over
1071
+ // TURN_IN_MS, starting 80ms in, so its title lands where the old title
1072
+ // was. The box eases to its new height. No layout jump.
1295
1073
  var TURN_IN_MS = 320, DRIFT = 4;
1296
- function turn(s) {
1297
- crossfade(function () { bet.last = s; bet.drawn = s.id; drawBetCard(s); });
1074
+ function turnTo(card) {
1075
+ var b = box();
1076
+ b.classList.remove('holding');
1077
+ bet.shown = card;
1078
+ turnInPlace(b, function () { draw(card); }, function () { bet.busy = false; measured(); if (card.watch) watchWait(); else stopWatch(); });
1298
1079
  }
1299
1080
 
1300
- // draw() puts the next card in the box
1301
- function crossfade(draw) {
1302
- var box = document.getElementById('bet');
1303
- box.classList.remove('holding');
1304
- if (reduced) { box.style.minHeight = ''; draw(); bet.busy = false; measured(); return; }
1305
- var from = box.offsetHeight;
1306
- box.style.height = from + 'px';
1307
- box.style.minHeight = '';
1308
- var ghost = box.cloneNode(true);
1081
+ function turnInPlace(b, paint, after) {
1082
+ if (reduced) { b.style.minHeight = ''; paint(); after(); return; }
1083
+ var from = b.offsetHeight;
1084
+ b.style.height = from + 'px';
1085
+ b.style.minHeight = '';
1086
+ var ghost = b.cloneNode(true);
1309
1087
  ghost.removeAttribute('id');
1310
- ghost.classList.remove('quick', 'turning', 'turn-in');
1088
+ ghost.classList.remove('quick', 'acted', 'turning', 'turn-in');
1311
1089
  ghost.classList.add('up', 'ghost');
1312
1090
  ghost.setAttribute('aria-hidden', 'true');
1313
1091
  ghost.inert = true;
1314
- var r = box.getBoundingClientRect(), p = box.offsetParent.getBoundingClientRect();
1092
+ var r = b.getBoundingClientRect(), p = b.offsetParent.getBoundingClientRect();
1315
1093
  ghost.style.top = (r.top - p.top) + 'px';
1316
1094
  ghost.style.left = (r.left - p.left) + 'px';
1317
1095
  ghost.style.width = r.width + 'px';
1318
- box.after(ghost);
1319
- box.classList.remove('quick', 'turning');
1320
- draw();
1321
- box.style.height = 'auto';
1322
- var to = box.offsetHeight;
1323
- box.style.height = from + 'px';
1324
- box.classList.add('turn-in');
1325
- void box.offsetHeight;
1326
- box.classList.add('turning');
1327
- box.classList.remove('turn-in');
1328
- box.style.height = to + 'px';
1096
+ b.after(ghost);
1097
+ b.classList.remove('quick', 'acted', 'turning');
1098
+ paint();
1099
+ b.style.height = 'auto';
1100
+ var to = b.offsetHeight;
1101
+ b.style.height = from + 'px';
1102
+ b.classList.add('turn-in');
1103
+ void b.offsetHeight;
1104
+ b.classList.add('turning');
1105
+ b.classList.remove('turn-in');
1106
+ b.style.height = to + 'px';
1329
1107
  ghost.classList.add('gone');
1330
1108
  setTimeout(function () {
1331
1109
  ghost.remove();
1332
- box.classList.remove('turning');
1333
- box.style.height = '';
1334
- bet.busy = false;
1335
- measured();
1110
+ b.classList.remove('turning');
1111
+ b.style.height = '';
1112
+ after();
1336
1113
  }, 80 + TURN_IN_MS + 20);
1337
1114
  }
1338
1115
 
1339
- // one tap: buzz at once and ask the server for the next card. From the
1340
- // sphere the orb plays into it; with a card up it is one calm turn.
1341
- function tapOrb(sharper) {
1342
- if (bet.busy) return;
1343
- buzz();
1344
- think(sharper, 0);
1116
+ // While the calm line shows, look every WAIT_LOOK_MS for the idea; once the
1117
+ // Mac has one ready, it is asked for the way a tap does and turns in with
1118
+ // one crossfade. It waits while the owner types there (the box has the
1119
+ // cursor or any words), and on the big orb it waits for their tap.
1120
+ var WAIT_LOOK_MS = 1500;
1121
+ function typingOnCard() {
1122
+ var input = box().querySelector('.field input');
1123
+ return Boolean(input && (input === document.activeElement || input.value.trim()));
1124
+ }
1125
+ function stopWatch() { if (bet.watching) { clearInterval(bet.watching); bet.watching = null; } }
1126
+ function watchWait() {
1127
+ if (bet.watching) return;
1128
+ var quiet = function () { return !bet.busy && document.body.classList.contains('has-card') && !typingOnCard(); };
1129
+ bet.watching = setInterval(function () {
1130
+ if (!bet.shown || !bet.shown.watch) { stopWatch(); return; }
1131
+ if (!quiet()) return;
1132
+ api('/card').then(function (s) {
1133
+ if (s.http !== 200 || !quiet() || !bet.shown || !bet.shown.watch || !s.card) return;
1134
+ if (s.card.id !== bet.shown.id && isLive(s)) { turnTo(s.card); return; }
1135
+ if (s.queue > 0) {
1136
+ bet.busy = true;
1137
+ api('/act', { orb: true, card_id: bet.shown.id }).catch(offline).then(function (r) {
1138
+ if (r.ok && r.card) turnTo(r.card); else bet.busy = false;
1139
+ });
1140
+ }
1141
+ }).catch(function () { /* the next look tries again */ });
1142
+ }, WAIT_LOOK_MS);
1345
1143
  }
1346
1144
 
1347
- // Ask for the next card. heldAt: an answer has held as chosen since then,
1348
- // so the card is asked for during the hold and turns in once it is over,
1349
- // and the time to the card counts from that answer.
1350
- function think(sharper, heldAt) {
1145
+ function isLive(s) { return Boolean(s && s.card); }
1146
+
1147
+ // ---- the orb ----------------------------------------------------------------------------
1148
+ // The big orb: the next card, with the spiral, as on page open. An idea
1149
+ // that landed behind the calm line is the next one, so it is never passed.
1150
+ function think() {
1151
+ if (bet.busy) return;
1152
+ buzz();
1351
1153
  bet.busy = true;
1352
- bet.armed = false;
1353
- bet.tapAt = heldAt || performance.now();
1154
+ bet.tapAt = performance.now();
1354
1155
  document.body.removeAttribute('data-reveal-ms');
1355
- var onCard = document.body.classList.contains('has-card');
1356
1156
  var failed = function () {
1357
1157
  bet.busy = false;
1358
- document.getElementById('bet').classList.remove('holding');
1359
1158
  orb.relax();
1360
1159
  line.set('that did not go through. tap to try again', true);
1361
1160
  };
1362
- Promise.all([api('/bet/think', { sharper: Boolean(sharper) }), afterHold(heldAt)]).then(function (both) {
1363
- var s = both[0];
1364
- if (s.http !== 200) { failed(); return; }
1365
- bet.status = s.status;
1366
- if (onCard && isCard(s)) { turn(s); return; }
1367
- if (onCard) { dissolve(function () { reveal(s, true); }); return; }
1368
- reveal(s, true);
1161
+ api('/act', { orb: true, card_id: bet.shown ? bet.shown.id : null }).then(function (r) {
1162
+ if (r.http !== 200 || !r.card) { if (r.http === 200) { bet.busy = false; orb.relax(); line.set('tap to think', false); return; } failed(); return; }
1163
+ line.set('', false);
1164
+ orb.play(function (phase) { if (phase === 'settled') { bet.busy = false; showCard(r.card, true, true); } }, true);
1369
1165
  }, failed);
1370
1166
  }
1371
1167
 
1372
- function loadBet() {
1373
- return api('/bet').then(function (s) {
1374
- if (s.http !== 200) return;
1375
- bet.status = s.status;
1376
- if ((s.bet || s.job || s.question || s.choice || s.report) && bet.drawn === s.id) { bet.last = s; drawBetCard(s); if (s.status === 'starting') poll(); }
1377
- });
1378
- }
1379
-
1380
- function poll() {
1381
- if (bet.polling) return;
1382
- bet.polling = true;
1383
- setTimeout(function () { bet.polling = false; loadBet(); }, 400);
1384
- }
1385
-
1386
- // first look: a card already on screen shows settled, anything else is the sphere
1387
- function firstLook() {
1388
- api('/bet').then(function (s) {
1389
- if (s.http !== 200) return;
1390
- bet.status = s.status;
1391
- if ((s.bet || s.job || s.question || s.choice || s.report) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting')) {
1392
- orb.settled();
1393
- showCard(s, false);
1394
- return;
1395
- }
1396
- line.set(s.status === 'skipped' ? 'not now. tap when you want another' : 'tap to think', false);
1397
- });
1398
- }
1399
-
1400
- // The small orb on a card goes back to the big dotted orb, the same calm
1401
- // way the page goes home before a new idea: the card fades, the small orb
1402
- // glides down and the dots form again, idle under the pointer. The card
1403
- // stays where it was on the server, so nothing is passed yet.
1168
+ // The small orb on a card goes back to the big dotted orb, from any card:
1169
+ // the card fades, the small orb glides down and the dots form again, idle
1170
+ // under the pointer. The card stays where it is on the Mac, so nothing is
1171
+ // passed until the big orb is tapped.
1404
1172
  function goHome() {
1405
1173
  if (bet.busy) return;
1406
1174
  buzz();
1407
1175
  bet.busy = true;
1408
- bet.armed = false;
1409
- dissolve(function () { bet.busy = false; line.set('tap to think', false); });
1176
+ stopWatch();
1177
+ // the line under the big orb is ready before the sphere is back
1178
+ line.set('tap to think', false);
1179
+ leave(function () { bet.busy = false; });
1410
1180
  }
1411
1181
 
1412
- // The big orb: the next idea, with the spiral, as on page open. An idea
1413
- // that landed behind the calm line is the next one, so it is never passed.
1414
- function tapBig() { tapOrb(false); }
1415
-
1416
1182
  function tapSmallOrb() {
1417
- if (document.body.classList.contains('has-card')) goHome(); else tapBig();
1183
+ if (document.body.classList.contains('has-card')) goHome(); else think();
1418
1184
  }
1419
1185
 
1420
1186
  var canvas = document.getElementById('orb');
1421
1187
  var lavaTap = document.getElementById('lava');
1422
- [[canvas, tapBig], [lavaTap, tapSmallOrb]].forEach(function (pair) {
1188
+ [[canvas, think], [lavaTap, tapSmallOrb]].forEach(function (pair) {
1423
1189
  pair[0].addEventListener('click', pair[1]);
1424
1190
  pair[0].addEventListener('keydown', function (e) {
1425
1191
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pair[1](); }
1426
1192
  });
1427
1193
  });
1428
1194
 
1429
- // ---- the team: who is on what, from real state, and a word to any of them ----
1430
-
1431
- var teamBox = document.getElementById('team');
1432
- var teamInner = document.getElementById('team-inner');
1433
- // the way back from what is on top: the team closes, a talk card goes back to the team
1434
- var teamBack = null;
1435
-
1436
- function closeTeam() { teamBox.hidden = true; }
1437
-
1438
- function teamHead(title) {
1439
- teamInner.textContent = '';
1440
- teamBack = closeTeam;
1441
- var head = el('div', 'head');
1442
- head.appendChild(el('h1', null, title));
1443
- var close = el('button', 'quiet', 'close');
1444
- close.type = 'button';
1445
- close.addEventListener('click', closeTeam);
1446
- head.appendChild(close);
1447
- teamInner.appendChild(head);
1195
+ // first look: a card already on screen shows settled, anything else is the sphere
1196
+ function firstLook() {
1197
+ api('/card').then(function (s) {
1198
+ if (s.http !== 200) return;
1199
+ if (s.card) { orb.settled(); showCard(s.card, false); return; }
1200
+ line.set(s.status === 'skipped' ? 'not now. tap when you want another' : 'tap to think', false);
1201
+ });
1448
1202
  }
1449
1203
 
1450
- function openTeam() {
1451
- teamBox.hidden = false;
1452
- teamHead('the team');
1453
- api('/bet/team').then(function (s) { if (s.http === 200) drawTeam(s.rows || []); });
1204
+ // ---- the footer: the team and the yeses waiting, both cards like any other ---------------
1205
+ // From any card, or the big orb: the Mac serves the team table or the job
1206
+ // deck as the next card, and its back option returns to where the owner was.
1207
+ function openFrom(which) {
1208
+ if (bet.busy) return;
1209
+ buzz();
1210
+ bet.busy = true;
1211
+ bet.tapAt = performance.now();
1212
+ var answer = api('/act', { open: which, card_id: bet.shown ? bet.shown.id : null }).catch(offline);
1213
+ if (!document.body.classList.contains('has-card')) {
1214
+ answer.then(function (r) {
1215
+ if (!r.ok || !r.card) { bet.busy = false; line.set((r && r.message) || 'that did not go through. tap to try again', true); return; }
1216
+ spiralTo(r.card, null);
1217
+ });
1218
+ return;
1219
+ }
1220
+ flow(answer, bet.shown, null, null);
1454
1221
  }
1222
+ document.getElementById('working').addEventListener('click', function () { openFrom('team'); });
1223
+ document.getElementById('needs').addEventListener('click', function () { openFrom('deck'); });
1455
1224
 
1456
- function drawTeam(rows) {
1457
- teamHead('the team');
1458
- if (!rows.length) { teamInner.appendChild(el('p', 'why', 'nobody has had work in the last day.')); return; }
1459
- rows.forEach(function (r) {
1460
- var row = el('button', 'row');
1461
- row.type = 'button';
1462
- row.appendChild(el('span', 'who', r.member));
1463
- row.appendChild(el('span', 'st ' + r.status.replace(/ /g, '-'), r.status));
1464
- row.appendChild(el('span', 'on', r.on));
1465
- row.appendChild(el('span', 'time', r.time));
1466
- row.addEventListener('click', function () { drawTalk(r); });
1467
- teamInner.appendChild(row);
1468
- });
1225
+ // the option on the card that does this, if it has one
1226
+ function optionFor(action, step) {
1227
+ if (!bet.shown) return null;
1228
+ return bet.shown.options.filter(function (o) { return o.action === action && (step == null || (o.binds && o.binds.step === step)); })[0] || null;
1469
1229
  }
1470
-
1471
- // a talk card: what the member is on, and a word to steer them
1472
- function drawTalk(r) {
1473
- teamHead(r.member);
1474
- teamBack = openTeam;
1475
- teamInner.appendChild(el('p', 'why', r.status + ': ' + r.on + '. ' + r.time + '.'));
1476
- var talkBox = convo(function (body) { body.member = r.member; return api('/bet/steer', body); }, function (ack) {
1477
- talkBox.replaceWith(el('p', 'mission', ack));
1478
- }, 'tell ' + r.member + ' something');
1479
- var back = el('button', 'quiet', 'back to the team');
1480
- back.type = 'button';
1481
- back.addEventListener('click', openTeam);
1482
- teamInner.appendChild(talkBox);
1483
- teamInner.appendChild(back);
1230
+ function useOption(o) {
1231
+ var b = o && box().querySelector('[data-option="' + o.id + '"]');
1232
+ if (b && !bet.busy) b.click();
1484
1233
  }
1485
1234
 
1486
- document.getElementById('working').addEventListener('click', openTeam);
1487
-
1488
- // Esc on the team page steps back one layer, the way its own buttons do:
1489
- // words being typed are cancelled and the cursor leaves first, then the
1490
- // talk card goes back to the team, then the team closes to the card.
1491
- // On the card itself it does nothing. It listens on the way down, so the
1492
- // focused element is still the field the key was pressed in.
1235
+ // Esc: words being typed are cancelled and the cursor leaves first; then
1236
+ // a card with a way back takes it. On a card with none it does nothing.
1493
1237
  document.addEventListener('keydown', function (e) {
1494
- if (e.key !== 'Escape' || e.isComposing || teamBox.hidden) return;
1495
- e.preventDefault();
1238
+ if (e.key !== 'Escape' || e.isComposing || !bet.shown || !document.body.classList.contains('has-card')) return;
1496
1239
  var field = document.activeElement;
1497
- if (field && (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA')) {
1240
+ if (field && field.tagName === 'INPUT') {
1241
+ e.preventDefault();
1498
1242
  field.value = '';
1499
- // the field's own input listener drops a second tap armed for those words
1500
- field.dispatchEvent(new Event('input'));
1501
1243
  field.blur();
1502
1244
  return;
1503
1245
  }
1504
- teamBack();
1246
+ var back = optionFor('back');
1247
+ if (back) { e.preventDefault(); useOption(back); }
1505
1248
  }, true);
1506
1249
 
1250
+ // the arrows and a sideways swipe flip the job deck, unless the owner is typing
1251
+ document.addEventListener('keydown', function (e) {
1252
+ var step = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
1253
+ var field = document.activeElement;
1254
+ if (!step || e.isComposing || (field && field.tagName === 'INPUT')) return;
1255
+ var o = optionFor('flip', step);
1256
+ if (!o) return;
1257
+ e.preventDefault();
1258
+ useOption(o);
1259
+ });
1260
+ var swipe = null;
1261
+ box().addEventListener('touchstart', function (e) { swipe = { x: e.touches[0].clientX, y: e.touches[0].clientY }; }, { passive: true });
1262
+ box().addEventListener('touchend', function (e) {
1263
+ if (!swipe) return;
1264
+ var dx = e.changedTouches[0].clientX - swipe.x, dy = e.changedTouches[0].clientY - swipe.y;
1265
+ swipe = null;
1266
+ if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) useOption(optionFor('flip', dx < 0 ? 1 : -1));
1267
+ });
1268
+
1507
1269
  // the footer: how many members are really working, or the team when none are
1508
1270
  function drawWorking(n) {
1509
1271
  var btn = document.getElementById('working');
1510
1272
  btn.textContent = n ? n + ' working' : 'the team';
1511
1273
  }
1512
1274
 
1275
+ // Every few seconds: the footer counts, and the card on screen. When the
1276
+ // Mac's card has changed (a new id: its options are not the ones shown),
1277
+ // it turns in place, unless the owner is typing or a motion is under way.
1513
1278
  function refresh() {
1514
- return api('/bet').then(function (s) {
1279
+ return api('/card').then(function (s) {
1515
1280
  if (s.http !== 200) { line.set('this link stopped working. start gm phone again on the mac.', true); return; }
1516
1281
  drawWorking(s.team_working || 0);
1282
+ if (s.card && bet.shown && s.card.id !== bet.shown.id && !bet.busy && !typingOnCard() && document.body.classList.contains('has-card')) {
1283
+ bet.busy = true;
1284
+ turnTo(s.card);
1285
+ }
1517
1286
  }).catch(function () { /* the next refresh tries again */ });
1518
1287
  }
1519
1288