atris 3.73.1 → 3.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,207 @@ 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
+ if (c.other) { typeHere(b, c.label, 60, function (text, field) { answer(field, { choice: 'other', text: text }); }); return; }
756
+ answer(b, { choice: c.id });
715
757
  });
716
758
  act.appendChild(b);
717
759
  });
718
- act.appendChild(el('div', 'links', 'or tap the orb for the next one'));
760
+ // a quiet line under the answers, empty until an answer does not go through
761
+ act.appendChild(el('div', 'links'));
719
762
  box.appendChild(act);
720
763
  box.appendChild(sayBox());
721
764
  }
722
765
 
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');
766
+ // ---- answering in place: the eyes stay in one spot ----------------------------
767
+ // A tapped answer, or the words typed in its place, holds that spot as
768
+ // chosen for HOLD_MS while the rest of the card dims and stays put. The
769
+ // next card is asked for during the hold and turns in once it is over.
770
+ var HOLD_MS = 400;
771
+
772
+ // a failed fetch reads like a refusal, so a hold never hangs
773
+ function offline() { return { ok: false, http: 0 }; }
774
+
775
+ // every answer row of the card dims but the one in use; no spot, none
776
+ function dimOthers(spot) {
777
+ var box = document.getElementById('bet');
778
+ box.querySelectorAll('.act > *, .options > *, .sayline, :scope > .links').forEach(function (n) {
779
+ n.classList.toggle('muted', Boolean(spot) && n !== spot && !n.contains(spot));
780
+ });
781
+ }
782
+
783
+ // the spot holds as chosen and the card takes no taps; returns when it
784
+ // started, which is also where the time to the next card counts from
785
+ function hold(spot) {
786
+ var box = document.getElementById('bet');
787
+ dimOthers(spot);
788
+ spot.classList.add('chosen');
789
+ box.classList.add('holding');
790
+ var input = spot.querySelector('input');
791
+ if (input) { input.readOnly = true; input.blur(); }
792
+ bet.busy = true;
793
+ bet.armed = false;
794
+ bet.tapAt = performance.now();
795
+ document.body.removeAttribute('data-reveal-ms');
796
+ return bet.tapAt;
797
+ }
798
+
799
+ // it did not go through: the card is as it was
800
+ function unhold() {
801
+ var box = document.getElementById('bet');
802
+ box.classList.remove('holding');
803
+ dimOthers(null);
804
+ box.querySelectorAll('.chosen').forEach(function (n) { n.classList.remove('chosen'); });
805
+ bet.busy = false;
806
+ }
807
+
808
+ // resolves once the hold that started at heldAt has run HOLD_MS
809
+ function afterHold(heldAt) {
810
+ var left = heldAt ? heldAt + HOLD_MS - performance.now() : 0;
811
+ return new Promise(function (resolve) { setTimeout(resolve, Math.max(0, left)); });
812
+ }
813
+
814
+ // The tapped button becomes the field at its own size and spot, with the
815
+ // cursor already in it, and the other answers dim. Enter or send hands
816
+ // the words to sent(text, field). Left empty, it turns back into the button.
817
+ function typeHere(btn, placeholder, maxLength, sent) {
818
+ var field = el('div', btn.classList.contains('choice') ? 'choice field' : 'field');
819
+ // its exact height, to the subpixel, so nothing under it moves
820
+ field.style.height = btn.getBoundingClientRect().height + 'px';
821
+ var input = el('input');
726
822
  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 () {
823
+ input.maxLength = maxLength;
824
+ input.placeholder = placeholder;
825
+ input.enterKeyHint = 'send';
826
+ input.setAttribute('aria-label', placeholder);
827
+ var send = el('button', 'send', 'send');
828
+ send.type = 'button';
829
+ var submit = function () {
732
830
  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
- });
831
+ if (text && !bet.busy) sent(text, field);
741
832
  };
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);
833
+ // a press on send keeps the cursor in the field, so it never closes first
834
+ send.addEventListener('mousedown', function (e) { e.preventDefault(); });
835
+ send.addEventListener('click', function () { buzz(); submit(); });
836
+ input.addEventListener('keydown', function (e) {
837
+ if (e.key === 'Enter') { e.preventDefault(); submit(); }
838
+ if (e.key === 'Escape') { input.value = ''; input.blur(); }
839
+ });
840
+ input.addEventListener('blur', function () {
841
+ if (input.value.trim() || field.classList.contains('chosen') || !field.isConnected) return;
842
+ field.replaceWith(btn);
843
+ dimOthers(null);
844
+ });
845
+ field.appendChild(input);
846
+ field.appendChild(send);
847
+ btn.replaceWith(field);
848
+ dimOthers(field);
747
849
  input.focus();
