atris 3.74.5 → 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.
@@ -71,55 +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, the role, the rating, the model, and the status */
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 .inner.wide { max-width: 64rem; }
81
- /* the table: a line between every column and every row. at phone width the
82
- box scrolls sideways and the name column stays put; no column ever gets
83
- narrower than its words */
84
- .team .grid { overflow-x: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; border: 1px solid var(--line); border-radius: 14px; }
85
- .team table { width: 100%; min-width: 44rem; border-collapse: separate; border-spacing: 0; font-size: 17px; line-height: 1.35; }
86
- .team th, .team td { padding: 10px 14px; text-align: left; vertical-align: top; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
87
- .team th:last-child, .team td:last-child { border-right: 0; }
88
- .team tbody tr:last-child td { border-bottom: 0; }
89
- .team th { color: var(--text-2); font-weight: 400; white-space: nowrap; }
90
- .team .row { cursor: pointer; }
91
- @media (hover: hover) { .team .row:hover td { background: #1c1816; } }
92
- .team .who { position: sticky; left: 0; z-index: 1; background: var(--bg); white-space: nowrap; }
93
- .team .who button { font-size: 18px; font-weight: 700; text-align: left; }
94
- .team .role { min-width: 9rem; color: var(--text-2); }
95
- .team .ovr { white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
96
- .team .eng { white-space: nowrap; }
97
- .team td.eng button { text-align: left; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; min-height: 32px; }
98
- /* the engine card: the line, who it is for, then every model on this machine by tool */
99
- .team .done { margin: 0; color: var(--text); font-size: 18px; line-height: 1.4; }
100
- .team .scopes { display: flex; flex-wrap: wrap; gap: 8px; }
101
- .team .scopes button { min-height: 44px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); color: var(--text-2); font-size: 17px; }
102
- .team .scopes button.chosen { border-color: var(--amber); background: rgba(255, 140, 66, 0.1); color: var(--text); }
103
- .team .models { display: flex; flex-direction: column; gap: 8px; }
104
- .team .models h2 { margin: 10px 0 0; color: var(--text-2); font-size: 17px; font-weight: 400; }
105
- .team .models .choice { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
106
- .team .models .choice .w { color: var(--text-2); font-size: 17px; white-space: nowrap; }
107
- .team .models .choice[disabled] { cursor: default; }
108
- .team td.st { min-width: 16rem; color: var(--text-2); }
109
- .team td.st.working { color: var(--text); }
110
- .team td.st.waiting-for-your-yes { color: var(--amber); }
111
- /* the job deck: one running job at a time, in one spot, with its place in
112
- the deck. the card is a .bet card, so a flip is the main card's own turn */
113
- .deckspot { position: relative; }
114
- .deckcard { display: flex; flex-direction: column; gap: 12px; touch-action: pan-y; }
115
- .deckcard .count, .deckcard .label, .deckcard .time { font-size: 17px; color: var(--text-2); }
116
- .deckcard .label.needs-you { color: var(--amber); }
117
- .deckcard .why, .deckcard .act, .deckcard .mission { margin: 0; }
118
- .jobs-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; border: 1px solid var(--line); border-radius: 14px; }
119
- .team table.jobs { min-width: 56rem; }
120
- .jobs td.work { min-width: 14rem; }
121
- .jobs td.done, .jobs td.member, .jobs td.engine, .jobs td.status { white-space: nowrap; }
122
- .deckbar { display: flex; justify-content: space-between; gap: 12px; }
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; }
123
106
  .sayline { margin: 10px 0 0; display: flex; flex-direction: column; align-items: center; gap: 8px; }
124
107
  .sayline .thread, .sayline .reply { width: 100%; text-align: left; margin: 0; }
125
108
  .unsent { margin: 0; text-align: center; }
@@ -165,6 +148,10 @@ body.has-card .foot { position: static; margin-top: 28px; }
165
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); }
166
149
  .bet.up { opacity: 1; transform: none; }
167
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; }
168
155
  /* card to card: a crossfade in place. the old card is a still copy that
169
156
  fades over the box while the new card fades in under it from a small
170
157
  drift, its title landing where the old title was; the box eases to the
@@ -229,7 +216,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
229
216
  </div>
230
217
  <p class="line" id="line" aria-live="polite">tap to think</p>
231
218
  <article class="bet" id="bet" hidden></article>
232
- <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>
233
220
  </main>
234
221
  <div class="lava" id="lava" role="button" tabindex="-1" aria-label="the atris orb. tap to go back to the big orb">
235
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>
@@ -238,7 +225,6 @@ body.has-card .foot { position: static; margin-top: 28px; }
238
225
  <div class="core" id="lava-core"></div>
239
226
  <div class="dim" id="lava-dim"></div>
240
227
  </div>
241
- <section class="team" id="team" hidden role="dialog" aria-label="the team"><div class="inner" id="team-inner"></div></section>
242
228
  <script nonce="${nonce}">
243
229
  (function () {
244
230
  var token = new URLSearchParams(location.search).get('t') || '';
@@ -259,7 +245,15 @@ body.has-card .foot { position: static; margin-top: 28px; }
259
245
  opts.body = JSON.stringify(body);
260
246
  }
261
247
  return fetch(path + (body ? '' : '?t=' + encodeURIComponent(token)), opts)
262
- .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') : '';
263
257
  }
264
258
 
265
259
  // ---- the one line under the orb: fades out, changes, fades in ------------------
@@ -310,6 +304,11 @@ body.has-card .foot { position: static; margin-top: 28px; }
310
304
  // the fast reveal stays contained: the lava grows to BLOOM of the smaller
311
305
  // window side and never washes the page
312
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;
313
312
  // Two speeds, chosen by truth. A bet already waiting in the queue plays
314
313
  // the same curves at FAST of their length (tap to card in about two seconds, unhurried).
315
314
  // A bet still being made plays the full landing length, and the spiral
@@ -499,8 +498,8 @@ body.has-card .foot { position: static; margin-top: 28px; }
499
498
  function grow() {
500
499
  var rect = canvas.getBoundingClientRect();
501
500
  var contained = pace < 1;
502
- var growMs = contained ? FAST_GROW_MS : (GROW_MS * pace);
503
- 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);
504
503
  var vx = rect.left + cx, vy = rect.top + cy;
505
504
  var reach = contained
506
505
  ? BLOOM * Math.min(window.innerWidth, window.innerHeight) / 2
@@ -572,7 +571,9 @@ body.has-card .foot { position: static; margin-top: 28px; }
572
571
  // swap() takes the card off the page once it has faded. At the centre the
573
572
  // lava melts and the sphere grows back out of the point, holds for a
574
573
  // beat so it reads, then calls back. Reduced motion: no travel.
575
- 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;
576
577
  lava.classList.remove('tap', 'alive');
577
578
  lava.tabIndex = -1;
578
579
  var swapped = false;
@@ -589,7 +590,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
589
590
  mode = 'idle';
590
591
  bridgeAt = reduced ? 0 : performance.now();
591
592
  start();
592
- setTimeout(cb, reduced ? 0 : BEAT_MS);
593
+ setTimeout(cb, reduced ? 0 : beatMs);
593
594
  };
594
595
  if (reduced || lava.style.visibility !== 'visible') { reform(); return; }
595
596
  var r = lava.getBoundingClientRect();
@@ -600,9 +601,9 @@ body.has-card .foot { position: static; margin-top: 28px; }
600
601
  var sEnd = 30 / base;
601
602
  var t0 = performance.now();
602
603
  var step = function (now) {
603
- var k = Math.min(1, (now - t0) / GLIDE_MS);
604
+ var k = Math.min(1, (now - t0) / glideMs);
604
605
  var t = ease(k);
605
- if (now - t0 >= LEAVE_MS) takeOff();
606
+ if (now - t0 >= leaveMs) takeOff();
606
607
  place(from.s + (sEnd - from.s) * t, from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
607
608
  core.style.opacity = String(smooth(0.3, 0.9, k));
608
609
  lava.style.opacity = String(1 - smooth(0.7, 1, k));
@@ -648,11 +649,14 @@ body.has-card .foot { position: static; margin-top: 28px; }
648
649
 
649
650
  return {
650
651
  // the bet is ready: the homepage tap, on the clock
651
- 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) {
652
654
  onBorn = cb;
655
+ quick = Boolean(act);
653
656
  if (reduced) { settled(); return; }
654
- pace = fast ? FAST : 1;
655
- 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));
656
660
  timed = true;
657
661
  start();
658
662
  },
@@ -679,194 +683,191 @@ body.has-card .foot { position: static; margin-top: 28px; }
679
683
  };
680
684
  })();
681
685
 
682
- // ---- the bet ------------------------------------------------------------------
683
- var bet = { status: null, polling: false, armed: false, drawn: null, busy: false, last: null, watching: null };
684
- // 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
685
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'];
686
704
 
687
705
  function buzz() { if (navigator.vibrate) { try { navigator.vibrate(10); } catch (e) { /* not on this device */ } } }
