atris 3.73.1 → 3.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/bin/atris.js +85 -82
  2. package/commands/aeo.js +2 -2
  3. package/commands/align.js +10 -19
  4. package/commands/auth.js +1 -1
  5. package/commands/autopilot.js +4 -8
  6. package/commands/avail.js +36 -7
  7. package/commands/computer.js +3 -1
  8. package/commands/console.js +1 -1
  9. package/commands/context-sync.js +1 -60
  10. package/commands/fleet.js +0 -1
  11. package/commands/lifecycle.js +17 -6
  12. package/commands/log-sync.js +1 -1
  13. package/commands/member.js +158 -18
  14. package/commands/mission.js +157 -69
  15. package/commands/now.js +0 -7
  16. package/commands/plugin.js +1 -1
  17. package/commands/pull.js +1 -1
  18. package/commands/push.js +0 -1
  19. package/commands/radar.js +0 -4
  20. package/commands/recap.js +25 -1
  21. package/commands/report.js +0 -4
  22. package/commands/review.js +1 -1
  23. package/commands/setup.js +6 -2
  24. package/commands/slop.js +8 -2
  25. package/commands/task.js +0 -522
  26. package/commands/terminal.js +1 -1
  27. package/commands/youtube.js +4 -6
  28. package/commands/zen.js +4 -4
  29. package/lib/agent-today.js +412 -0
  30. package/lib/autoland.js +0 -8
  31. package/lib/bench/runner.js +0 -4
  32. package/lib/chat-log-scan.js +0 -28
  33. package/lib/cli-scope.js +4 -2
  34. package/lib/engine-registry.js +0 -4
  35. package/lib/falsifier-probe.js +2 -1
  36. package/lib/file-ops.js +6 -1
  37. package/lib/first-minute.js +2 -0
  38. package/lib/gm-bet.js +125 -30
  39. package/lib/gm-phone-page.js +398 -99
  40. package/lib/gm-phone.js +30 -3
  41. package/lib/handoff-wake.js +180 -0
  42. package/lib/manifest.js +0 -10
  43. package/lib/member-switches.js +0 -11
  44. package/lib/member-up.js +18 -3
  45. package/lib/mission-protected-lane.js +38 -6
  46. package/lib/review-integrity.js +0 -4
  47. package/lib/spawn-env.js +92 -3
  48. package/lib/task-proof.js +2 -1
  49. package/lib/task-receipt.js +3 -2
  50. package/lib/yes-lock.js +633 -81
  51. package/package.json +1 -1
@@ -86,6 +86,8 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
86
86
  .team .time { grid-column: 1 / -1; font-size: 17px; color: var(--text-2); }
87
87
  .sayline { margin: 10px 0 0; display: flex; flex-direction: column; align-items: center; gap: 8px; }
88
88
  .sayline .thread, .sayline .reply { width: 100%; text-align: left; margin: 0; }
89
+ .unsent { margin: 0; text-align: center; }
90
+ .act > .links:empty { display: none; }
89
91
  .turn { margin: 0 0 6px; font-size: 18px; line-height: 1.4; }
90
92
  .turn.you { color: var(--text-2); }
91
93
  .turn.orb { color: var(--text); }
@@ -127,12 +129,14 @@ body.has-card .foot { position: static; margin-top: 28px; }
127
129
  .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); }
128
130
  .bet.up { opacity: 1; transform: none; }
129
131
  .bet.quick { transition-duration: 0.4s, 0.5s; }
130
- /* card to card: the old card lifts and fades, the box eases to the new
131
- height, and the new card settles up from a small drift */
132
- .bet.turning { overflow: hidden; transition: opacity 0.22s ease-in, transform 0.22s ease-in, height 0.36s cubic-bezier(0.4, 0, 0.2, 1); }
133
- .bet.turning.settle { transition: opacity 0.36s ease-out, transform 0.36s cubic-bezier(0.22, 1, 0.36, 1), height 0.36s cubic-bezier(0.4, 0, 0.2, 1); }
134
- .bet.turn-out { opacity: 0; transform: translateY(-10px); }
135
- .bet.turn-in { opacity: 0; transform: translateY(10px); transition: none; }
132
+ /* card to card: a crossfade in place. the old card is a still copy that
133
+ fades over the box while the new card fades in under it from a small
134
+ drift, its title landing where the old title was; the box eases to the
135
+ new height */
136
+ .bet.ghost { position: absolute; z-index: 1; margin: 0; pointer-events: none; transition: opacity 0.2s ease-out; }
137
+ .bet.ghost.gone { opacity: 0; }
138
+ .bet.turning { overflow: hidden; transition: opacity 0.32s ease-out 0.08s, transform 0.32s cubic-bezier(0.22, 1, 0.36, 1) 0.08s, height 0.36s cubic-bezier(0.4, 0, 0.2, 1); }
139
+ .bet.turn-in { opacity: 0; transform: translateY(4px); transition: none; }
136
140
  /* leaving: a soft fade in place, never a cut */
137
141
  .bet.leave { opacity: 0; transform: none; transition: opacity 0.4s ease-out; }