748
850
  }
749
851
 
852
+ // An answer to a question holds as chosen while it saves. If it does not
853
+ // go through, the card comes back as it was, the words still in the
854
+ // field, and why takes the hint's line under the answers.
855
+ function answer(spot, body) {
856
+ body.card = shownCard();
857
+ var heldAt = hold(spot);
858
+ api('/bet/answer', body).catch(offline).then(function (r) {
859
+ if (r.ok) { think(false, heldAt); return; }
860
+ unhold();
861
+ var hint = document.getElementById('bet').querySelector('.act .links');
862
+ if (hint) hint.textContent = r.message || 'that did not go through';
863
+ var input = spot.querySelector('input');
864
+ if (input) { input.readOnly = false; dimOthers(spot); input.focus(); }
865
+ });
866
+ }
867
+
868
+ // the card the owner is looking at, named with every answer and every
869
+ // word said, so the server acts on that card or on nothing
870
+ function shownCard() { return bet.last && bet.last.id ? bet.last.id : ''; }
871
+
872
+ // Words said on a card hold their spot as chosen while the server reads
873
+ // them. An answer or an instruction brings the next card; a question
874
+ // back or a second tap it needs turns in as the orb's reply, in the
875
+ // title's spot. If it did not send, the words stay where they were.
876
+ // One id per typed send: made when these words are first sent from this
877
+ // spot, the same on every retry of them, and new when the words change,
878
+ // so a send whose reply was lost is never sent twice. getRandomValues
879
+ // works on the plain http of the wifi link, where randomUUID does not.
880
+ function sendId(spot, text) {
881
+ if (spot.sendText !== text) {
882
+ var n = new Uint32Array(2);
883
+ crypto.getRandomValues(n);
884
+ spot.sendText = text;
885
+ spot.sendId = ('000000' + n[0].toString(36)).slice(-7) + ('000000' + n[1].toString(36)).slice(-7);
886
+ }
887
+ return spot.sendId;
888
+ }
889
+
890
+ function say(spot, body) {
891
+ body.card = shownCard();
892
+ body.send = sendId(spot, body.text);
893
+ var heldAt = hold(spot);
894
+ api('/bet/say', body).catch(offline).then(function (r) {
895
+ if (r.ok && !r.stay) { think(false, heldAt); return; }
896
+ if (!r.ok && r.reason !== 'needs_confirm') { unsent(spot, r.message || 'that did not send. tap send to try again'); return; }
897
+ afterHold(heldAt).then(function () { crossfade(function () { drawReply(r, body.text); }); });
898
+ });
899
+ }
900
+
901
+ // It did not send: the card is as it was, the words still in the box with
902
+ // the cursor in it, and one short line under it says why, so Enter (or
903
+ // the same tap) tries again. Left empty, the box turns back into the
904
+ // button and the line goes with it.
905
+ function unsent(spot, why) {
906
+ unhold();
907
+ var note = spot.nextElementSibling;
908
+ var input = spot.querySelector('input');
909
+ if (!note || !note.classList.contains('unsent')) {
910
+ note = el('p', 'links unsent');
911
+ spot.after(note);
912
+ if (input) input.addEventListener('blur', function () { if (!spot.isConnected) note.remove(); });
913
+ }
914
+ note.textContent = why;
915
+ if (input) { input.readOnly = false; dimOthers(spot); input.focus(); }
916
+ }
917
+
918
+ // one reply to tap: it holds as chosen and is said
919
+ function sayChoice(label, body) {
920
+ var b = el('button', 'choice', label);
921
+ b.type = 'button';
922
+ b.addEventListener('click', function () {
923
+ if (bet.busy) return;
924
+ buzz();
925
+ say(b, body);
926
+ });
927
+ return b;
928
+ }
929
+
930
+ // the orb's reply is the title, with the card's own answer right under it
931
+ // (do it, yes, nice, or the answers), then up to three suggested replies;
932
+ // starting a member who is not running takes one more tap, or it is kept
933
+ // as a note. The say line takes anything else.
934
+ function drawReply(r, said) {
935
+ var box = document.getElementById('bet');
936
+ // a reply never takes the card's own answer away: the card is drawn
937
+ // once more, and its row is kept, buttons and all
938
+ var own = null;
939
+ if (bet.last) { drawBetCard(bet.last); own = box.querySelector(':scope > .act, :scope > .options'); }
940
+ box.textContent = '';
941
+ bet.drawn = null;
942
+ box.appendChild(el('h1', null, r.stay ? r.reply : (r.message || 'that did not go through')));
943
+ if (own) box.appendChild(own);
944
+ var act = el('div', 'act answers');
945
+ if (r.stay) (r.suggestions || []).forEach(function (sug) { act.appendChild(sayChoice(sug, { text: sug })); });
946
+ if (r.reason === 'needs_confirm') {
947
+ act.appendChild(sayChoice('start it', { text: said, confirm: true }));
948
+ act.appendChild(sayChoice('keep it as a note instead', { text: said, note: true }));
949
+ }
950
+ box.appendChild(act);
951
+ box.appendChild(sayBox());
952
+ }
953
+
750
954
  // the owner's own number, once a week: one box and one tap to save