688
706
 
689
- // after a yes or a nice, the next card comes the way a re-tap brings it
690
- function moveOn(ack) {
691
- var box = document.getElementById('bet');
692
- box.style.minHeight = box.offsetHeight + 'px';
693
- var act = box.querySelector('.act');
694
- var line = box.querySelector('.sayline');
695
- if (line) line.remove();
696
- if (act) act.replaceWith(el('p', 'mission', ack));
697
- else box.appendChild(el('p', 'mission', ack));
698
- setTimeout(function () { tapOrb(false); }, AFTER_MS);
699
- }
700
-
701
- // A second tap, armed on this one button: one card and one command. A tap
702
- // on another card, or a card drawn again after a flip, starts over, and
703
- // the armed tap goes quiet after five seconds. Once sent, it takes no more.
704
- function armedTap(go, label, armedLabel, run) {
705
- var armed = false;
706
- var quiet = null;
707
- go.addEventListener('click', function () {
708
- buzz();
709
- if (!armed) {
710
- armed = true; go.textContent = armedLabel; go.className = 'pill armed';
711
- quiet = setTimeout(function () { armed = false; go.textContent = label; go.className = 'pill'; }, 5000);
712
- 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 + ': '));
713
754
  }
714
- clearTimeout(quiet);
715
- armed = false;
716
- go.disabled = true;
717
- run();
755
+ links.appendChild(optionButton(o));
718
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));
719
761
  }
720
762
 
721
- // a line that ends like a sentence
722
- function sentence(t) {
723
- var s = String(t || '').trim();
724
- return s && !/[.!?]$/.test(s) ? s + '.' : s;
725
- }
726
-
727
- // the yes on a yes card: the first tap arms it, the second sends it.
728
- // done(line, reply) gets the line to show after it.
729
- function yesPill(post, done) {
730
- var yes = el('button', 'pill', 'yes');
731
- yes.type = 'button';
732
- armedTap(yes, 'yes', 'tap again to say yes', function () {
733
- post().then(function (r) { done(r.ack || r.message || 'sent', r); });
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;
785
+ });
786
+ if (mine.length) tr.addEventListener('click', function () { var first = tr.querySelector('button'); if (first) first.click(); });
734
787
  });
735
- return yes;
788
+ return grid;
736
789
  }
737
790
 
738
- // a yes the owner owes, or a finished business move: what is going on,
739
- // then why it matters, both written by the server
740
- function drawJobCard(s) {
741
- var box = document.getElementById('bet');
742
- box.textContent = '';
743
- var j = s.job;
744
- box.appendChild(el('h1', null, j.what));
745
- if (j.why) box.appendChild(el('p', 'why', j.why));
746
- var act = el('div', 'act');
747
- if (s.kind === 'yes') {
748
- act.appendChild(yesPill(function () { return api('/bet/yes', { confirm: true }); }, moveOn));
749
- } else if (s.kind === 'wait') {
750
- // one calm line: the idea turns in on its own once it is written
751
- watchWait();
752
- box.appendChild(sayBox());
753
- return;
754
- } else {
755
- var ok = el('button', 'pill', 'nice');
756
- ok.type = 'button';
757
- ok.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 () {
809
+ if (bet.busy) return;
758
810
  buzz();
759
- api('/bet/nice', {}).then(function (r) { moveOn(r.ack || 'nice.'); });
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
- act.appendChild(ok);
820
+ return btn;
762
821
  }
763
- box.appendChild(act);
764
- box.appendChild(sayBox());
765
- }
766
-
767
- // While the calm line shows, look every WAIT_LOOK_MS for the idea; once the
768
- // server has one ready, it is asked for the way a tap does and turns in
769
- // with one crossfade. No tap, and nothing moves on the page while it
770
- // waits. It also waits while the owner types there (the box has the
771
- // cursor or any words), so it comes in after they send, or once they
772
- // clear the box and leave it; and on the big orb, it waits for their tap.
773
- var WAIT_LOOK_MS = 1500;
774
- function typingOnCard() {
775
- var input = document.getElementById('bet').querySelector('.field input');
776
- return Boolean(input && (input === document.activeElement || input.value.trim()));
777
- }
778
- function watchWait() {
779
- if (bet.watching) return;
780
- var quiet = function () { return !bet.busy && document.body.classList.contains('has-card') && !typingOnCard(); };
781
- bet.watching = setInterval(function () {
782
- if (!bet.last || bet.last.kind !== 'wait') { clearInterval(bet.watching); bet.watching = null; return; }
783
- if (!quiet()) return;
784
- api('/bet').then(function (s) {
785
- if (s.http !== 200 || !quiet() || !bet.last || bet.last.kind !== 'wait') return;
786
- if (s.id !== bet.last.id && s.kind !== 'wait' && isCard(s)) { bet.status = s.status; turn(s); return; }
787
- if (s.kind === 'wait' && s.queue > 0) think(false, 0);
788
- }).catch(function () { /* the next look tries again */ });
789
- }, WAIT_LOOK_MS);
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);
827
+ });
828
+ return btn;
790
829
  }
791
830
 
792
- // no bet was ready: one question, then what is going on and why the answer matters
793
- function drawQuestionCard(s) {
794
- var box = document.getElementById('bet');
795
- box.textContent = '';
796
- var q = s.question;
797
- box.appendChild(el('h1', null, q.ask));
798
- if (q.line) box.appendChild(el('p', 'why', q.line));
799
- var act = el('div', 'act answers');
800
- q.choices.forEach(function (c) {
801
- var b = el('button', 'choice', c.label);
802
- b.type = 'button';
803
- b.addEventListener('click', function () {
804
- if (bet.busy) return;
805
- buzz();
806
- // typed words are read first, so a question back is explained and nothing is saved
807
- if (c.other) { typeHere(b, c.label, 60, function (text, field) { say(field, { text: text }); }); return; }
808
- answer(b, { choice: c.id });
809
- });
810
- if (c.other) b.classList.add('other');
811
- act.appendChild(b);
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); });
812
839
  });
813
- // a quiet line under the answers, empty until an answer does not go through
814
- act.appendChild(el('div', 'links'));
815
- box.appendChild(act);
816
- // its own something else box is the one place to type
817
- if (!q.choices.some(function (c) { return c.other; })) box.appendChild(sayBox());
840
+ return wrap;
818
841
  }
819
842
 
820
- // ---- answering in place: the eyes stay in one spot ----------------------------
821
- // A tapped answer, or the words typed in its place, holds that spot as
822
- // chosen for HOLD_MS while the rest of the card dims and stays put. The
823
- // next card is asked for during the hold and turns in once it is over.
824
- var HOLD_MS = 400;
825
-
826
- // a failed fetch reads like a refusal, so a hold never hangs
827
- function offline() { return { ok: false, http: 0 }; }
828
-
829
- // 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
830
845
  function dimOthers(spot) {
831
- var box = document.getElementById('bet');
832
- box.querySelectorAll('.act > *, .options > *, .sayline, :scope > .links').forEach(function (n) {
833
- 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));
834
848
  });
835
849
  }
836
850
 