138
142
  .bet h1 { margin: 0; font-size: clamp(23px, 6.2vw, 30px); line-height: 1.12; font-weight: 700; letter-spacing: -0.03em; text-align: left; }
@@ -159,6 +163,20 @@ body.has-card .foot { position: static; margin-top: 28px; }
159
163
  /* an answer to a question card */
160
164
  .choice { width: 100%; min-height: 52px; text-align: left; padding: 12px 16px; border-radius: 14px; border: 1px solid var(--line); color: var(--text); font-size: 19px; }
161
165
 
166
+ /* typing in place: the tapped answer becomes this box at its own size and
167
+ spot, the cursor in it, and the rest of the card dims and stays put */
168
+ .field { position: relative; display: block; width: 100%; padding: 0; border: 1px solid var(--line); border-radius: 14px; color: var(--text); }
169
+ .field input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0 76px 0 16px; border: 0; border-radius: 14px; background: transparent; color: var(--text); font: inherit; outline: none; }
170
+ .field input::placeholder { color: var(--text-2); opacity: 1; }
171
+ .field:focus-within { border-color: var(--text-2); }
172
+ .field .send { position: absolute; top: 0; right: 0; bottom: 0; min-width: 64px; padding: 0 14px; color: var(--text-2); font-size: 17px; text-decoration: none; }
173
+ .sayline .field input { padding: 0 76px; text-align: center; }
174
+ /* a chosen answer holds its spot with an amber edge, and the card takes no taps */
175
+ .choice.chosen, .option.chosen, .field.chosen { border-color: var(--amber); background: rgba(255, 140, 66, 0.1); }
176
+ .chosen .send { visibility: hidden; }
177
+ .bet.holding { pointer-events: none; }
178
+ .muted { opacity: 0.35; transition: opacity 0.2s ease; }
179
+
162
180
  @media (prefers-reduced-motion: reduce) {
163
181
  .lava .tx, .lava .ty, .lava .blob { animation: none !important; }
164
182
  .bet { transform: none; transition: opacity 0.2s linear; }
@@ -175,7 +193,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
175
193
  <article class="bet" id="bet" hidden></article>
176
194
  <div class="foot" id="foot"><button type="button" class="quiet" id="working" aria-haspopup="dialog">the team</button></div>
177
195
  </main>
178
- <div class="lava" id="lava" role="button" tabindex="-1" aria-label="the atris orb. tap for the next idea">
196
+ <div class="lava" id="lava" role="button" tabindex="-1" aria-label="the atris orb. tap to go back to the big orb">
179
197
  <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>
180
198
  <div class="tx" style-vars="18%,12.6s,-7.2s"><div class="ty" style-vars="15%,7.2s,-4.7s"><div class="blob b2 rev" spin-vars="48s,45deg"></div></div></div>
181
199
  <div class="tx" style-vars="12%,7.2s,-6.4s"><div class="ty" style-vars="18%,10.1s,-9.9s"><div class="blob b3" spin-vars="57.6s,90deg"></div></div></div>
@@ -624,7 +642,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
624
642
  })();
625
643
 
626
644
  // ---- the bet ------------------------------------------------------------------
627
- var bet = { status: null, polling: false, armed: false, drawn: null, busy: false, last: null };
645
+ var bet = { status: null, polling: false, armed: false, drawn: null, busy: false, last: null, watching: null };
628
646
  // how long "builder has it" (or any ack) stays before the next card
629
647
  var AFTER_MS = 1500;
630
648
 
@@ -668,7 +686,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
668
686
  box.textContent = '';
669
687
  var j = s.job;
670
688
  box.appendChild(el('h1', null, j.what));
671
- box.appendChild(el('p', 'why', j.why));
689
+ if (j.why) box.appendChild(el('p', 'why', j.why));
672
690
  var act = el('div', 'act');
673
691
  if (s.kind === 'yes') {
674
692
  var yes = el('button', 'pill', 'yes');
@@ -677,9 +695,11 @@ body.has-card .foot { position: static; margin-top: 28px; }
677
695
  api('/bet/yes', { confirm: true }).then(function (r) { moveOn(r.ack || r.message || 'sent'); });
678
696
  });
679
697
  act.appendChild(yes);
680
- act.appendChild(el('div', 'links', 'or tap the orb for the next one'));
681
698
  } else if (s.kind === 'wait') {
682
- act.appendChild(el('div', 'links', 'or tap the orb for the next one'));
699
+ // one calm line: the idea turns in on its own once it is written
700
+ watchWait();
701
+ box.appendChild(sayBox());
702
+ return;
683
703
  } else {
684
704
  var ok = el('button', 'pill', 'nice');
685
705
  ok.type = 'button';
@@ -693,6 +713,31 @@ body.has-card .foot { position: static; margin-top: 28px; }
693
713
  box.appendChild(sayBox());
694
714
  }
695
715
 