751
955
  function drawReportCard(s) {
752
956
  var box = document.getElementById('bet');
@@ -774,7 +978,6 @@ body.has-card .foot { position: static; margin-top: 28px; }
774
978
  input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } });
775
979
  act.appendChild(input);
776
980
  act.appendChild(save);
777
- act.appendChild(el('div', 'links', 'or tap the orb for the next one'));
778
981
  box.appendChild(act);
779
982
  }
780
983
 
@@ -817,9 +1020,10 @@ body.has-card .foot { position: static; margin-top: 28px; }
817
1020
  reply.hidden = false;
818
1021
  reply.textContent = '\u2026';
819
1022
  extra.textContent = '';
820
- var body = { text: text };
1023
+ // the same words carry the same id on every try, so a retry never sends twice
1024
+ var body = { text: text, send: sendId(wrap, text) };
821
1025
  if (more) Object.keys(more).forEach(function (k) { body[k] = more[k]; });
822
- post(body).then(function (r) {
1026
+ post(body).catch(offline).then(function (r) {
823
1027
  if (r.ok && !r.stay) { done(r.ack); return; }
824
1028
  send.disabled = false;
825
1029
  if (r.stay) { reply.textContent = r.reply;
@@ -844,7 +1048,11 @@ body.has-card .foot { position: static; margin-top: 28px; }
844
1048
  extra.appendChild(keep);
845
1049
  return;
846
1050
  }
847
- reply.textContent = r.message || 'that did not go through';
1051
+ // it did not send: the words are in the box, send works again, and
1052
+ // one short line says so
1053
+ if (said && !input.value) input.value = said;
1054
+ reply.textContent = r.message || 'that did not send. tap send to try again';
1055
+ input.focus();
848
1056
  });
849
1057
  }
850
1058
  send.addEventListener('click', function () {
@@ -862,26 +1070,22 @@ body.has-card .foot { position: static; margin-top: 28px; }
862
1070
  return wrap;
863
1071
  }
864
1072
 
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
1073
+ // a quiet line under every card: tap it and the line becomes the box, in
1074
+ // its own spot. on a question it is the answer, on an idea a note to
1075
+ // rework it, on a yes or a finished job a word to that member; the server
1076
+ // reads it first, and a question back is answered
868
1077
  function sayBox() {
869
1078
  var wrap = el('div', 'links sayline');
870
1079
  var open = el('button', 'quiet', 'or say something');
871
1080
  open.type = 'button';
872
1081
  open.addEventListener('click', function () {
873
- var box = sayConvo();
874
- wrap.replaceWith(box);
875
- box.querySelector('input').focus();
1082
+ if (bet.busy) return;
1083
+ typeHere(open, 'say something', 280, function (text, field) { say(field, { text: text }); });
876
1084
  });
877
1085
  wrap.appendChild(open);
878
1086
  return wrap;
879
1087
  }
880
1088
 
881
- function sayConvo() {
882
- return convo(function (body) { return api('/bet/say', body); }, moveOn, 'say something');
883
- }
884
-
885
1089
  // two or three ideas side by side: one tap picks one and opens its full card
886
1090
  function drawChoiceCard(s) {
887
1091
  var box = document.getElementById('bet');
@@ -894,17 +1098,17 @@ body.has-card .foot { position: static; margin-top: 28px; }
894
1098
  b.appendChild(el('span', 't', o.title));
895
1099
  b.appendChild(el('span', 'w', o.why));
896
1100
  b.addEventListener('click', function () {
1101
+ if (bet.busy) return;
897
1102
  buzz();
898
- list.querySelectorAll('button').forEach(function (x) { x.disabled = true; });
1103
+ var heldAt = hold(b);
899
1104
  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
- });
1105
+ if (r.http === 200 && r.bet) { afterHold(heldAt).then(function () { turn(r); }); return; }
1106
+ think(false, heldAt);
1107
+ }, function () { think(false, heldAt); });
903
1108
  });