837
- // the spot holds as chosen and the card takes no taps; returns when it
838
- // 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
839
852
  function hold(spot) {
840
- var box = document.getElementById('bet');
841
853
  dimOthers(spot);
842
854
  spot.classList.add('chosen');
843
- box.classList.add('holding');
855
+ box().classList.add('holding');
844
856
  var input = spot.querySelector('input');
845
857
  if (input) { input.readOnly = true; input.blur(); }
846
858
  bet.busy = true;
847
- bet.armed = false;
848
859
  bet.tapAt = performance.now();
849
860
  document.body.removeAttribute('data-reveal-ms');
850
861
  return bet.tapAt;
851
862
  }
852
863
 
853
- // it did not go through: the card is as it was
854
- function unhold() {
855
- var box = document.getElementById('bet');
856
- box.classList.remove('holding');
857
- dimOthers(null);
858
- box.querySelectorAll('.chosen').forEach(function (n) { n.classList.remove('chosen'); });
859
- bet.busy = false;
860
- }
861
-
862
- // resolves once the hold that started at heldAt has run HOLD_MS
863
864
  function afterHold(heldAt) {
864
865
  var left = heldAt ? heldAt + HOLD_MS - performance.now() : 0;
865
866
  return new Promise(function (resolve) { setTimeout(resolve, Math.max(0, left)); });
866
867
  }
867
868
 
868
869
  // The tapped button becomes the field at its own size and spot, with the
869
- // 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
870
871
  // the words to sent(text, field). Left empty, it turns back into the button.
871
872
  function typeHere(btn, placeholder, maxLength, sent) {
872
873
  var field = el('div', btn.classList.contains('choice') ? 'choice field' : 'field');
@@ -878,18 +879,17 @@ body.has-card .foot { position: static; margin-top: 28px; }
878
879
  input.placeholder = placeholder;
879
880
  input.enterKeyHint = 'send';
880
881
  input.setAttribute('aria-label', placeholder);
881
- var send = el('button', 'send', 'send');
882
- send.type = 'button';
882
+ var go = el('button', 'send', 'send');
883
+ go.type = 'button';
883
884
  var submit = function () {
884
885
  var text = input.value.trim();
885
886
  if (text && !bet.busy) sent(text, field);
886
887
  };
887
888
  // a press on send keeps the cursor in the field, so it never closes first
888
- send.addEventListener('mousedown', function (e) { e.preventDefault(); });
889
- send.addEventListener('click', function () { buzz(); submit(); });
889
+ go.addEventListener('mousedown', function (e) { e.preventDefault(); });
890
+ go.addEventListener('click', function () { buzz(); submit(); });
890
891
  input.addEventListener('keydown', function (e) {
891
892
  if (e.key === 'Enter') { e.preventDefault(); submit(); }
892
- if (e.key === 'Escape') { input.value = ''; input.blur(); }
893
893
  });
894
894
  input.addEventListener('blur', function () {
895
895
  if (input.value.trim() || field.classList.contains('chosen') || !field.isConnected) return;
@@ -897,370 +897,130 @@ body.has-card .foot { position: static; margin-top: 28px; }
897
897
  dimOthers(null);
898
898
  });
899
899
  field.appendChild(input);
900
- field.appendChild(send);
900
+ field.appendChild(go);
901
901
  btn.replaceWith(field);
902
902
  dimOthers(field);
903
903
  input.focus();
904
+ return field;
904
905
  }
905
906
 
906
- // An answer to a question holds as chosen while it saves. If it does not
907
- // go through, the card comes back as it was, the words still in the
908
- // field, and why takes the hint's line under the answers.
909
- function answer(spot, body) {
910
- body.card = shownCard();
911
- var heldAt = hold(spot);
912
- api('/bet/answer', body).catch(offline).then(function (r) {
913
- if (r.ok) { think(false, heldAt); return; }
914
- unhold();
915
- var hint = document.getElementById('bet').querySelector('.act .links');
916
- if (hint) hint.textContent = r.message || 'that did not go through';
917
- var input = spot.querySelector('input');
918
- if (input) { input.readOnly = false; dimOthers(spot); input.focus(); }
919
- });
920
- }
921
-
922
- // the card the owner is looking at, named with every answer and every
923
- // word said, so the server acts on that card or on nothing
924
- function shownCard() { return bet.last && bet.last.id ? bet.last.id : ''; }
925
-
926
- // Words said on a card hold their spot as chosen while the server reads
927
- // them. An answer or an instruction brings the next card; a question
928
- // back or a second tap it needs turns in as the orb's reply, in the
929
- // title's spot. If it did not send, the words stay where they were.
930
- // One id per typed send: made when these words are first sent from this
931
- // spot, the same on every retry of them, and new when the words change,
932
- // 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
933
911
  // works on the plain http of the wifi link, where randomUUID does not.
934
- function sendId(spot, text) {
935
- if (spot.sendText !== text) {
912
+ var sent = { key: null, id: null };
913
+ function sendId(key) {
914
+ if (sent.key !== key) {
936
915
  var n = new Uint32Array(2);
937
916
  crypto.getRandomValues(n);
938
- spot.sendText = text;
939
- 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);
940
919
  }
941
- return spot.sendId;
920
+ return sent.id;
942
921
  }
943
922
 
944
- // while the words are read, one quiet ellipsis in the held spot: where
945
- // send was in a box, or after a tapped reply. Nowhere else.
946
- function reading(spot) {
947
- var send = spot.querySelector('.send');
948
- if (send) { send.textContent = '\u2026'; send.classList.add('reading'); return; }
949
- spot.appendChild(el('span', 'reading', ' \u2026'));
950
- }
951
- function doneReading(spot) {
952
- var send = spot.querySelector('.send.reading');
953
- if (send) { send.textContent = 'send'; send.classList.remove('reading'); }
954
- var dots = spot.querySelector('span.reading');
955
- if (dots) dots.remove();
956
- }
957
-
958
- function say(spot, body) {
959
- body.card = shownCard();
960
- 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(' | '));
961
936
  var heldAt = hold(spot);
962
- reading(spot);
963
- api('/bet/say', body).catch(offline).then(function (r) {
964
- if (r.ok && !r.stay) { think(false, heldAt); return; }
965
- if (!r.ok && r.reason !== 'needs_confirm') { unsent(spot, r.message || 'that did not send. tap send to try again'); return; }
966
- afterHold(heldAt).then(function () { crossfade(function () { drawReply(r, body.text); }); });
967
- });
968
- }
969
-
970
- // It did not send: the card is as it was, the words still in the box with
971
- // the cursor in it, and one short line under it says why, so Enter (or
972
- // the same tap) tries again. Left empty, the box turns back into the
973
- // button and the line goes with it.
974
- function unsent(spot, why) {
975
- doneReading(spot);
976
- unhold();
977
- var note = spot.nextElementSibling;
978
- var input = spot.querySelector('input');
979
- if (!note || !note.classList.contains('unsent')) {
980
- note = el('p', 'links unsent');
981
- spot.after(note);
982
- if (input) input.addEventListener('blur', function () { if (!spot.isConnected) note.remove(); });
983
- }
984
- note.textContent = why;
985
- if (input) { input.readOnly = false; dimOthers(spot); input.focus(); }
986
- }
987
-
988
- // one reply to tap: it holds as chosen and is said
989
- function sayChoice(label, body) {
990
- var b = el('button', 'choice', label);
991
- b.type = 'button';
992
- b.addEventListener('click', function () {
993
- if (bet.busy) return;
994
- buzz();
995
- say(b, body);
996
- });
997
- return b;
998
- }
999
-
1000
- // the orb's reply is the title, with the card's own answer right under it
1001
- // (do it, yes, nice, or the answers), then up to three suggested replies;
1002
- // starting a member who is not running takes one more tap, or it is kept
1003
- // as a note. The say line takes anything else.
1004
- function drawReply(r, said) {
1005
- var box = document.getElementById('bet');
1006
- // a reply never takes the card's own answer away: the card is drawn
1007
- // once more, and its row is kept, buttons and all
1008
- var own = null;
1009
- if (bet.last) { drawBetCard(bet.last); own = box.querySelector(':scope > .act, :scope > .options'); }
1010
- box.textContent = '';
1011
- bet.drawn = null;
1012
- var title = el('h1', null, r.stay ? r.reply : (r.message || 'that did not go through'));
1013
- box.appendChild(title);
1014
- // more than one line reads at the body size, in the same spot
1015
- var lines = document.createRange();
1016
- lines.selectNodeContents(title);
1017
- if (lines.getClientRects().length > 1) title.classList.add('said');
1018
- if (own) box.appendChild(own);
1019
- var act = el('div', 'act answers');
1020
- var have = own ? Array.prototype.map.call(own.querySelectorAll('button'), function (b) { return b.textContent.trim().toLowerCase(); }) : [];
1021
- if (r.stay) (r.suggestions || []).forEach(function (sug) {
1022
- if (have.indexOf(String(sug).trim().toLowerCase()) < 0) act.appendChild(sayChoice(sug, { text: sug }));
1023
- });
1024
- if (r.reason === 'needs_confirm') {
1025
- act.appendChild(sayChoice('start it', { text: said, confirm: true }));
1026
- 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;
1027
945
  }
1028
- box.appendChild(act);
1029
- if (!own || !own.querySelector('.other')) box.appendChild(sayBox());
946
+ afterHold(heldAt).then(function () { flow(answer, from, body.text, option); });
1030
947
  }