716
+ // While the calm line shows, look every WAIT_LOOK_MS for the idea; once the
717
+ // server has one ready, it is asked for the way a tap does and turns in
718
+ // with one crossfade. No tap, and nothing moves on the page while it
719
+ // waits. It also waits while the owner types there (the box has the
720
+ // cursor or any words), so it comes in after they send, or once they
721
+ // clear the box and leave it; and on the big orb, it waits for their tap.
722
+ var WAIT_LOOK_MS = 1500;
723
+ function typingOnCard() {
724
+ var input = document.getElementById('bet').querySelector('.field input');
725
+ return Boolean(input && (input === document.activeElement || input.value.trim()));
726
+ }
727
+ function watchWait() {
728
+ if (bet.watching) return;
729
+ var quiet = function () { return !bet.busy && document.body.classList.contains('has-card') && !typingOnCard(); };
730
+ bet.watching = setInterval(function () {
731
+ if (!bet.last || bet.last.kind !== 'wait') { clearInterval(bet.watching); bet.watching = null; return; }
732
+ if (!quiet()) return;
733
+ api('/bet').then(function (s) {
734
+ if (s.http !== 200 || !quiet() || !bet.last || bet.last.kind !== 'wait') return;
735
+ if (s.id !== bet.last.id && s.kind !== 'wait' && isCard(s)) { bet.status = s.status; turn(s); return; }
736
+ if (s.kind === 'wait' && s.queue > 0) think(false, 0);
737
+ }).catch(function () { /* the next look tries again */ });
738
+ }, WAIT_LOOK_MS);
739
+ }
740
+
696
741
  // no bet was ready: one question, then what is going on and why the answer matters
697
742
  function drawQuestionCard(s) {
698
743
  var box = document.getElementById('bet');
@@ -705,48 +750,210 @@ body.has-card .foot { position: static; margin-top: 28px; }
705
750
  var b = el('button', 'choice', c.label);
706
751
  b.type = 'button';
707
752
  b.addEventListener('click', function () {
753
+ if (bet.busy) return;
708
754
  buzz();
709
- if (c.other) { ownAnswer(act, c); return; }
710
- act.querySelectorAll('button').forEach(function (x) { x.disabled = true; });
711
- api('/bet/answer', { choice: c.id }).then(function (r) {
712
- if (r.ok) { moveOn(r.ack); return; }
713
- act.replaceWith(el('p', 'mission', r.message || 'that did not go through'));
714
- });
755
+ // typed words are read first, so a question back is explained and nothing is saved
756
+ if (c.other) { typeHere(b, c.label, 60, function (text, field) { say(field, { text: text }); }); return; }
757
+ answer(b, { choice: c.id });
715
758
  });
759
+ if (c.other) b.classList.add('other');
716
760
  act.appendChild(b);
717
761
  });
718
- act.appendChild(el('div', 'links', 'or tap the orb for the next one'));
762
+ // a quiet line under the answers, empty until an answer does not go through
763
+ act.appendChild(el('div', 'links'));
719
764
  box.appendChild(act);
720
- box.appendChild(sayBox());
765
+ // its own something else box is the one place to type
766
+ if (!q.choices.some(function (c) { return c.other; })) box.appendChild(sayBox());
721
767
  }
722
768
 