904
1109
  list.appendChild(b);
905
1110
  });
906
1111
  box.appendChild(list);
907
- box.appendChild(el('div', 'links', 'or tap the orb for the next one'));
908
1112
  box.appendChild(sayBox());
909
1113
  }
910
1114
 
@@ -933,13 +1137,24 @@ body.has-card .foot { position: static; margin-top: 28px; }
933
1137
  return;
934
1138
  }
935
1139
  bet.armed = false;
936
- s.status = 'starting'; drawBetCard(s);
937
- api('/bet/do', { confirm: true }).then(function (r) {
1140
+ s.status = 'starting'; s.unstarted = null; drawBetCard(s);
1141
+ api('/bet/do', { confirm: true }).catch(offline).then(function (r) {
1142
+ if (!r || !r.ok) {
1143
+ // it did not get going: the card comes back as it was, do it and
1144
+ // all, with one line why, and the page follows this card again
1145
+ s.status = 'ready';
1146
+ s.unstarted = (r && r.message) || 'that did not start. tap do it to try again';
1147
+ bet.drawn = s.id;
1148
+ drawBetCard(s);
1149
+ refresh();
1150
+ return;
1151
+ }
938
1152
  loadBet(); refresh();
939
- if (r && r.ok) setTimeout(function () { tapOrb(false); }, AFTER_MS);
1153
+ setTimeout(function () { tapOrb(false); }, AFTER_MS);
940
1154
  });
941
1155
  });
942
1156
  act.appendChild(go);
1157
+ if (s.unstarted) act.appendChild(el('p', 'links unsent', s.unstarted));
943
1158
  var links = el('div', 'links');
944
1159
  var sharp = el('button', null, 'sharper idea');
945
1160
  sharp.type = 'button';
@@ -974,7 +1189,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
974
1189
  drawBetCard(s);
975
1190
  document.body.classList.add('has-card');
976
1191
  box.hidden = false;
977
- box.classList.remove('leave');
1192
+ box.classList.remove('leave', 'holding');
978
1193
  box.classList.toggle('quick', Boolean(fast));
979
1194
  if (!animate) { box.classList.add('up'); measured(); return; }
980
1195
  box.classList.remove('up');
@@ -1035,39 +1250,53 @@ body.has-card .foot { position: static; margin-top: 28px; }
1035
1250
  return Boolean((s.bet || s.job || s.question || s.choice || s.report) && (s.status === 'ready' || s.status === 'started' || s.status === 'starting'));
1036
1251
  }
1037
1252
 
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;
1253
+ // Card to card: a crossfade in place. The small orb stays where it is.
1254
+ // A still copy of the old card fades over the box while the new card,
1255
+ // drawn under it, fades in from DRIFT below over TURN_IN_MS, starting
1256
+ // 80ms in, so its title lands where the old title was. The box eases to
1257
+ // its new height. No layout jump.
1258
+ var TURN_IN_MS = 320, DRIFT = 4;
1043
1259
  function turn(s) {
1260
+ crossfade(function () { bet.last = s; bet.drawn = s.id; drawBetCard(s); });
1261
+ }
1262
+
1263
+ // draw() puts the next card in the box
1264
+ function crossfade(draw) {
1044
1265
  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; }