1031
948
 
1032
- // the owner's own number, once a week: one box and one tap to save
1033
- function drawReportCard(s) {
1034
- var box = document.getElementById('bet');
1035
- box.textContent = '';
1036
- box.appendChild(el('h1', null, s.report.what));
1037
- box.appendChild(el('p', 'why', s.report.why));
1038
- var act = el('div', 'act answers');
1039
- var input = el('input', 'say');
1040
- input.type = 'number';
1041
- input.inputMode = 'decimal';
1042
- input.setAttribute('aria-label', s.report.what);
1043
- var save = el('button', 'pill', 'save');
1044
- save.type = 'button';
1045
- var go = function () {
1046
- if (!String(input.value).trim()) return;
1047
- save.disabled = true;
1048
- api('/bet/report', { value: input.value }).then(function (r) {
1049
- if (r.ok) { moveOn(r.ack); return; }
1050
- save.disabled = false;
1051
- input.value = '';
1052
- input.placeholder = r.message || 'that did not go through';
1053
- });
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);
1054
968
  };
1055
- save.addEventListener('click', go);
1056
- input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } });
1057
- act.appendChild(input);
1058
- act.appendChild(save);
1059
- // a quiet way out: moves on, saves nothing, and is not asked again today
1060
- var links = el('div', 'links');
1061
- var later = el('button', null, 'not now');
1062
- later.type = 'button';
1063
- later.addEventListener('click', function () { if (bet.busy) return; buzz(); think(false, 0); });
1064
- links.appendChild(later);
1065
- act.appendChild(links);
1066
- box.appendChild(act);
1067
- box.appendChild(sayBox());
1068
- }
1069
-
1070
- // A conversation in one box. While the words are read the reply spot shows
1071
- // a quiet ellipsis; a question back is answered in place, with the turns so
1072
- // far above it and up to three suggested replies to tap. Starting a member
1073
- // who is not running takes a second tap, or it is kept as a note.
1074
- // post(body) sends it; done(ack) runs when something real came of it.
1075
- function convo(post, done, placeholder) {
1076
- var wrap = el('div', 'links sayline');
1077
- var thread = el('div', 'thread');
1078
- var reply = el('p', 'why reply');
1079
- reply.hidden = true;
1080
- var input = el('input', 'say');
1081
- input.type = 'text';
1082
- input.maxLength = 280;
1083
- input.setAttribute('aria-label', placeholder);
1084
- input.placeholder = placeholder;
1085
- var send = el('button', 'pill', 'send');
1086
- send.type = 'button';
1087
- var extra = el('div', 'links');
1088
- var pending = null;
1089
- // an armed second tap belongs to the words it was asked about: any edit clears it
1090
- function disarm() {
1091
- pending = null;
1092
- send.textContent = 'send';
1093
- send.className = 'pill';
1094
- extra.textContent = '';
1095
- reply.textContent = '';
1096
- reply.hidden = true;
1097
- }
1098
- function drawThread(turns) {
1099
- thread.textContent = '';
1100
- turns.forEach(function (t) { thread.appendChild(el('p', 'turn ' + (t.who === 'owner' ? 'you' : 'orb'), t.text)); });
1101
- }
1102
- function go(more, said) {
1103
- var text = said || input.value.trim();
1104
- if (!text) return;
1105
- send.disabled = true;
1106
- reply.hidden = false;
1107
- reply.textContent = '\u2026';
1108
- extra.textContent = '';
1109
- // the same words carry the same id on every try, so a retry never sends twice
1110
- var body = { text: text, send: sendId(wrap, text) };
1111
- if (more) Object.keys(more).forEach(function (k) { body[k] = more[k]; });
1112
- post(body).catch(offline).then(function (r) {
1113
- if (r.ok && !r.stay) { done(r.ack); return; }
1114
- send.disabled = false;
1115
- if (r.stay) { reply.textContent = r.reply;
1116
- input.value = '';
1117
- drawThread(r.thread.slice(0, -1));
1118
- (r.suggestions || []).forEach(function (sug) {
1119
- var b = el('button', 'quiet', sug);
1120
- b.type = 'button';
1121
- b.addEventListener('click', function () { buzz(); go(null, sug); });
1122
- extra.appendChild(b);
1123
- });
1124
- return;
1125
- }
1126
- if (r.reason === 'needs_confirm') {
1127
- pending = text;
1128
- reply.textContent = r.message;
1129
- send.textContent = 'tap again';
1130
- send.className = 'pill armed';
1131
- var keep = el('button', 'quiet', 'keep it as a note instead');
1132
- keep.type = 'button';
1133
- keep.addEventListener('click', function () { disarm(); go({ note: true }); });
1134
- extra.appendChild(keep);
1135
- return;
1136
- }
1137
- // it did not send: the words are in the box, send works again, and
1138
- // one short line says so
1139
- if (said && !input.value) input.value = said;
1140
- reply.textContent = r.message || 'that did not send. tap send to try again';
1141
- input.focus();
1142
- });
1143
- }
1144
- send.addEventListener('click', function () {
1145
- buzz();
1146
- if (pending) { disarm(); go({ confirm: true }); return; }
1147
- 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();
1148
973
  });
1149
- input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); send.click(); } });
1150
- input.addEventListener('input', function () { if (pending) disarm(); });
1151
- wrap.appendChild(thread);
1152
- wrap.appendChild(reply);
1153
- wrap.appendChild(input);
1154
- wrap.appendChild(send);
1155
- wrap.appendChild(extra);
1156
- return wrap;
974
+ leave(function () { sphere = true; land(); }, true);
1157
975
  }
1158
976
 
1159
- // a quiet line under every card: tap it and the line becomes the box, in
1160
- // its own spot. on a question it is the answer, on an idea a note to
1161
- // rework it, on a yes or a finished job a word to that member; the server
1162
- // reads it first, and a question back is answered
1163
- function sayBox() {
1164
- var wrap = el('div', 'links sayline');
1165
- var open = el('button', 'quiet', 'or say something');
1166
- open.type = 'button';
1167
- open.addEventListener('click', function () {
1168
- if (bet.busy) return;
1169
- typeHere(open, 'say something', 280, function (text, field) { say(field, { text: text }); });
1170
- });
1171
- wrap.appendChild(open);
1172
- 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);
1173
993
  }
1174
994
 