723
- // "something else": the owner's own words, one box and one tap to save
724
- function ownAnswer(act, c) {
725
- var input = el('input', 'say');
769
+ // ---- answering in place: the eyes stay in one spot ----------------------------
770
+ // A tapped answer, or the words typed in its place, holds that spot as
771
+ // chosen for HOLD_MS while the rest of the card dims and stays put. The
772
+ // next card is asked for during the hold and turns in once it is over.
773
+ var HOLD_MS = 400;
774
+
775
+ // a failed fetch reads like a refusal, so a hold never hangs
776
+ function offline() { return { ok: false, http: 0 }; }
777
+
778
+ // every answer row of the card dims but the one in use; no spot, none
779
+ function dimOthers(spot) {
780
+ var box = document.getElementById('bet');
781
+ box.querySelectorAll('.act > *, .options > *, .sayline, :scope > .links').forEach(function (n) {
782
+ n.classList.toggle('muted', Boolean(spot) && n !== spot && !n.contains(spot));
783
+ });
784
+ }
785
+
786
+ // the spot holds as chosen and the card takes no taps; returns when it
787
+ // started, which is also where the time to the next card counts from
788
+ function hold(spot) {
789
+ var box = document.getElementById('bet');
790
+ dimOthers(spot);
791
+ spot.classList.add('chosen');
792
+ box.classList.add('holding');
793
+ var input = spot.querySelector('input');
794
+ if (input) { input.readOnly = true; input.blur(); }
795
+ bet.busy = true;
796
+ bet.armed = false;
797
+ bet.tapAt = performance.now();
798
+ document.body.removeAttribute('data-reveal-ms');
799
+ return bet.tapAt;
800
+ }
801
+
802
+ // it did not go through: the card is as it was
803
+ function unhold() {
804
+ var box = document.getElementById('bet');
805
+ box.classList.remove('holding');
806
+ dimOthers(null);
807
+ box.querySelectorAll('.chosen').forEach(function (n) { n.classList.remove('chosen'); });
808
+ bet.busy = false;
809
+ }
810
+
811
+ // resolves once the hold that started at heldAt has run HOLD_MS
812
+ function afterHold(heldAt) {
813
+ var left = heldAt ? heldAt + HOLD_MS - performance.now() : 0;
814
+ return new Promise(function (resolve) { setTimeout(resolve, Math.max(0, left)); });
815
+ }
816
+
817
+ // The tapped button becomes the field at its own size and spot, with the
818
+ // cursor already in it, and the other answers dim. Enter or send hands
819
+ // the words to sent(text, field). Left empty, it turns back into the button.
820
+ function typeHere(btn, placeholder, maxLength, sent) {
821
+ var field = el('div', btn.classList.contains('choice') ? 'choice field' : 'field');
822
+ // its exact height, to the subpixel, so nothing under it moves
823
+ field.style.height = btn.getBoundingClientRect().height + 'px';
824
+ var input = el('input');
726
825
  input.type = 'text';
727
- input.maxLength = 60;
728
- input.setAttribute('aria-label', c.label);
729
- var save = el('button', 'pill', 'save');
730
- save.type = 'button';
731
- var go = function () {
826
+ input.maxLength = maxLength;
827
+ input.placeholder = placeholder;
828
+ input.enterKeyHint = 'send';
829
+ input.setAttribute('aria-label', placeholder);
830
+ var send = el('button', 'send', 'send');
831
+ send.type = 'button';
832
+ var submit = function () {
732
833
  var text = input.value.trim();
733
- if (!text) return;
734
- save.disabled = true;
735
- api('/bet/answer', { choice: 'other', text: text }).then(function (r) {
736
- if (r.ok) { moveOn(r.ack); return; }
737
- save.disabled = false;
738
- input.value = '';
739
- input.placeholder = r.message || 'that did not go through';
740
- });
834
+ if (text && !bet.busy) sent(text, field);
741
835
  };
742
- save.addEventListener('click', go);
743
- input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } });
744
- act.textContent = '';
745
- act.appendChild(input);
746
- act.appendChild(save);
836
+ // a press on send keeps the cursor in the field, so it never closes first
837
+ send.addEventListener('mousedown', function (e) { e.preventDefault(); });
838
+ send.addEventListener('click', function () { buzz(); submit(); });
839
+ input.addEventListener('keydown', function (e) {
840
+ if (e.key === 'Enter') { e.preventDefault(); submit(); }
841
+ if (e.key === 'Escape') { input.value = ''; input.blur(); }
842
+ });
843
+ input.addEventListener('blur', function () {
844
+ if (input.value.trim() || field.classList.contains('chosen') || !field.isConnected) return;
845
+ field.replaceWith(btn);
846
+ dimOthers(null);
847
+ });
848
+ field.appendChild(input);
849
+ field.appendChild(send);
850
+ btn.replaceWith(field);
851
+ dimOthers(field);
747
852
  input.focus();
748
853
  }
749
854
 