1266
+ box.classList.remove('holding');
1267
+ if (reduced) { box.style.minHeight = ''; draw(); bet.busy = false; measured(); return; }
1046
1268
  var from = box.offsetHeight;
1047
1269
  box.style.height = from + 'px';
1048
1270
  box.style.minHeight = '';
1049
- box.classList.remove('quick', 'settle');
1050
- box.classList.add('turning', 'turn-out');
1271
+ var ghost = box.cloneNode(true);
1272
+ ghost.removeAttribute('id');
1273
+ ghost.classList.remove('quick', 'turning', 'turn-in');
1274
+ ghost.classList.add('up', 'ghost');
1275
+ ghost.setAttribute('aria-hidden', 'true');
1276
+ ghost.inert = true;
1277
+ var r = box.getBoundingClientRect(), p = box.offsetParent.getBoundingClientRect();
1278
+ ghost.style.top = (r.top - p.top) + 'px';
1279
+ ghost.style.left = (r.left - p.left) + 'px';
1280
+ ghost.style.width = r.width + 'px';
1281
+ box.after(ghost);
1282
+ box.classList.remove('quick', 'turning');
1283
+ draw();
1284
+ box.style.height = 'auto';
1285
+ var to = box.offsetHeight;
1286
+ box.style.height = from + 'px';
1287
+ box.classList.add('turn-in');
1288
+ void box.offsetHeight;
1289
+ box.classList.add('turning');
1290
+ box.classList.remove('turn-in');
1291
+ box.style.height = to + 'px';
1292
+ ghost.classList.add('gone');
1051
1293
  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);
1294
+ ghost.remove();
1295
+ box.classList.remove('turning');
1296
+ box.style.height = '';
1297
+ bet.busy = false;
1298
+ measured();
1299
+ }, 80 + TURN_IN_MS + 20);
1071
1300
  }
1072
1301
 
1073
1302
  // one tap: buzz at once and ask the server for the next card. From the
@@ -1075,18 +1304,32 @@ body.has-card .foot { position: static; margin-top: 28px; }
1075
1304
  function tapOrb(sharper) {
1076
1305
  if (bet.busy) return;
1077
1306
  buzz();
1307
+ think(sharper, 0);
1308
+ }
1309
+
1310
+ // Ask for the next card. heldAt: an answer has held as chosen since then,
1311
+ // so the card is asked for during the hold and turns in once it is over,
1312
+ // and the time to the card counts from that answer.
1313
+ function think(sharper, heldAt) {
1078
1314
  bet.busy = true;
1079
1315
  bet.armed = false;
1080
- bet.tapAt = performance.now();
1316
+ bet.tapAt = heldAt || performance.now();
1081
1317
  document.body.removeAttribute('data-reveal-ms');
1082
1318
  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; }
1319
+ var failed = function () {
1320
+ bet.busy = false;
1321
+ document.getElementById('bet').classList.remove('holding');
1322
+ orb.relax();
1323
+ line.set('that did not go through. tap to try again', true);
1324
+ };
1325
+ Promise.all([api('/bet/think', { sharper: Boolean(sharper) }), afterHold(heldAt)]).then(function (both) {
1326
+ var s = both[0];
1327
+ if (s.http !== 200) { failed(); return; }
1085
1328
  bet.status = s.status;
1086
1329
  if (onCard && isCard(s)) { turn(s); return; }
1087
1330
  if (onCard) { dissolve(function () { reveal(s, true); }); return; }
1088
1331
  reveal(s, true);
1089
- });
1332
+ }, failed);
1090
1333
  }
1091
1334
 
1092
1335
  function loadBet() {
@@ -1117,12 +1360,32 @@ body.has-card .foot { position: static; margin-top: 28px; }
1117
1360
  });
1118
1361
  }
1119
1362
 