1175
- // two or three ideas side by side: one tap picks one and opens its full card
1176
- function drawChoiceCard(s) {
1177
- var box = document.getElementById('bet');
1178
- box.textContent = '';
1179
- box.appendChild(el('h1', null, 'which one next?'));
1180
- var list = el('div', 'options');
1181
- s.choice.options.forEach(function (o) {
1182
- var b = el('button', 'option');
1183
- b.type = 'button';
1184
- b.appendChild(el('span', 't', o.title));
1185
- b.appendChild(el('span', 'w', o.why));
1186
- b.addEventListener('click', function () {
1187
- if (bet.busy) return;
1188
- buzz();
1189
- var heldAt = hold(b);
1190
- api('/bet/pick', { id: o.id }).then(function (r) {
1191
- if (r.http === 200 && r.bet) { afterHold(heldAt).then(function () { turn(r); }); return; }
1192
- think(false, heldAt);
1193
- }, function () { think(false, heldAt); });
1194
- });
1195
- list.appendChild(b);
1196
- });
1197
- box.appendChild(list);
1198
- 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);
1199
1004
  }
1200
1005
 
1201
- function drawBetCard(s) {
1202
- if (s.kind === 'report') { drawReportCard(s); return; }
1203
- if (s.kind === 'choice') { drawChoiceCard(s); return; }
1204
- if (s.kind === 'question') { drawQuestionCard(s); return; }
1205
- if (s.kind && s.kind !== 'bet') { drawJobCard(s); return; }
1206
- var box = document.getElementById('bet');
1207
- box.textContent = '';
1208
- var b = s.bet;
1209
- box.appendChild(el('h1', null, b.title));
1210
- // why it matters: what is going on and what we expect, in the same big type
1211
- box.appendChild(el('p', 'why', [b.why_now, b.expect].map(sentence).join(' ')));
1212
- if (s.mission) { box.appendChild(el('p', 'mission', s.mission.line)); return; }
1213
- if (s.status === 'starting') { box.appendChild(el('p', 'mission', 'starting now')); return; }
1214
- var act = el('div', 'act');
1215
- var go = el('button', 'pill' + (bet.armed ? ' armed' : ''), bet.armed ? 'tap again to start' : 'do it');
1216
- go.type = 'button';
1217
- 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');
1218
- go.addEventListener('click', function () {
1219
- buzz();
1220
- if (!bet.armed) {
1221
- bet.armed = true; drawBetCard(s);
1222
- setTimeout(function () { if (bet.armed) { bet.armed = false; if (bet.last) drawBetCard(bet.last); } }, 5000);
1223
- return;
1224
- }
1225
- bet.armed = false;
1226
- s.status = 'starting'; s.unstarted = null; drawBetCard(s);
1227
- api('/bet/do', { confirm: true }).catch(offline).then(function (r) {
1228
- if (!r || !r.ok) {
1229
- // it did not get going: the card comes back as it was, do it and
1230
- // all, with one line why, and the page follows this card again
1231
- s.status = 'ready';
1232
- s.unstarted = (r && r.message) || 'that did not start. tap do it to try again';
1233
- bet.drawn = s.id;
1234
- drawBetCard(s);
1235
- refresh();
1236
- return;
1237
- }
1238
- loadBet(); refresh();
1239
- setTimeout(function () { tapOrb(false); }, AFTER_MS);
1240
- });
1241
- });
1242
- act.appendChild(go);
1243
- if (s.unstarted) act.appendChild(el('p', 'links unsent', s.unstarted));
1244
- var links = el('div', 'links');
1245
- var sharp = el('button', null, 'sharper idea');
1246
- sharp.type = 'button';
1247
- sharp.addEventListener('click', function () { tapOrb(true); });
1248
- var skip = el('button', null, 'not now');
1249
- skip.type = 'button';
1250
- skip.addEventListener('click', function () {
1251
- buzz();
1252
- api('/bet/skip', {}).then(function () {
1253
- dissolve(function () { bet.status = 'skipped'; line.set('not now. tap when you want another', false); });
1254
- });
1255
- });
1256
- links.appendChild(sharp);
1257
- links.appendChild(document.createTextNode(' · '));
1258
- links.appendChild(skip);
1259
- act.appendChild(links);
1260
- box.appendChild(act);
1261
- 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;
1262
1021
  }
1263
1022
 
1023
+ // ---- showing a card ---------------------------------------------------------------------
1264
1024
  // the time from the tap to the card fully on screen, kept on the page so
1265
1025
  // the speed can be measured
1266
1026
  function measured() {
@@ -1268,544 +1028,261 @@ body.has-card .foot { position: static; margin-top: 28px; }
1268
1028
  bet.tapAt = 0;
1269
1029
  }
1270
1030
 
1271
- function showCard(s, animate, fast) {
1272
- var box = document.getElementById('bet');
1273
- bet.last = s;
1274
- bet.drawn = s.id;
1275
- 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
1276
1042
  document.body.classList.add('has-card');
1277
- box.hidden = false;
1278
- box.classList.remove('leave', 'holding');
1279
- box.classList.toggle('quick', Boolean(fast));
1280
- if (!animate) { box.classList.add('up'); measured(); return; }
1281
- box.classList.remove('up');
1282
- var done = function (e) { if (e.propertyName === 'opacity') { box.removeEventListener('transitionend', done); measured(); } };
1283
- box.addEventListener('transitionend', done);
1284
- void box.offsetWidth;
1285
- 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'); });
1286
1051
  }
1287
1052
 
1288
1053
  // where the sphere sits once the card is gone, read before anything moves,
1289
1054
  // in page coordinates (the screen's once the page is back at the top)
1290
1055
  function sphereSpot() {
1291
- var box = document.getElementById('bet');
1292
- var y0 = window.scrollY, was = box.hidden;
1293
- document.body.classList.remove('has-card');
1294
- 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;
1295
1060
  var r = document.getElementById('orb').getBoundingClientRect();
1296
1061
  var spot = { x: r.left + r.width / 2, y: r.top + window.scrollY + r.height / 2 };
1297
1062
  document.body.classList.add('has-card');
1298
- box.hidden = was;
1063
+ if (wide) document.body.classList.add('wide');
1064
+ b.hidden = was;
1299
1065
  if (window.scrollY !== y0) window.scrollTo(0, y0);
1300
1066
  return spot;
1301
1067
  }
1302
1068
 
1303
- // the card fades out in place while the small orb glides down and becomes
1304
- // the sphere again; the card leaves the page once it has faded
1305
- function dissolve(then) {
1306
- var box = document.getElementById('bet');
1307
- var to = sphereSpot();
1308
- box.classList.remove('quick', 'up');
1309
- box.classList.add('leave');
1310
- var swap = function () {
1311
- box.style.minHeight = '';
1312
- box.hidden = true;
1313
- box.classList.remove('leave');
1314
- document.body.classList.remove('has-card');
1315
- window.scrollTo(0, 0);
1316
- };
1317
- if (reduced) { setTimeout(function () { orb.melt(then, to, swap); }, orb.leaveMs); return; }
1318
- orb.melt(then, to, swap);
1319
- }
1320
-
1321
- // what the page does with the server's answer once the sphere is ready.
1322
- // Every tap comes back with a card, so the orb plays straight into it.
1323
- function reveal(s, fast) {
1324
- bet.status = s.status;
1325
- if ((s.bet || s.job || s.question || s.choice || s.report) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting')) {
1326
- line.set('', false);
1327
- orb.play(function (phase) { if (phase === 'settled') { bet.busy = false; showCard(s, true, fast); } }, fast);
1328
- return;
1329
- }
1330
- bet.busy = false;
1331
- orb.relax();
1332
- line.set('tap to think', false);
1333
- }
1334
-
1335
- function isCard(s) {
1336
- return Boolean((s.bet || s.job || s.question || s.choice || s.report) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting'));
1337
- }
1338
-
1339
- // Card to card: a crossfade in place. The small orb stays where it is.
1340
- // A still copy of the old card fades over the box while the new card,
1341
- // drawn under it, fades in from DRIFT below over TURN_IN_MS, starting
1342
- // 80ms in, so its title lands where the old title was. The box eases to
1343
- // 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.
1344
1073
  var TURN_IN_MS = 320, DRIFT = 4;
1345
- function turn(s) {
1346
- 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(); });
1347
1079
  }
1348
1080
 