855
+ // An answer to a question holds as chosen while it saves. If it does not
856
+ // go through, the card comes back as it was, the words still in the
857
+ // field, and why takes the hint's line under the answers.
858
+ function answer(spot, body) {
859
+ body.card = shownCard();
860
+ var heldAt = hold(spot);
861
+ api('/bet/answer', body).catch(offline).then(function (r) {
862
+ if (r.ok) { think(false, heldAt); return; }
863
+ unhold();
864
+ var hint = document.getElementById('bet').querySelector('.act .links');
865
+ if (hint) hint.textContent = r.message || 'that did not go through';
866
+ var input = spot.querySelector('input');
867
+ if (input) { input.readOnly = false; dimOthers(spot); input.focus(); }
868
+ });
869
+ }
870
+
871
+ // the card the owner is looking at, named with every answer and every
872
+ // word said, so the server acts on that card or on nothing
873
+ function shownCard() { return bet.last && bet.last.id ? bet.last.id : ''; }
874
+
875
+ // Words said on a card hold their spot as chosen while the server reads
876
+ // them. An answer or an instruction brings the next card; a question
877
+ // back or a second tap it needs turns in as the orb's reply, in the
878
+ // title's spot. If it did not send, the words stay where they were.
879
+ // One id per typed send: made when these words are first sent from this
880
+ // spot, the same on every retry of them, and new when the words change,
881
+ // so a send whose reply was lost is never sent twice. getRandomValues
882
+ // works on the plain http of the wifi link, where randomUUID does not.
883
+ function sendId(spot, text) {
884
+ if (spot.sendText !== text) {
885
+ var n = new Uint32Array(2);
886
+ crypto.getRandomValues(n);
887
+ spot.sendText = text;
888
+ spot.sendId = ('000000' + n[0].toString(36)).slice(-7) + ('000000' + n[1].toString(36)).slice(-7);
889
+ }
890
+ return spot.sendId;
891
+ }
892
+
893
+ function say(spot, body) {
894
+ body.card = shownCard();
895
+ body.send = sendId(spot, body.text);
896
+ var heldAt = hold(spot);
897
+ api('/bet/say', body).catch(offline).then(function (r) {
898
+ if (r.ok && !r.stay) { think(false, heldAt); return; }
899
+ if (!r.ok && r.reason !== 'needs_confirm') { unsent(spot, r.message || 'that did not send. tap send to try again'); return; }
900
+ afterHold(heldAt).then(function () { crossfade(function () { drawReply(r, body.text); }); });
901
+ });
902
+ }
903
+
904
+ // It did not send: the card is as it was, the words still in the box with
905
+ // the cursor in it, and one short line under it says why, so Enter (or
906
+ // the same tap) tries again. Left empty, the box turns back into the
907
+ // button and the line goes with it.
908
+ function unsent(spot, why) {
909
+ unhold();
910
+ var note = spot.nextElementSibling;
911
+ var input = spot.querySelector('input');
912
+ if (!note || !note.classList.contains('unsent')) {
913
+ note = el('p', 'links unsent');
914
+ spot.after(note);
915
+ if (input) input.addEventListener('blur', function () { if (!spot.isConnected) note.remove(); });
916
+ }
917
+ note.textContent = why;
918
+ if (input) { input.readOnly = false; dimOthers(spot); input.focus(); }
919
+ }
920
+
921
+ // one reply to tap: it holds as chosen and is said
922
+ function sayChoice(label, body) {
923
+ var b = el('button', 'choice', label);
924
+ b.type = 'button';
925
+ b.addEventListener('click', function () {
926
+ if (bet.busy) return;
927
+ buzz();
928
+ say(b, body);
929
+ });
930
+ return b;
931
+ }
932
+
933
+ // the orb's reply is the title, with the card's own answer right under it
934
+ // (do it, yes, nice, or the answers), then up to three suggested replies;
935
+ // starting a member who is not running takes one more tap, or it is kept
936
+ // as a note. The say line takes anything else.
937
+ function drawReply(r, said) {
938
+ var box = document.getElementById('bet');
939
+ // a reply never takes the card's own answer away: the card is drawn
940
+ // once more, and its row is kept, buttons and all
941
+ var own = null;
942
+ if (bet.last) { drawBetCard(bet.last); own = box.querySelector(':scope > .act, :scope > .options'); }
943
+ box.textContent = '';
944
+ bet.drawn = null;
945
+ box.appendChild(el('h1', null, r.stay ? r.reply : (r.message || 'that did not go through')));
946
+ if (own) box.appendChild(own);
947
+ var act = el('div', 'act answers');
948
+ if (r.stay) (r.suggestions || []).forEach(function (sug) { act.appendChild(sayChoice(sug, { text: sug })); });
949
+ if (r.reason === 'needs_confirm') {
950
+ act.appendChild(sayChoice('start it', { text: said, confirm: true }));
951
+ act.appendChild(sayChoice('keep it as a note instead', { text: said, note: true }));
952
+ }
953
+ box.appendChild(act);
954
+ if (!own || !own.querySelector('.other')) box.appendChild(sayBox());
955
+ }
956
+
750
957
  // the owner's own number, once a week: one box and one tap to save
751
958
  function drawReportCard(s) {
752
959
  var box = document.getElementById('bet');
@@ -774,8 +981,15 @@ body.has-card .foot { position: static; margin-top: 28px; }
774
981
  input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } });
775
982
  act.appendChild(input);
776
983
  act.appendChild(save);
777
- act.appendChild(el('div', 'links', 'or tap the orb for the next one'));
984
+ // a quiet way out: moves on, saves nothing, and is not asked again today
985
+ var links = el('div', 'links');
986
+ var later = el('button', null, 'not now');
987
+ later.type = 'button';
988
+ later.addEventListener('click', function () { if (bet.busy) return; buzz(); think(false, 0); });
989
+ links.appendChild(later);
990
+ act.appendChild(links);
778
991
  box.appendChild(act);
992
+ box.appendChild(sayBox());
779
993
  }
780
994
 
781
995
  // A conversation in one box. While the words are read the reply spot shows
@@ -817,9 +1031,10 @@ body.has-card .foot { position: static; margin-top: 28px; }
817
1031
  reply.hidden = false;
818
1032
  reply.textContent = '\u2026';
819
1033
  extra.textContent = '';
820
- var body = { text: text };
1034
+ // the same words carry the same id on every try, so a retry never sends twice
1035
+ var body = { text: text, send: sendId(wrap, text) };
821
1036
  if (more) Object.keys(more).forEach(function (k) { body[k] = more[k]; });