1363
+ // The small orb on a card goes back to the big dotted orb, the same calm
1364
+ // way the page goes home before a new idea: the card fades, the small orb
1365
+ // glides down and the dots form again, idle under the pointer. The card
1366
+ // stays where it was on the server, so nothing is passed yet.
1367
+ function goHome() {
1368
+ if (bet.busy) return;
1369
+ buzz();
1370
+ bet.busy = true;
1371
+ bet.armed = false;
1372
+ dissolve(function () { bet.busy = false; line.set('tap to think', false); });
1373
+ }
1374
+
1375
+ // The big orb: the next idea, with the spiral, as on page open. An idea
1376
+ // that landed behind the calm line is the next one, so it is never passed.
1377
+ function tapBig() { tapOrb(false); }
1378
+
1379
+ function tapSmallOrb() {
1380
+ if (document.body.classList.contains('has-card')) goHome(); else tapBig();
1381
+ }
1382
+
1120
1383
  var canvas = document.getElementById('orb');
1121
1384
  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); }
1385
+ [[canvas, tapBig], [lavaTap, tapSmallOrb]].forEach(function (pair) {
1386
+ pair[0].addEventListener('click', pair[1]);
1387
+ pair[0].addEventListener('keydown', function (e) {
1388
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pair[1](); }
1126
1389
  });
1127
1390
  });
1128
1391
 
@@ -1130,14 +1393,19 @@ body.has-card .foot { position: static; margin-top: 28px; }
1130
1393
 
1131
1394
  var teamBox = document.getElementById('team');
1132
1395
  var teamInner = document.getElementById('team-inner');
1396
+ // the way back from what is on top: the team closes, a talk card goes back to the team
1397
+ var teamBack = null;
1398
+
1399
+ function closeTeam() { teamBox.hidden = true; }
1133
1400
 
1134
1401
  function teamHead(title) {
1135
1402
  teamInner.textContent = '';
1403
+ teamBack = closeTeam;
1136
1404
  var head = el('div', 'head');
1137
1405
  head.appendChild(el('h1', null, title));
1138
1406
  var close = el('button', 'quiet', 'close');
1139
1407
  close.type = 'button';
1140
- close.addEventListener('click', function () { teamBox.hidden = true; });
1408
+ close.addEventListener('click', closeTeam);
1141
1409
  head.appendChild(close);
1142
1410
  teamInner.appendChild(head);
1143
1411
  }
@@ -1166,6 +1434,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
1166
1434
  // a talk card: what the member is on, and a word to steer them
1167
1435
  function drawTalk(r) {
1168
1436
  teamHead(r.member);
1437
+ teamBack = openTeam;
1169
1438
  teamInner.appendChild(el('p', 'why', r.status + ': ' + r.on + '. ' + r.time + '.'));
1170
1439
  var talkBox = convo(function (body) { body.member = r.member; return api('/bet/steer', body); }, function (ack) {
1171
1440
  talkBox.replaceWith(el('p', 'mission', ack));
@@ -1179,6 +1448,25 @@ body.has-card .foot { position: static; margin-top: 28px; }
1179
1448
 
1180
1449
  document.getElementById('working').addEventListener('click', openTeam);
1181
1450
 
1451
+ // Esc on the team page steps back one layer, the way its own buttons do:
1452
+ // words being typed are cancelled and the cursor leaves first, then the
1453
+ // talk card goes back to the team, then the team closes to the card.
1454
+ // On the card itself it does nothing. It listens on the way down, so the
1455
+ // focused element is still the field the key was pressed in.
1456
+ document.addEventListener('keydown', function (e) {
1457
+ if (e.key !== 'Escape' || e.isComposing || teamBox.hidden) return;
1458
+ e.preventDefault();
1459
+ var field = document.activeElement;
1460
+ if (field && (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA')) {
1461
+ field.value = '';
1462
+ // the field's own input listener drops a second tap armed for those words
1463
+ field.dispatchEvent(new Event('input'));
1464
+ field.blur();
1465
+ return;
1466
+ }
1467
+ teamBack();
1468
+ }, true);
1469
+
1182
1470
  // the footer: how many members are really working, or the team when none are
1183
1471
  function drawWorking(n) {
1184
1472
  var btn = document.getElementById('working');