1349
- // draw() puts the next card in the box
1350
- function crossfade(draw) {
1351
- var box = document.getElementById('bet');
1352
- box.classList.remove('holding');
1353
- turnInPlace(box, draw, function () { bet.busy = false; measured(); });
1354
- }
1355
-
1356
- // one card turns into the next in its own spot: a still copy of the old
1357
- // one fades over it while the new one drifts in, and the box eases to
1358
- // the new height. after() runs once it is at rest.
1359
- function turnInPlace(box, draw, after) {
1360
- if (reduced) { box.style.minHeight = ''; draw(); after(); return; }
1361
- var from = box.offsetHeight;
1362
- box.style.height = from + 'px';
1363
- box.style.minHeight = '';
1364
- 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);
1365
1087
  ghost.removeAttribute('id');
1366
- ghost.classList.remove('quick', 'turning', 'turn-in');
1088
+ ghost.classList.remove('quick', 'acted', 'turning', 'turn-in');
1367
1089
  ghost.classList.add('up', 'ghost');
1368
1090
  ghost.setAttribute('aria-hidden', 'true');
1369
1091
  ghost.inert = true;
1370
- var r = box.getBoundingClientRect(), p = box.offsetParent.getBoundingClientRect();
1092
+ var r = b.getBoundingClientRect(), p = b.offsetParent.getBoundingClientRect();
1371
1093
  ghost.style.top = (r.top - p.top) + 'px';
1372
1094
  ghost.style.left = (r.left - p.left) + 'px';
1373
1095
  ghost.style.width = r.width + 'px';
1374
- box.after(ghost);
1375
- box.classList.remove('quick', 'turning');
1376
- draw();
1377
- box.style.height = 'auto';
1378
- var to = box.offsetHeight;
1379
- box.style.height = from + 'px';
1380
- box.classList.add('turn-in');
1381
- void box.offsetHeight;
1382
- box.classList.add('turning');
1383
- box.classList.remove('turn-in');
1384
- 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';
1385
1107
  ghost.classList.add('gone');
1386
1108
  setTimeout(function () {
1387
1109
  ghost.remove();
1388
- box.classList.remove('turning');
1389
- box.style.height = '';
1110
+ b.classList.remove('turning');
1111
+ b.style.height = '';
1390
1112
  after();
1391
1113
  }, 80 + TURN_IN_MS + 20);
1392
1114
  }
1393
1115
 
1394
- // one tap: buzz at once and ask the server for the next card. From the
1395
- // sphere the orb plays into it; with a card up it is one calm turn.
1396
- function tapOrb(sharper) {
1397
- if (bet.busy) return;
1398
- buzz();
1399
- 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);
1400
1143
  }
1401
1144
 
1402
- // Ask for the next card. heldAt: an answer has held as chosen since then,
1403
- // so the card is asked for during the hold and turns in once it is over,
1404
- // and the time to the card counts from that answer.
1405
- 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();
1406
1153
  bet.busy = true;
1407
- bet.armed = false;
1408
- bet.tapAt = heldAt || performance.now();
1154
+ bet.tapAt = performance.now();
1409
1155
  document.body.removeAttribute('data-reveal-ms');
1410
- var onCard = document.body.classList.contains('has-card');
1411
1156
  var failed = function () {
1412
1157
  bet.busy = false;
1413
- document.getElementById('bet').classList.remove('holding');
1414
1158
  orb.relax();
1415
1159
  line.set('that did not go through. tap to try again', true);
1416
1160
  };
1417
- Promise.all([api('/bet/think', { sharper: Boolean(sharper) }), afterHold(heldAt)]).then(function (both) {
1418
- var s = both[0];
1419
- if (s.http !== 200) { failed(); return; }
1420
- bet.status = s.status;
1421
- if (onCard && isCard(s)) { turn(s); return; }
1422
- if (onCard) { dissolve(function () { reveal(s, true); }); return; }
1423
- 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);
1424
1165
  }, failed);
1425
1166
  }
1426
1167
 
1427
- function loadBet() {
1428
- return api('/bet').then(function (s) {
1429
- if (s.http !== 200) return;
1430
- bet.status = s.status;
1431
- 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(); }
1432
- });
1433
- }
1434
-
1435
- function poll() {
1436
- if (bet.polling) return;
1437
- bet.polling = true;
1438
- setTimeout(function () { bet.polling = false; loadBet(); }, 400);
1439
- }
1440
-
1441
- // first look: a card already on screen shows settled, anything else is the sphere
1442
- function firstLook() {
1443
- api('/bet').then(function (s) {
1444
- if (s.http !== 200) return;
1445
- bet.status = s.status;
1446
- if ((s.bet || s.job || s.question || s.choice || s.report) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting')) {
1447
- orb.settled();
1448
- showCard(s, false);
1449
- return;
1450
- }
1451
- line.set(s.status === 'skipped' ? 'not now. tap when you want another' : 'tap to think', false);
1452
- });
1453
- }
1454
-
1455
- // The small orb on a card goes back to the big dotted orb, the same calm
1456
- // way the page goes home before a new idea: the card fades, the small orb
1457
- // glides down and the dots form again, idle under the pointer. The card
1458
- // 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.
1459
1172
  function goHome() {
1460
1173
  if (bet.busy) return;
1461
1174
  buzz();
1462
1175
  bet.busy = true;
1463
- bet.armed = false;
1464
- 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; });
1465
1180
  }
1466
1181
 
1467
- // The big orb: the next idea, with the spiral, as on page open. An idea
1468
- // that landed behind the calm line is the next one, so it is never passed.
1469
- function tapBig() { tapOrb(false); }
1470
-
1471
1182
  function tapSmallOrb() {
1472
- if (document.body.classList.contains('has-card')) goHome(); else tapBig();
1183
+ if (document.body.classList.contains('has-card')) goHome(); else think();
1473
1184
  }
1474
1185
 
1475
1186
  var canvas = document.getElementById('orb');
1476
1187
  var lavaTap = document.getElementById('lava');
1477
- [[canvas, tapBig], [lavaTap, tapSmallOrb]].forEach(function (pair) {
1188
+ [[canvas, think], [lavaTap, tapSmallOrb]].forEach(function (pair) {
1478
1189
  pair[0].addEventListener('click', pair[1]);
1479
1190
  pair[0].addEventListener('keydown', function (e) {
1480
1191
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pair[1](); }
1481
1192
  });
1482
1193
  });
1483
1194
 
1484
- // ---- the team: who is on what, from real state, and a word to any of them ----
1485
-
1486
- var teamBox = document.getElementById('team');
1487
- var teamInner = document.getElementById('team-inner');
1488
- // the way back from what is on top: the team closes, a talk card goes back to the team
1489
- var teamBack = null;
1490
-
1491
- // every redraw of the team box makes a new view; an answer that comes
1492
- // back for an older view is dropped (a closed box is hidden, and the next
1493
- // open is a new view)
1494
- var teamView = 0;
1495
-
1496
- function closeTeam() { teamBox.hidden = true; }
1497
-
1498
- function teamHead(title) {
1499
- teamView += 1;
1500
- teamInner.textContent = '';
1501
- teamInner.className = 'inner';
1502
- teamBack = closeTeam;
1503
- var head = el('div', 'head');
1504
- head.appendChild(el('h1', null, title));
1505
- var close = el('button', 'quiet', 'close');
1506
- close.type = 'button';
1507
- close.addEventListener('click', closeTeam);
1508
- head.appendChild(close);
1509
- teamInner.appendChild(head);
1510
- }
1511
-
1512
- function openTeam() {
1513
- teamBox.hidden = false;
1514
- teamHead('the team');
1515
- var view = teamView;
1516
- api('/bet/team').then(function (s) { if (s.http === 200 && view === teamView) drawTeam(s.rows || []); });
1517
- }
1518
-
1519
- // one row per member, five columns; a tap anywhere on a row opens its talk
1520
- // card, and the name is a button so the keyboard reaches it too
1521
- var TEAM_COLUMNS = ['member', 'role', 'ovr', 'engine', 'status'];
1522
- var TEAM_CELLS = ['who', 'role', 'ovr', 'eng', 'st'];
1523
-
1524
- // note is the one plain line a swap just said, shown over the table
1525
- function drawTeam(rows, note) {
1526
- teamHead('the team');
1527
- if (note) teamInner.appendChild(el('p', 'done', note));
1528
- if (!rows.length) { teamInner.appendChild(el('p', 'why', 'nobody is on the team yet.')); return; }
1529
- teamInner.className = 'inner wide';
1530
- deckEntry(rows);
1531
- var grid = el('div', 'grid');
1532
- var table = el('table', 'roster');
1533
- var head = el('tr');
1534
- TEAM_COLUMNS.forEach(function (name, i) { head.appendChild(el('th', TEAM_CELLS[i], name)).scope = 'col'; });
1535
- table.appendChild(el('thead')).appendChild(head);
1536
- var body = table.appendChild(el('tbody'));
1537
- rows.forEach(function (r) {
1538
- var tr = el('tr', 'row');
1539
- var who = el('td', 'who');
1540
- var open = who.appendChild(el('button', null, r.member));
1541
- open.type = 'button';
1542
- open.setAttribute('aria-label', 'talk to ' + r.member);
1543
- tr.appendChild(who);
1544
- tr.appendChild(el('td', 'role', r.role));
1545
- tr.appendChild(el('td', 'ovr', r.ovr));
1546
- // the engine cell is its own button: a tap opens the engine card, not the talk card
1547
- var eng = tr.appendChild(el('td', 'eng'));
1548
- if (r.engine === 'person') eng.textContent = r.engine;
1549
- else {
1550
- var swap = eng.appendChild(el('button', null, r.engine));
1551
- swap.type = 'button';
1552
- swap.setAttribute('aria-label', 'change what ' + r.member + ' runs on');
1553
- swap.addEventListener('click', function (e) { e.stopPropagation(); drawEngine(r); });
1554
- }
1555
- tr.appendChild(el('td', 'st ' + r.status.replace(/ /g, '-'), r.sentence));
1556
- tr.addEventListener('click', function () { drawTalk(r); });
1557
- body.appendChild(tr);
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);
1558
1201
  });
1559
- grid.appendChild(table);
1560
- teamInner.appendChild(grid);
1561
1202
  }