822
- post(body).then(function (r) {
1037
+ post(body).catch(offline).then(function (r) {
823
1038
  if (r.ok && !r.stay) { done(r.ack); return; }
824
1039
  send.disabled = false;
825
1040
  if (r.stay) { reply.textContent = r.reply;
@@ -844,7 +1059,11 @@ body.has-card .foot { position: static; margin-top: 28px; }
844
1059
  extra.appendChild(keep);
845
1060
  return;
846
1061
  }
847
- reply.textContent = r.message || 'that did not go through';
1062
+ // it did not send: the words are in the box, send works again, and
1063
+ // one short line says so
1064
+ if (said && !input.value) input.value = said;
1065
+ reply.textContent = r.message || 'that did not send. tap send to try again';
1066
+ input.focus();
848
1067
  });
849
1068
  }
850
1069
  send.addEventListener('click', function () {
@@ -862,26 +1081,22 @@ body.has-card .foot { position: static; margin-top: 28px; }
862
1081
  return wrap;
863
1082
  }
864
1083
 
865
- // a quiet line under every card: tap it for one box. on a question it is the
866
- // answer, on an idea a note to rework it, on a yes or a finished job a word
867
- // to that member; the server reads it first, and a question back is answered
1084
+ // a quiet line under every card: tap it and the line becomes the box, in
1085
+ // its own spot. on a question it is the answer, on an idea a note to
1086
+ // rework it, on a yes or a finished job a word to that member; the server
1087
+ // reads it first, and a question back is answered
868
1088
  function sayBox() {
869
1089
  var wrap = el('div', 'links sayline');
870
1090
  var open = el('button', 'quiet', 'or say something');
871
1091
  open.type = 'button';
872
1092
  open.addEventListener('click', function () {
873
- var box = sayConvo();
874
- wrap.replaceWith(box);
875
- box.querySelector('input').focus();
1093
+ if (bet.busy) return;
1094
+ typeHere(open, 'say something', 280, function (text, field) { say(field, { text: text }); });
876
1095
  });
877
1096
  wrap.appendChild(open);
878
1097
  return wrap;
879
1098
  }
880
1099
 
881
- function sayConvo() {
882
- return convo(function (body) { return api('/bet/say', body); }, moveOn, 'say something');
883
- }
884
-
885
1100
  // two or three ideas side by side: one tap picks one and opens its full card
886
1101
  function drawChoiceCard(s) {
887
1102
  var box = document.getElementById('bet');
@@ -894,17 +1109,17 @@ body.has-card .foot { position: static; margin-top: 28px; }
894
1109
  b.appendChild(el('span', 't', o.title));
895
1110
  b.appendChild(el('span', 'w', o.why));
896
1111
  b.addEventListener('click', function () {
1112
+ if (bet.busy) return;
897
1113
  buzz();
898
- list.querySelectorAll('button').forEach(function (x) { x.disabled = true; });
1114
+ var heldAt = hold(b);
899
1115
  api('/bet/pick', { id: o.id }).then(function (r) {
900
- if (r.http === 200 && r.bet) { bet.busy = true; turn(r); return; }
901
- tapOrb(false);
902
- });
1116
+ if (r.http === 200 && r.bet) { afterHold(heldAt).then(function () { turn(r); }); return; }
1117
+ think(false, heldAt);
1118
+ }, function () { think(false, heldAt); });
903
1119
  });
904
1120
  list.appendChild(b);
905
1121
  });
906
1122
  box.appendChild(list);
907
- box.appendChild(el('div', 'links', 'or tap the orb for the next one'));
908
1123
  box.appendChild(sayBox());
909
1124
  }
910
1125
 
@@ -933,13 +1148,24 @@ body.has-card .foot { position: static; margin-top: 28px; }
933
1148
  return;
934
1149
  }
935
1150
  bet.armed = false;
936
- s.status = 'starting'; drawBetCard(s);
937
- api('/bet/do', { confirm: true }).then(function (r) {
1151
+ s.status = 'starting'; s.unstarted = null; drawBetCard(s);
1152
+ api('/bet/do', { confirm: true }).catch(offline).then(function (r) {
1153
+ if (!r || !r.ok) {
1154
+ // it did not get going: the card comes back as it was, do it and
1155
+ // all, with one line why, and the page follows this card again
1156
+ s.status = 'ready';
1157
+ s.unstarted = (r && r.message) || 'that did not start. tap do it to try again';
1158
+ bet.drawn = s.id;
1159
+ drawBetCard(s);
1160
+ refresh();
1161
+ return;
1162
+ }
938
1163
  loadBet(); refresh();
939
- if (r && r.ok) setTimeout(function () { tapOrb(false); }, AFTER_MS);
1164
+ setTimeout(function () { tapOrb(false); }, AFTER_MS);
940
1165
  });
941
1166
  });
942
1167
  act.appendChild(go);
1168
+ if (s.unstarted) act.appendChild(el('p', 'links unsent', s.unstarted));
943
1169
  var links = el('div', 'links');
944
1170
  var sharp = el('button', null, 'sharper idea');
945
1171
  sharp.type = 'button';
@@ -974,7 +1200,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
974
1200
  drawBetCard(s);
975
1201
  document.body.classList.add('has-card');
976
1202
  box.hidden = false;
977
- box.classList.remove('leave');
1203
+ box.classList.remove('leave', 'holding');
978
1204
  box.classList.toggle('quick', Boolean(fast));
979
1205
  if (!animate) { box.classList.add('up'); measured(); return; }
980
1206
  box.classList.remove('up');
@@ -1035,39 +1261,53 @@ body.has-card .foot { position: static; margin-top: 28px; }
1035
1261
  return Boolean((s.bet || s.job || s.question || s.choice || s.report) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting'));
1036
1262
  }
1037
1263
 
1038
- // Card to card: one calm turn in place. The small orb stays where it is.
1039
- // The box holds its height while the old card lifts and fades for
1040
- // TURN_OUT_MS, then the new card settles up from DRIFT below over
1041
- // TURN_IN_MS while the box eases to its new height. No layout jump.
1042
- var TURN_OUT_MS = 220, TURN_IN_MS = 360, DRIFT = 10;
1264
+ // Card to card: a crossfade in place. The small orb stays where it is.
1265
+ // A still copy of the old card fades over the box while the new card,
1266
+ // drawn under it, fades in from DRIFT below over TURN_IN_MS, starting
1267
+ // 80ms in, so its title lands where the old title was. The box eases to
1268
+ // its new height. No layout jump.
1269
+ var TURN_IN_MS = 320, DRIFT = 4;
1043
1270
  function turn(s) {
1271
+ crossfade(function () { bet.last = s; bet.drawn = s.id; drawBetCard(s); });
1272
+ }
1273
+
1274
+ // draw() puts the next card in the box
1275
+ function crossfade(draw) {
1044
1276
  var box = document.getElementById('bet');
1045
- if (reduced) { box.style.minHeight = ''; bet.last = s; bet.drawn = s.id; drawBetCard(s); bet.busy = false; measured(); return; }
1277
+ box.classList.remove('holding');
1278
+ if (reduced) { box.style.minHeight = ''; draw(); bet.busy = false; measured(); return; }
1046
1279
  var from = box.offsetHeight;
1047
1280
  box.style.height = from + 'px';
1048
1281
  box.style.minHeight = '';
1049
- box.classList.remove('quick', 'settle');
1050
- box.classList.add('turning', 'turn-out');
1282
+ var ghost = box.cloneNode(true);
1283
+ ghost.removeAttribute('id');
1284
+ ghost.classList.remove('quick', 'turning', 'turn-in');
1285
+ ghost.classList.add('up', 'ghost');
1286
+ ghost.setAttribute('aria-hidden', 'true');
1287
+ ghost.inert = true;
1288
+ var r = box.getBoundingClientRect(), p = box.offsetParent.getBoundingClientRect();
1289
+ ghost.style.top = (r.top - p.top) + 'px';
1290
+ ghost.style.left = (r.left - p.left) + 'px';
1291
+ ghost.style.width = r.width + 'px';
1292
+ box.after(ghost);
1293
+ box.classList.remove('quick', 'turning');
1294
+ draw();
1295
+ box.style.height = 'auto';
1296
+ var to = box.offsetHeight;
1297
+ box.style.height = from + 'px';
1298
+ box.classList.add('turn-in');
1299
+ void box.offsetHeight;
1300
+ box.classList.add('turning');
1301
+ box.classList.remove('turn-in');
1302
+ box.style.height = to + 'px';
1303
+ ghost.classList.add('gone');
1051
1304
  setTimeout(function () {
1052
- bet.last = s;
1053
- bet.drawn = s.id;
1054
- drawBetCard(s);
1055
- box.style.height = 'auto';
1056
- var to = box.offsetHeight;
1057
- box.style.height = from + 'px';
1058
- box.classList.remove('turning', 'turn-out');
1059
- box.classList.add('turn-in');
1060
- void box.offsetHeight;
1061
- box.classList.add('turning', 'settle');
1062
- box.classList.remove('turn-in');
1063
- box.style.height = to + 'px';
1064
- setTimeout(function () {
1065
- box.classList.remove('turning', 'settle');
1066
- box.style.height = '';
1067
- bet.busy = false;
1068
- measured();
1069
- }, TURN_IN_MS + 20);
1070
- }, TURN_OUT_MS);
1305
+ ghost.remove();
1306
+ box.classList.remove('turning');
1307
+ box.style.height = '';
1308
+ bet.busy = false;
1309
+ measured();
1310
+ }, 80 + TURN_IN_MS + 20);
1071
1311
  }
1072
1312
 
1073
1313
  // one tap: buzz at once and ask the server for the next card. From the
@@ -1075,18 +1315,32 @@ body.has-card .foot { position: static; margin-top: 28px; }
1075
1315
  function tapOrb(sharper) {
1076
1316
  if (bet.busy) return;
1077
1317
  buzz();
1318
+ think(sharper, 0);
1319
+ }
1320
+
1321
+ // Ask for the next card. heldAt: an answer has held as chosen since then,
1322
+ // so the card is asked for during the hold and turns in once it is over,
1323
+ // and the time to the card counts from that answer.
1324
+ function think(sharper, heldAt) {
1078
1325
  bet.busy = true;
1079
1326
  bet.armed = false;
1080
- bet.tapAt = performance.now();
1327
+ bet.tapAt = heldAt || performance.now();
1081
1328
  document.body.removeAttribute('data-reveal-ms');
1082
1329
  var onCard = document.body.classList.contains('has-card');
1083
- api('/bet/think', { sharper: Boolean(sharper) }).then(function (s) {
1084
- if (s.http !== 200) { bet.busy = false; orb.relax(); line.set('that did not go through. tap to try again', true); return; }
1330
+ var failed = function () {
1331
+ bet.busy = false;
1332
+ document.getElementById('bet').classList.remove('holding');
1333
+ orb.relax();
1334
+ line.set('that did not go through. tap to try again', true);
1335
+ };
1336
+ Promise.all([api('/bet/think', { sharper: Boolean(sharper) }), afterHold(heldAt)]).then(function (both) {
1337
+ var s = both[0];
1338
+ if (s.http !== 200) { failed(); return; }
1085
1339
  bet.status = s.status;
1086
1340
  if (onCard && isCard(s)) { turn(s); return; }
1087
1341
  if (onCard) { dissolve(function () { reveal(s, true); }); return; }
1088
1342
  reveal(s, true);
1089
- });
1343
+ }, failed);
1090
1344
  }