1562
1203
 
1563
- // the engine card: what the member runs on, who a swap is for, then every
1564
- // model this machine offers by tool, the current one on top. One tap swaps
1565
- // (the tap is the approval) and the team comes back with the new engine in
1566
- // its row and one line saying what changed. Esc goes back to the team.
1567
- function drawEngine(r) {
1568
- teamHead(r.member);
1569
- teamBack = openTeam;
1570
- // this card's view and member: an answer for any other is not drawn, and
1571
- // every swap button posts this member, never whoever is on screen later
1572
- var view = teamView;
1573
- var member = r.member;
1574
- var mine = function (s) { return view === teamView && (!s || !s.member || s.member === member); };
1575
- var said = teamInner.appendChild(el('p', 'why', member + ' runs on ' + r.engine + '.'));
1576
- var back = el('button', 'quiet', 'back to the team');
1577
- back.type = 'button';
1578
- back.addEventListener('click', openTeam);
1579
- var busy = false;
1580
- api('/bet/engine', { member: member }).then(function (s) {
1581
- if (!mine(s)) return;
1582
- if (s.http !== 200 || !s.ok) { said.textContent = s.message || 'could not read the models on this machine.'; teamInner.appendChild(back); return; }
1583
- said.textContent = s.line;
1584
- var scope = 'member';
1585
- var scopes = el('div', 'scopes');
1586
- scopes.setAttribute('role', 'group');
1587
- scopes.setAttribute('aria-label', 'who the swap is for');
1588
- (s.scopes || []).forEach(function (sc) {
1589
- var b = scopes.appendChild(el('button', sc.id === scope ? 'chosen' : null, sc.label));
1590
- b.type = 'button';
1591
- b.setAttribute('aria-pressed', sc.id === scope ? 'true' : 'false');
1592
- b.addEventListener('click', function () {
1593
- scope = sc.id;
1594
- Array.prototype.forEach.call(scopes.children, function (x) { var on = x === b; x.classList.toggle('chosen', on); x.setAttribute('aria-pressed', on ? 'true' : 'false'); });
1595
- });
1596
- });
1597
- if ((s.scopes || []).length > 1) teamInner.appendChild(scopes);
1598
- (s.groups || []).forEach(function (g) {
1599
- var box = teamInner.appendChild(el('section', 'models'));
1600
- box.appendChild(el('h2', null, g.tool));
1601
- g.models.forEach(function (m) {
1602
- var b = box.appendChild(el('button', 'choice' + (m.current ? ' chosen' : '')));
1603
- b.type = 'button';
1604
- b.appendChild(el('span', 't', m.label));
1605
- var w = m.current ? 'now' : m.record;
1606
- if (w) b.appendChild(el('span', 'w', w));
1607
- if (m.current) { b.disabled = true; return; }
1608
- b.setAttribute('data-member', member);
1609
- b.addEventListener('click', function () {
1610
- if (busy || !mine()) return;
1611
- busy = true;
1612
- b.classList.add('chosen');
1613
- api('/bet/swap', { member: b.getAttribute('data-member'), engine: m.engine, model: m.model, scope: scope }).then(function (out) {
1614
- busy = false;
1615
- if (!mine()) return;
1616
- if (out.http === 200 && out.ok) { drawTeam(out.rows || [], out.ack); return; }
1617
- b.classList.remove('chosen');
1618
- said.textContent = out.message || 'that did not change. try again.';
1619
- }, function () { busy = false; if (!mine()) return; b.classList.remove('chosen'); said.textContent = 'that did not reach the mac. try again.'; });
1620
- });
1621
- });
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);
1622
1217
  });
1623
- teamInner.appendChild(back);
1624
- }, function () { if (!mine()) return; said.textContent = 'that did not reach the mac. try again.'; teamInner.appendChild(back); });
1625
- }
1626
-
1627
- // a talk card: what the member is on, and a word to steer them
1628
- function drawTalk(r) {
1629
- teamHead(r.member);
1630
- teamBack = openTeam;
1631
- teamInner.appendChild(el('p', 'why', r.sentence + '.'));
1632
- var talkBox = convo(function (body) { body.member = r.member; return api('/bet/steer', body); }, function (ack) {
1633
- talkBox.replaceWith(el('p', 'mission', ack));
1634
- }, 'tell ' + r.member + ' something');
1635
- var back = el('button', 'quiet', 'back to the team');
1636
- back.type = 'button';
1637
- back.addEventListener('click', openTeam);
1638
- teamInner.appendChild(talkBox);
1639
- teamInner.appendChild(back);
1640
- }
1641
-
1642
- // ---- the job deck: every running job is one card, the ones that need
1643
- // the owner first. The arrows or a sideways swipe flip one card at a
1644
- // time, and it turns in its own spot the way the main card does. Esc
1645
- // goes back to talking. A card that needs the owner is that job's yes
1646
- // card, and under the table one box talks to the job's member and keeps
1647
- // the turns, even across flips.
1648
- var deckCards = [];
1649
- var deckColumns = [];
1650
- var deckAt = 0;
1651
- var deckTurning = false;
1652
- var deckTalk = {};
1653
- var DECK_LIVE = ['waiting for your yes', 'working', 'waiting to start'];
1654
-
1655
- // the way in from the team page, there only while a job is running
1656
- function deckEntry(rows) {
1657
- if (!rows.some(function (r) { return DECK_LIVE.indexOf(r.status) >= 0; })) return;
1658
- var open = el('button', 'quiet', 'the jobs, one at a time');
1659
- open.type = 'button';
1660
- open.addEventListener('click', openDeck);
1661
- teamInner.appendChild(open);
1662
- }
1663
-
1664
- function openDeck() {
1665
- teamBox.hidden = false;
1666
- teamHead('the jobs');
1667
- var waiting = teamInner.appendChild(el('div', 'deckspot'));
1668
- api('/bet/jobs').then(function (s) {
1669
- // the owner moved on while it loaded: nothing is drawn over it
1670
- if (s.http !== 200 || !waiting.isConnected || teamBox.hidden) return;
1671
- deckCards = s.cards || [];
1672
- deckColumns = s.columns || [];
1673
- if (!deckCards.length) { openTeam(); return; }
1674
- deckAt = Math.min(deckAt, deckCards.length - 1);
1675
- drawDeck();
1676
- });
1677
- }
1678
-
1679
- function drawDeck() {
1680
- teamHead('the jobs');
1681
- teamInner.className = 'inner wide';
1682
- var spot = teamInner.appendChild(el('div', 'deckspot'));
1683
- // a card like the main one, so it turns the same way
1684
- var card = spot.appendChild(el('article', 'bet'));
1685
- card.classList.add('up', 'deckcard');
1686
- fillDeckCard(card);
1687
- var startX = null, startY = null;
1688
- card.addEventListener('touchstart', function (e) { startX = e.touches[0].clientX; startY = e.touches[0].clientY; }, { passive: true });
1689
- card.addEventListener('touchend', function (e) {
1690
- if (startX === null) return;
1691
- var dx = e.changedTouches[0].clientX - startX, dy = e.changedTouches[0].clientY - startY;
1692
- startX = null;
1693
- if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) flipDeck(dx < 0 ? 1 : -1);
1694
- });
1695
- var bar = el('div', 'deckbar');
1696
- if (deckCards.length > 1) {
1697
- var prev = bar.appendChild(el('button', 'quiet', 'previous'));
1698
- prev.type = 'button';
1699
- prev.addEventListener('click', function () { flipDeck(-1); });
1700
- }
1701
- var whole = bar.appendChild(el('button', 'quiet', 'the whole team'));
1702
- whole.type = 'button';
1703
- whole.addEventListener('click', openTeam);
1704
- if (deckCards.length > 1) {
1705
- var next = bar.appendChild(el('button', 'quiet', 'next'));
1706
- next.type = 'button';
1707
- next.addEventListener('click', function () { flipDeck(1); });
1218
+ return;
1708
1219
  }