1091
1345
 
1092
1346
  function loadBet() {
@@ -1117,12 +1371,32 @@ body.has-card .foot { position: static; margin-top: 28px; }
1117
1371
  });
1118
1372
  }
1119
1373
 
1374
+ // The small orb on a card goes back to the big dotted orb, the same calm
1375
+ // way the page goes home before a new idea: the card fades, the small orb
1376
+ // glides down and the dots form again, idle under the pointer. The card
1377
+ // stays where it was on the server, so nothing is passed yet.
1378
+ function goHome() {
1379
+ if (bet.busy) return;
1380
+ buzz();
1381
+ bet.busy = true;
1382
+ bet.armed = false;
1383
+ dissolve(function () { bet.busy = false; line.set('tap to think', false); });
1384
+ }
1385
+
1386
+ // The big orb: the next idea, with the spiral, as on page open. An idea
1387
+ // that landed behind the calm line is the next one, so it is never passed.
1388
+ function tapBig() { tapOrb(false); }
1389
+
1390
+ function tapSmallOrb() {
1391
+ if (document.body.classList.contains('has-card')) goHome(); else tapBig();
1392
+ }
1393
+
1120
1394
  var canvas = document.getElementById('orb');
1121
1395
  var lavaTap = document.getElementById('lava');
1122
- [canvas, lavaTap].forEach(function (node) {
1123
- node.addEventListener('click', function () { tapOrb(false); });
1124
- node.addEventListener('keydown', function (e) {
1125
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tapOrb(false); }
1396
+ [[canvas, tapBig], [lavaTap, tapSmallOrb]].forEach(function (pair) {
1397
+ pair[0].addEventListener('click', pair[1]);
1398
+ pair[0].addEventListener('keydown', function (e) {
1399
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pair[1](); }
1126
1400
  });
1127
1401
  });
1128
1402
 
@@ -1130,14 +1404,19 @@ body.has-card .foot { position: static; margin-top: 28px; }
1130
1404
 
1131
1405
  var teamBox = document.getElementById('team');
1132
1406
  var teamInner = document.getElementById('team-inner');
1407
+ // the way back from what is on top: the team closes, a talk card goes back to the team
1408
+ var teamBack = null;
1409
+
1410
+ function closeTeam() { teamBox.hidden = true; }
1133
1411
 
1134
1412
  function teamHead(title) {
1135
1413
  teamInner.textContent = '';
1414
+ teamBack = closeTeam;
1136
1415
  var head = el('div', 'head');
1137
1416
  head.appendChild(el('h1', null, title));
1138
1417
  var close = el('button', 'quiet', 'close');
1139
1418
  close.type = 'button';
1140
- close.addEventListener('click', function () { teamBox.hidden = true; });
1419
+ close.addEventListener('click', closeTeam);
1141
1420
  head.appendChild(close);
1142
1421
  teamInner.appendChild(head);
1143
1422
  }
@@ -1166,6 +1445,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
1166
1445
  // a talk card: what the member is on, and a word to steer them
1167
1446
  function drawTalk(r) {
1168
1447
  teamHead(r.member);
1448
+ teamBack = openTeam;
1169
1449
  teamInner.appendChild(el('p', 'why', r.status + ': ' + r.on + '. ' + r.time + '.'));
1170
1450
  var talkBox = convo(function (body) { body.member = r.member; return api('/bet/steer', body); }, function (ack) {
1171
1451
  talkBox.replaceWith(el('p', 'mission', ack));
@@ -1179,6 +1459,25 @@ body.has-card .foot { position: static; margin-top: 28px; }
1179
1459
 
1180
1460
  document.getElementById('working').addEventListener('click', openTeam);
1181
1461
 
1462
+ // Esc on the team page steps back one layer, the way its own buttons do:
1463
+ // words being typed are cancelled and the cursor leaves first, then the
1464
+ // talk card goes back to the team, then the team closes to the card.
1465
+ // On the card itself it does nothing. It listens on the way down, so the
1466
+ // focused element is still the field the key was pressed in.
1467
+ document.addEventListener('keydown', function (e) {
1468
+ if (e.key !== 'Escape' || e.isComposing || teamBox.hidden) return;
1469
+ e.preventDefault();
1470
+ var field = document.activeElement;
1471
+ if (field && (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA')) {
1472
+ field.value = '';
1473
+ // the field's own input listener drops a second tap armed for those words
1474
+ field.dispatchEvent(new Event('input'));
1475
+ field.blur();
1476
+ return;
1477
+ }
1478
+ teamBack();
1479
+ }, true);
1480
+
1182
1481
  // the footer: how many members are really working, or the team when none are
1183
1482
  function drawWorking(n) {
1184
1483
  var btn = document.getElementById('working');