1709
- teamInner.appendChild(bar);
1710
- }
1711
-
1712
- // one job: its place in the deck, the yes card when it needs the owner,
1713
- // the divided table, how long, and the talk box for its member
1714
- function fillDeckCard(card) {
1715
- var c = deckCards[deckAt];
1716
- card.textContent = '';
1717
- card.appendChild(el('span', 'count', 'job ' + (deckAt + 1) + ' of ' + deckCards.length));
1718
- card.appendChild(el('span', 'label ' + c.label.replace(/ /g, '-'), c.label));
1719
- if (c.yes) {
1720
- card.appendChild(el('h1', null, c.yes.what));
1721
- if (c.yes.why) card.appendChild(el('p', 'why', c.yes.why));
1722
- var act = card.appendChild(el('div', 'act'));
1723
- act.appendChild(yesPill(function () { return api('/bet/yes', { confirm: true, key: c.yes.key, job: c.job_id }); }, function (said, r) {
1724
- if (r.ok || r.reason === 'gone') { c.yes = null; c.said = said; act.replaceWith(el('p', 'mission', said)); return; }
1725
- c.unsent = said;
1726
- fillDeckCard(card);
1727
- }));
1728
- if (c.unsent) act.appendChild(el('p', 'links unsent', c.unsent));
1729
- } else if (c.said) card.appendChild(el('p', 'mission', c.said));
1730
- var table = el('table', 'jobs');
1731
- var head = el('tr');
1732
- deckColumns.forEach(function (k) { head.appendChild(el('th', k, k)).scope = 'col'; });
1733
- table.appendChild(el('thead')).appendChild(head);
1734
- var body = table.appendChild(el('tbody'));
1735
- c.rows.forEach(function (r) {
1736
- var tr = body.appendChild(el('tr'));
1737
- deckColumns.forEach(function (k) { tr.appendChild(el('td', k, r[k] || '')); });
1738
- });
1739
- card.appendChild(el('div', 'jobs-wrap')).appendChild(table);
1740
- if (c.time) card.appendChild(el('span', 'time', c.time));
1741
- card.appendChild(deckTalk[c.member] || (deckTalk[c.member] = talkBox(c.member)));
1220
+ flow(answer, bet.shown, null, null);
1742
1221
  }
1222
+ document.getElementById('working').addEventListener('click', function () { openFrom('team'); });
1223
+ document.getElementById('needs').addEventListener('click', function () { openFrom('deck'); });
1743
1224
 
1744
- // one talk box per member, made once and kept, so the turns and any
1745
- // words being typed are still there after a flip; what came of a word
1746
- // stays above a fresh box
1747
- function talkBox(member) {
1748
- var holder = el('div', 'jobtalk');
1749
- var fresh = function () {
1750
- var box = convo(function (body) { body.member = member; return api('/bet/steer', body); }, function (ack) {
1751
- holder.insertBefore(el('p', 'mission', ack), box);
1752
- box.replaceWith(fresh());
1753
- }, 'talk to this job');
1754
- return box;
1755
- };
1756
- holder.appendChild(fresh());
1757
- return holder;
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;
1758
1229
  }
1759
-
1760
- function flipDeck(step) {
1761
- var card = teamInner.querySelector('.deckcard');
1762
- if (!card || deckTurning || deckCards.length < 2) return;
1763
- deckAt = (deckAt + step + deckCards.length) % deckCards.length;
1764
- deckTurning = true;
1765
- turnInPlace(card, function () { fillDeckCard(card); }, function () { deckTurning = false; });
1230
+ function useOption(o) {
1231
+ var b = o && box().querySelector('[data-option="' + o.id + '"]');
1232
+ if (b && !bet.busy) b.click();
1766
1233
  }
1767
1234
 
1768
- // the arrows flip the deck, unless the owner is typing in a box
1769
- document.addEventListener('keydown', function (e) {
1770
- var step = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
1771
- if (!step || e.isComposing || teamBox.hidden || !teamInner.querySelector('.deckcard')) return;
1772
- var field = document.activeElement;
1773
- if (field && (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA')) return;
1774
- e.preventDefault();
1775
- flipDeck(step);
1776
- });
1777
-
1778
- document.getElementById('working').addEventListener('click', openTeam);
1779
-
1780
- // Esc on the team page steps back one layer, the way its own buttons do:
1781
- // words being typed are cancelled and the cursor leaves first, then the
1782
- // talk card goes back to the team, then the team closes to the card.
1783
- // On the card itself it does nothing. It listens on the way down, so the
1784
- // 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.
1785
1237
  document.addEventListener('keydown', function (e) {
1786
- if (e.key !== 'Escape' || e.isComposing || teamBox.hidden) return;
1787
- e.preventDefault();
1238
+ if (e.key !== 'Escape' || e.isComposing || !bet.shown || !document.body.classList.contains('has-card')) return;
1788
1239
  var field = document.activeElement;
1789
- if (field && (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA')) {
1240
+ if (field && field.tagName === 'INPUT') {
1241
+ e.preventDefault();
1790
1242
  field.value = '';
1791
- // the field's own input listener drops a second tap armed for those words
1792
- field.dispatchEvent(new Event('input'));
1793
1243
  field.blur();
1794
1244
  return;
1795
1245
  }
1796
- teamBack();
1246
+ var back = optionFor('back');
1247
+ if (back) { e.preventDefault(); useOption(back); }
1797
1248
  }, true);
1798
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
+
1799
1269
  // the footer: how many members are really working, or the team when none are
1800
1270
  function drawWorking(n) {
1801
1271
  var btn = document.getElementById('working');
1802
1272
  btn.textContent = n ? n + ' working' : 'the team';
1803
1273
  }
1804
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.
1805
1278
  function refresh() {
1806
- return api('/bet').then(function (s) {
1279
+ return api('/card').then(function (s) {
1807
1280
  if (s.http !== 200) { line.set('this link stopped working. start gm phone again on the mac.', true); return; }
1808
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
+ }
1809
1286
  }).catch(function () { /* the next refresh tries again */ });
1810
1287
  }
1811
1288