atris 3.74.8 → 3.74.10

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.
@@ -23,7 +23,13 @@
23
23
  // Every card is big type in two parts: what is going on, and why it matters
24
24
  // to the business. Nothing about how work is run or checked reaches the page.
25
25
 
26
- function phonePage(nonce) {
26
+ // first: what GET /card answers right now, put in the page so the first
27
+ // paint is the card on screen (no cut from the big orb a round trip later)
28
+ const firstJson = first => (first ? JSON.stringify(first).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029') : '');
29
+
30
+ const { iconSvg, touchIconPng } = require('./gm-phone-icon');
31
+
32
+ function phonePage(nonce, first = null) {
27
33
  return `<!doctype html>
28
34
  <html lang="en">
29
35
  <head>
@@ -31,6 +37,15 @@ function phonePage(nonce) {
31
37
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
32
38
  <meta name="referrer" content="no-referrer">
33
39
  <meta name="theme-color" content="#141110">
40
+ <meta name="color-scheme" content="dark">
41
+ <!-- added to the home screen it opens on its own, with no browser bars, the dark page under the status bar -->
42
+ <meta name="apple-mobile-web-app-capable" content="yes">
43
+ <meta name="mobile-web-app-capable" content="yes">
44
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
45
+ <meta name="apple-mobile-web-app-title" content="orb">
46
+ <meta name="application-name" content="orb">
47
+ <link rel="icon" type="image/svg+xml" href="${iconSvg()}">
48
+ <link rel="apple-touch-icon" sizes="180x180" href="${touchIconPng()}">
34
49
  <title>front office</title>
35
50
  <style nonce="${nonce}">
36
51
  :root {
@@ -39,13 +54,19 @@ function phonePage(nonce) {
39
54
  --text-2: #a39b92;
40
55
  --text-3: #9a9187;
41
56
  --line: #3d332d;
57
+ /* the edge of a box you type in: 3.2:1 on the page, past WCAG AA's 3:1 for a text box */
58
+ --edge: #6e635b;
42
59
  --amber: rgb(255, 140, 66);
43
60
  --amber-ink: #1a0f06;
44
61
  --serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;
62
+ /* the phone's top inset (a notch, the status bar in full screen): the small orb rests below it, the same gap above the title as on a phone without one */
63
+ --safe-top: env(safe-area-inset-top, 0px);
45
64
  color-scheme: dark;
46
65
  }
47
66
  * { box-sizing: border-box; }
48
67
  html, body { margin: 0; background: var(--bg); color: var(--text); }
68
+ /* a phone turned on its side keeps the page's type sizes; Safari would scale the words up */
69
+ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
49
70
  body {
50
71
  font: 17px/1.5 var(--serif);
51
72
  min-height: 100vh;
@@ -53,18 +74,39 @@ body {
53
74
  -webkit-font-smoothing: antialiased;
54
75
  }
55
76
  button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
56
- :focus-visible { outline: 1px solid var(--amber); outline-offset: 4px; }
77
+ /* a tap answers with the page's own chosen and armed looks, not the system's
78
+ flash box; a double tap never zooms (a pinch still does); a long press on
79
+ a button or the orb never selects its words or opens a menu. The words of
80
+ a card stay selectable. */
81
+ html { -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
82
+ /* pulling down at the top never reloads the page mid card (it looks for new cards itself); the edge still gives */
83
+ html { overscroll-behavior-y: contain; }
84
+ /* the big orb's keyboard ring hugs the sphere, 8% inside the square the dots are drawn in */
85
+ .stage canvas:focus-visible { outline: none; }
86
+ .stage:has(canvas:focus-visible)::after { content: ''; position: absolute; inset: 8%; border-radius: 50%; outline: 2px solid var(--amber); outline-offset: 8px; pointer-events: none; }
87
+ button, .lava, .stage canvas { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
88
+ /* a button answers the moment a finger is down on it, and comes back when it lifts */
89
+ button:active:not(:disabled):not(.muted) { opacity: 0.7; }
90
+ :focus-visible { outline: 2px solid var(--amber); outline-offset: 4px; }
91
+ /* a link's ring hugs its own tall tap box, round, so it never touches the button above */
92
+ .links button:focus-visible, .quiet:focus-visible, .bet td button:focus-visible { outline-offset: 0; border-radius: 12px; }
57
93
  [hidden] { display: none !important; }
94
+ /* dark until the page's script has run: a slow link never shows the words without the dots or the card */
95
+ body:not(.on) .screen, body:not(.on) nav { visibility: hidden; }
96
+ /* read by a screen reader, never seen */
97
+ .sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
58
98
 
59
99
  /* ---- the screen: the orb in the middle, one line under it ---- */
60
100
  .screen {
61
101
  position: relative;
62
102
  min-height: 100vh; min-height: 100svh;
63
103
  max-width: 34rem; margin: 0 auto;
64
- padding: max(24px, env(safe-area-inset-top)) 24px max(24px, env(safe-area-inset-bottom));
104
+ padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
65
105
  display: flex; flex-direction: column; align-items: center; justify-content: center;
66
106
  }
67
107
  .stage { position: relative; width: min(72vmin, 520px); aspect-ratio: 1 / 1; }
108
+ /* on a short screen (a phone on its side) the orb leaves room for its line and the footer */
109
+ .stage { width: min(72vmin, 520px, 100svh - 200px); }
68
110
  .stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; touch-action: none; cursor: pointer; }
69
111
  .stage canvas:focus-visible { outline-offset: -8%; }
70
112
  .line {
@@ -78,34 +120,57 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
78
120
  /* while a card moves the footer steps aside, so it never jumps between the
79
121
  bottom of the screen and the end of a card; it comes back at rest */
80
122
  body[data-busy]:not([data-in-place]) .foot { opacity: 0; transition: opacity 0.15s ease-in; }
123
+ /* until the first look says what is up, the line and the footer wait, and
124
+ the orb is not drawn, so a card never opens over the big orb */
125
+ body.opening .line, body.opening .foot { opacity: 0; }
81
126
  /* the yeses still waiting: a small line beside the team, never a card on its own */
82
127
  #needs { margin-right: 20px; color: var(--amber); }
128
+ /* the footer never offers the page already open: no team link on the team, no need-you link on the job deck */
129
+ .bet[data-kind="team"]:not([hidden]) ~ .foot #working, .bet[data-kind="deck"]:not([hidden]) ~ .foot #needs { display: none; }
83
130
  .quiet { color: var(--text-2); font-size: 17px; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; min-height: 44px; }
84
131
  /* a card with a table (the team, one job) is wider, and at phone width the
85
132
  table scrolls sideways with the name column held in place; a line between
86
133
  every column and every row, and no column narrower than its words */
87
134
  body.wide .screen { max-width: 64rem; }
88
135
  .grid { margin: 14px 0 0; overflow-x: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; border: 1px solid var(--line); border-radius: 14px; }
136
+ /* more of the table is off to the right: its right edge fades out, still, until it is scrolled there */
137
+ .grid.more-right { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent); }
89
138
  .bet table { width: 100%; min-width: 44rem; border-collapse: separate; border-spacing: 0; font-size: 17px; line-height: 1.35; }
90
139
  .bet table.k-deck { min-width: 56rem; }
91
- .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); }
140
+ .bet th, .bet td { padding: 10px 14px; text-align: left; vertical-align: baseline; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
92
141
  .bet th:last-child, .bet td:last-child { border-right: 0; }
93
142
  .bet tbody tr:last-child td { border-bottom: 0; }
94
143
  .bet th { color: var(--text-2); font-weight: 400; white-space: nowrap; }
95
144
  .bet tr.row { cursor: pointer; }
96
145
  @media (hover: hover) { .bet tr.row:hover td { background: #1c1816; } }
97
146
  .bet td.member { position: sticky; left: 0; z-index: 1; background: var(--bg); white-space: nowrap; }
147
+ /* the team's member header is held with its names, so every header stays over its column */
148
+ .bet table.k-team th.member { position: sticky; left: 0; z-index: 2; background: var(--bg); }
98
149
  .bet td.member button { font-size: 18px; font-weight: 700; text-align: left; min-height: 32px; }
150
+ /* a long name never takes over a phone: the held column stops at about fifteen letters and the name breaks at its hyphens */
151
+ .bet table.k-team td.member button { width: max-content; max-width: 8.5em; white-space: normal; overflow-wrap: break-word; }
99
152
  .bet td.role { min-width: 9rem; color: var(--text-2); }
100
153
  .bet td.ovr { white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
154
+ .bet th.ovr { text-align: right; }
101
155
  .bet td.engine, .bet td.done { white-space: nowrap; }
102
156
  .bet td.engine button { text-align: left; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; min-height: 32px; }
103
157
  .bet td.status { min-width: 16rem; color: var(--text-2); }
104
158
  .bet td.status.working { color: var(--text); }
105
159
  .bet td.status.waiting-for-your-yes, .bet td.status.needs-you { color: var(--amber); }
106
160
  .bet td.work { min-width: 14rem; }
161
+ /* a job card's one row, where its columns cannot all fit: one line per column, its name beside its words, nothing to scroll sideways */
162
+ @media (max-width: 60rem) {
163
+ .bet table.k-deck { min-width: 0; }
164
+ .bet table.k-deck thead { display: none; }
165
+ .bet table.k-deck tbody, .bet table.k-deck tr { display: block; }
166
+ .bet table.k-deck td { display: flex; gap: 14px; min-width: 0; border-right: 0; position: static; white-space: normal; }
167
+ .bet table.k-deck td + td { border-top: 1px solid var(--line); }
168
+ .bet table.k-deck td::before { content: attr(data-col); flex: none; width: 4.5rem; color: var(--text-2); }
169
+ }
107
170
  /* a job deck card: its place in the deck and its status above the title */
108
171
  .bet .count, .bet .label, .bet .time { display: block; font-size: 17px; color: var(--text-2); }
172
+ .deckhead { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
173
+ .deckhead .pager { margin-right: -6px; white-space: nowrap; }
109
174
  .bet .label.needs-you { color: var(--amber); }
110
175
  .bet .time { margin: 10px 0 0; }
111
176
  .jobline { margin: 10px 0 0; color: var(--text-2); font-size: 17px; }
@@ -114,7 +179,7 @@ body.wide .screen { max-width: 64rem; }
114
179
  .sayline .thread, .sayline .reply { width: 100%; text-align: left; margin: 0; }
115
180
  .unsent { margin: 0; text-align: center; }
116
181
  .act > .links:empty { display: none; }
117
- .turn { margin: 0 0 6px; font-size: 18px; line-height: 1.4; }
182
+ .turn { margin: 0 0 6px; font-size: 17px; line-height: 1.4; }
118
183
  .turn.you { color: var(--text-2); }
119
184
  .turn.orb { color: var(--text); }
120
185
  .say { width: 100%; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: transparent; color: var(--text); font: inherit; font-size: 18px; line-height: 1.4; resize: none; }
@@ -128,6 +193,8 @@ body.wide .screen { max-width: 64rem; }
128
193
  box-shadow: 0 0 40px rgba(255, 140, 66, 0.1);
129
194
  filter: saturate(1.3);
130
195
  transform-origin: 50% 50%;
196
+ /* nothing until it is first placed, so its 280px box never widens a narrow page */
197
+ transform: scale(0);
131
198
  will-change: transform, opacity;
132
199
  }
133
200
  .lava .tx, .lava .ty { position: absolute; inset: 0; will-change: transform; }
@@ -143,6 +210,8 @@ body.wide .screen { max-width: 64rem; }
143
210
  .lava .core { position: absolute; inset: 0; border-radius: 50%; background: radial-gradient(circle, rgba(255,232,170,0.95) 0%, rgba(251,150,44,0.6) 30%, rgba(251,130,36,0) 62%); }
144
211
  .lava .dim { position: absolute; inset: 0; border-radius: 50%; background: var(--bg); opacity: 0; }
145
212
  .lava.tap { pointer-events: auto; cursor: pointer; }
213
+ /* the small orb is the big one drawn small, so its ring is set against that scale (settled) */
214
+ .lava:focus-visible { outline-width: var(--ring, 2px); outline-offset: var(--ring-off, 4px); }
146
215
  .lava.still .tx, .lava.still .ty, .lava.still .blob { animation-play-state: paused; }
147
216
  @keyframes sine-x { 0%, 100% { transform: translate3d(calc(var(--amp) * -1), 0, 0); } 50% { transform: translate3d(var(--amp), 0, 0); } }
148
217
  @keyframes sine-y { 0%, 100% { transform: translate3d(0, calc(var(--amp) * -1), 0); } 50% { transform: translate3d(0, var(--amp), 0); } }
@@ -151,7 +220,13 @@ body.wide .screen { max-width: 64rem; }
151
220
  /* ---- the card: same dark page, the lava orb small on top ---- */
152
221
  body.has-card .screen { justify-content: flex-start; padding-top: calc(max(16px, env(safe-area-inset-top)) + 76px); }
153
222
  body.has-card .stage, body.has-card .line { display: none; }
154
- body.has-card .foot { position: static; margin-top: 28px; }
223
+ /* the footer sits at the bottom of the screen on a card too, where it is on the big orb, and only
224
+ moves below a card taller than the screen, 28px under it */
225
+ body.has-card .foot { position: static; margin-top: auto; padding-top: 28px; }
226
+ /* it spans the card's column and keeps the room of its one line of links when none shows
227
+ (the team card hides its own), so a link that comes back with the next card never moves it */
228
+ body.has-card:not(.waiting) .foot { align-self: stretch; min-height: calc(28px + 44px); }
229
+ body.has-card:not(.waiting) .screen { padding-bottom: max(20px, env(safe-area-inset-bottom)); }
155
230
  .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); }
156
231
  .bet.up { opacity: 1; transform: none; }
157
232
  .bet.quick { transition-duration: 0.4s, 0.5s; }
@@ -162,8 +237,9 @@ body.has-card .foot { position: static; margin-top: 28px; }
162
237
  /* card to card: a crossfade in place. the old card is a still copy that
163
238
  fades over the box while the new card fades in under it from a small
164
239
  drift, its title landing where the old title was; the box eases to the
165
- new height */
166
- .bet.ghost { position: absolute; z-index: 1; margin: 0; pointer-events: none; transition: opacity 0.2s ease-out; }
240
+ new height. The copy fades on the new card's own curve and delay, so the
241
+ two always add up to one whole card, with no blink between them */
242
+ .bet.ghost { position: absolute; z-index: 1; margin: 0; pointer-events: none; transition: opacity 0.32s ease-out 0.08s; }
167
243
  .bet.ghost.gone { opacity: 0; }
168
244
  .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); }
169
245
  .bet.turn-in { opacity: 0; transform: translateY(4px); transition: none; }
@@ -171,19 +247,38 @@ body.has-card .foot { position: static; margin-top: 28px; }
171
247
  .bet.leave { opacity: 0; transform: none; transition: opacity 0.4s ease-out; }
172
248
  .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; }
173
249
  .why { margin: 14px 0 0; color: var(--text); font-size: 20px; line-height: 1.4; }
250
+ /* titles and labels wrap into even lines, and words to read never leave one word alone on the last line */
251
+ .bet h1, .option .t, .choice { text-wrap: balance; }
252
+ .why, .mission, .jobline, .turn, .option .w, .choice .w { text-wrap: pretty; }
253
+ /* a word longer than the screen (an email address) breaks instead of pushing the page sideways; the table keeps its own widths */
254
+ .bet, .line { overflow-wrap: anywhere; }
255
+ .bet .grid { overflow-wrap: normal; }
256
+ /* the last two words of a title, label, or line wrap together whenever the two fit on one line (keepPairs) */
257
+ .pair.kept { white-space: nowrap; }
174
258
  .act { margin: 18px 0 0; display: flex; flex-direction: column; align-items: center; gap: 6px; }
175
259
  .pill {
176
260
  min-width: 12rem; height: 44px; padding: 0 28px; border-radius: 22px;
177
261
  background: var(--amber); color: var(--amber-ink); font-size: 17px; font-weight: 600; letter-spacing: -0.01em;
178
262
  }
179
263
  .pill.armed { background: var(--text); color: var(--bg); }
180
- .links { color: var(--text-2); font-size: 17px; }
264
+ /* under 240px (a small phone at 200% zoom) the button is the card's width, never past its edges */
265
+ @media (max-width: 240px) { .pill { min-width: 100%; } }
266
+ .links { color: var(--text-2); font-size: 17px; text-align: center; }
267
+ /* a dot between two small links that falls where the row wraps becomes the break itself */
268
+ .links .sep.cut { display: block; height: 0; overflow: hidden; }
181
269
  .links button { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; min-height: 44px; padding: 0 6px; }
182
270
  .links .pill { color: var(--amber-ink); text-decoration: none; padding: 0 28px; }
271
+ /* the quiet choice that is set now (who a swap is for) reads in full color over an amber line */
272
+ .links button.chosen { color: var(--text); text-decoration-color: var(--amber); }
273
+ /* a short quiet link ("no") still takes a finger: it answers a tap at least
274
+ 44px wide, centered on its words, and the words never move */
275
+ .links button { position: relative; }
276
+ .links button::after { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: max(100%, 44px); transform: translateX(-50%); }
183
277
  .mission { margin: 32px 0 0; padding: 16px 0 0; border-top: 1px solid var(--line); color: var(--text); font-size: 20px; line-height: 1.4; text-align: center; }
278
+ .mission.news { margin: 14px 0 0; padding: 0; border-top: 0; text-align: left; }
184
279
  /* a choice: two or three ideas to tap, each a big title and one plain line of why */
185
280
  .options { margin: 14px 0 0; display: flex; flex-direction: column; gap: 10px; }
186
- .option { width: 100%; text-align: left; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); color: var(--text); }
281
+ .option { width: 100%; text-align: left; padding: 12px 16px; border-radius: 14px; border: 1px solid var(--line); color: var(--text); }
187
282
  .option .t { display: block; font-size: 20px; line-height: 1.2; font-weight: 700; letter-spacing: -0.02em; }
188
283
  .option .w { display: block; margin-top: 4px; font-size: 17px; line-height: 1.3; color: var(--text-2); }
189
284
  /* a question card: the answers stack full width under the question */
@@ -191,16 +286,27 @@ body.has-card .foot { position: static; margin-top: 28px; }
191
286
  .act.answers .links { text-align: center; }
192
287
 
193
288
  /* an answer to a question card */
194
- .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; }
289
+ .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: 20px; }
290
+ /* an answer with a note (a model and its record): the name on its line, the note quiet under it */
291
+ .choice .t { display: block; }
292
+ .choice .w { display: block; margin-top: 2px; font-size: 17px; line-height: 1.3; color: var(--text-2); }
293
+ /* an answer that wraps keeps its lines together, like an idea's title */
294
+ .choice { line-height: 1.3; }
195
295
 
196
296
  /* typing in place: the tapped answer becomes this box at its own size and
197
297
  spot, the cursor in it, and the rest of the card dims and stays put */
198
298
  .field { position: relative; display: block; width: 100%; padding: 0; border: 1px solid var(--line); border-radius: 14px; color: var(--text); }
299
+ .field { border-color: var(--edge); }
199
300
  .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; }
200
301
  .field input::placeholder { color: var(--text-2); opacity: 1; }
201
302
  .field:focus-within { border-color: var(--text-2); }
202
303
  .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; }
203
304
  .sayline .field input { padding: 0 76px; text-align: center; }
305
+ /* on a narrow phone its words start at the left, so the hint and what is typed have room beside send */
306
+ .sayline .field { container-type: inline-size; }
307
+ @container (max-width: 335px) { .sayline .field input { padding-left: 16px; text-align: left; } }
308
+ /* send waits for words: dim while the box shows only its hint */
309
+ .field input:placeholder-shown + .send { opacity: 0.45; }
204
310
  /* a chosen answer holds its spot with an amber edge, and the card takes no taps */
205
311
  .choice.chosen, .option.chosen, .field.chosen { border-color: var(--amber); background: rgba(255, 140, 66, 0.1); }
206
312
  .chosen .send { visibility: hidden; }
@@ -218,7 +324,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
218
324
  body.has-card.waiting .screen { justify-content: center; padding-top: max(24px, env(safe-area-inset-top)); }
219
325
  body.has-card.waiting .stage { display: block; transform: translateY(var(--wait-dy, 0px)) scale(var(--wait-k, 1)); }
220
326
  body.has-card.waiting .line { display: block; }
221
- body.has-card.waiting .foot { position: absolute; margin-top: 0; }
327
+ body.has-card.waiting .foot { position: absolute; margin-top: 0; padding-top: 0; }
222
328
  body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; top: var(--wait-top, 62%); padding-bottom: 76px; }
223
329
  .bet.wait-in { transform: translateY(8px); transition: opacity 0.4s ease-out 0.28s, transform 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.28s; }
224
330
  .bet.wait-in.up { transform: none; }
@@ -234,7 +340,8 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
234
340
  @media (prefers-reduced-motion: reduce) {
235
341
  .lava .tx, .lava .ty, .lava .blob { animation: none !important; }
236
342
  .bet { transform: none; transition: opacity 0.2s linear; }
237
- .line { transition: none; }
343
+ /* the line's words change with the same plain fade as everything else */
344
+ .line { transition: opacity 0.2s linear; }
238
345
  /* no travel and no swarm: every card change is a plain fade */
239
346
  .stage, .waitstep, .waitnotes, .waitnotes .turn { transition: none; }
240
347
  .bet.quick, .bet.acted, .bet.leave, .bet.leave.fast, .bet.wait-in { transform: none; transition: opacity 0.2s linear; }
@@ -242,15 +349,18 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
242
349
  }
243
350
  </style>
244
351
  </head>
245
- <body>
352
+ <body class="opening">
246
353
  <main class="screen" id="screen">
247
354
  <div class="stage" id="stage">
248
355
  <canvas id="orb" tabindex="0" role="button" aria-label="the atris orb. tap to think of one new idea for the business"></canvas>
249
356
  </div>
250
357
  <p class="line" id="line" aria-live="polite">tap to think</p>
251
358
  <article class="bet" id="bet" hidden></article>
359
+ <p class="sr" id="said" aria-live="polite" aria-atomic="true"></p>
252
360
  <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>
253
361
  </main>
362
+ <!-- the small orb, the way back to the big one, in a landmark of its own -->
363
+ <nav aria-label="the orb">
254
364
  <div class="lava" id="lava" role="button" tabindex="-1" aria-label="the atris orb. tap to go back to the big orb">
255
365
  <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>
256
366
  <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>
@@ -258,10 +368,14 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
258
368
  <div class="core" id="lava-core"></div>
259
369
  <div class="dim" id="lava-dim"></div>
260
370
  </div>
371
+ </nav>
372
+ <script type="application/json" id="first-look">${firstJson(first)}</script>
261
373
  <script nonce="${nonce}">
262
374
  (function () {
263
375
  var token = new URLSearchParams(location.search).get('t') || '';
264
376
  var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
377
+ // nothing paints until this script ends, so the first frame shown is whole
378
+ document.body.classList.add('on');
265
379
 
266
380
  function el(tag, cls, text) {
267
381
  var node = document.createElement(tag);
@@ -270,15 +384,29 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
270
384
  return node;
271
385
  }
272
386
 
273
- function api(path, body) {
387
+ // A look that never answers (the wifi dropped, the Mac fell asleep) gives
388
+ // up after LOOK_WAIT_MS. A tap is never cut off: past ACT_WAIT_MS the page
389
+ // only stops waiting on it (patient), because a slow tap may still be at
390
+ // work on the Mac, and its real answer still lands when it comes.
391
+ var ACT_WAIT_MS = 90000, LOOK_WAIT_MS = 15000;
392
+ // signal: lets a look that waits too long be called off (askCard); a look
393
+ // without one makes its own and gives up after LOOK_WAIT_MS
394
+ function api(path, body, signal) {
274
395
  var opts = { headers: { 'x-gm-token': token } };
396
+ if (signal) opts.signal = signal;
275
397
  if (body) {
276
398
  opts.method = 'POST';
277
399
  opts.headers['content-type'] = 'application/json';
278
400
  opts.body = JSON.stringify(body);
279
401
  }
402
+ var ctl = !body && !signal && typeof AbortController === 'function' ? new AbortController() : null;
403
+ var giveUp = ctl ? setTimeout(function () { ctl.abort(); }, LOOK_WAIT_MS) : 0;
404
+ if (ctl) opts.signal = ctl.signal;
405
+ // an answer whose words never finish (called off mid-read, or the link dropped) is
406
+ // no answer at all; one that came whole but is not json (a refusal) keeps its status
280
407
  return fetch(path + (body ? '' : '?t=' + encodeURIComponent(token)), opts)
281
- .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; }); });
408
+ .then(function (r) { return r.text().then(function (t) { var j = null; try { j = JSON.parse(t); } catch (e) { j = null; } if (!j || typeof j !== 'object') j = { ok: false }; j.http = r.status; if (typeof j.needs_you === 'number') drawNeeds(j.needs_you); return j; }); })
409
+ .then(function (j) { clearTimeout(giveUp); return j; }, function (err) { clearTimeout(giveUp); throw err; });
282
410
  }
283
411
 
284
412
  // The yeses still waiting past the card on screen: one small line, and a
@@ -292,26 +420,34 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
292
420
  // ---- the one line under the orb: fades out, changes, fades in ------------------
293
421
  var line = (function () {
294
422
  var node = document.getElementById('line');
295
- var want = null, busy = false;
423
+ var want = null, busy = false, fading = false;
296
424
  function next() {
297
425
  if (busy || want == null) return;
298
426
  var w = want; want = null;
299
427
  if (w.text === node.textContent && w.bad === node.classList.contains('bad')) return;
300
- if (reduced) { node.textContent = w.text; node.classList.toggle('bad', w.bad); return; }
301
428
  busy = true;
429
+ fading = true;
302
430
  node.classList.add('out');
303
431
  setTimeout(function () {
304
432
  node.textContent = w.text;
305
433
  node.classList.toggle('bad', w.bad);
306
434
  node.classList.remove('out');
435
+ fading = false;
307
436
  // each line stays long enough to read before the next one
308
437
  setTimeout(function () { busy = false; next(); }, 650);
309
- }, 280);
438
+ }, reduced ? 200 : 280);
310
439
  }
311
440
  return {
312
441
  set: function (text, bad) { want = { text: text, bad: Boolean(bad) }; next(); },
313
442
  // the page just opened: no fade to wait out, so the next line comes at once
314
443
  clear: function () { if (busy) { line.set('', false); return; } want = null; node.textContent = ''; node.classList.remove('bad', 'out'); },
444
+ // the card just left and the sphere is back: the line fades in with it, never there at once
445
+ appear: function () {
446
+ if (reduced || fading) return;
447
+ node.classList.add('out');
448
+ void node.offsetWidth;
449
+ requestAnimationFrame(function () { if (!fading) node.classList.remove('out'); });
450
+ },
315
451
  };
316
452
  })();
317
453
 
@@ -341,6 +477,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
341
477
  // the fast reveal stays contained: the lava grows to BLOOM of the smaller
342
478
  // window side and never washes the page
343
479
  var FAST_GROW_MS = 460, FAST_SETTLE_MS = 440, BLOOM = 0.72;
480
+ // the card under it starts fading up LAND of the way into the settle, as
481
+ // the lava eases into its spot, so the two read as one motion
482
+ var LAND = 0.6;
344
483
  // the owner acted on a card: one motion of about a second when the answer
345
484
  // is in. The card fades all the way while the small orb glides into the
346
485
  // sphere, the dots spiral in at ACT_PACE, and the next card grows out of
@@ -357,8 +496,10 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
357
496
  // when a spiral starts, each dot keeps the speed it had and lets it fade
358
497
  // at CARRY a frame, so the dots turn inward without a stop
359
498
  var CARRY = 0.82;
360
- // what the dots did on the last frame, kept on the canvas so the motion can be measured
361
- var stats = { frame: 0, mode: 'idle', step: 0, mean: 0 };
499
+ // what the dots did on the last frame, kept on the canvas so the motion can be
500
+ // measured, and ms: how much of the dots' own clock that frame was, which is
501
+ // at most 50ms however long the frame took
502
+ var stats = { frame: 0, mode: 'idle', step: 0, mean: 0, ms: 0 };
362
503
  canvas.__orb = stats;
363
504
  // Two speeds, chosen by truth. A bet already waiting in the queue plays
364
505
  // the same curves at FAST of their length (tap to card in about two seconds, unhurried).
@@ -372,6 +513,14 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
372
513
  var count = window.innerWidth < 768 ? 5500 : 11000;
373
514
  var w = 0, h = 0, R = 0, cx = 0, cy = 0;
374
515
  var dots = [];
516
+ // the dots are drawn by color and brightness, LEVELS of each, so a frame
517
+ // sets the fill color about a hundred times instead of once a dot. Each
518
+ // frame keeps every dot's place, size, and level in these, then draws
519
+ // them level by level, dim first.
520
+ var LEVELS = 64, STYLES = [];
521
+ for (var q = 0; q < 2 * LEVELS; q++) STYLES.push((q < LEVELS ? 'rgba(251, 130, 36, ' : 'rgba(253, 196, 84, ') + ((q % LEVELS) / (LEVELS - 1)).toFixed(3) + ')');
522
+ var px = null, py = null, pr = null, pb = null, order = null;
523
+ var counts = new Int32Array(2 * LEVELS);
375
524
  // idle: the sphere drifts. pull: the dots spiral in by age. card: still.
376
525
  var mode = 'idle';
377
526
  var angle = 0;
@@ -408,6 +557,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
408
557
  // random points on a sphere (normalised gaussians), so it reads as stipple
409
558
  var gauss = function () { return Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random()); };
410
559
  dots = [];
560
+ px = new Float32Array(count); py = new Float32Array(count); pr = new Float32Array(count); pb = new Int16Array(count); order = new Int32Array(count);
411
561
  for (var i = 0; i < count; i++) {
412
562
  var gx = gauss(), gy = gauss(), gz = gauss();
413
563
  var n = Math.hypot(gx, gy, gz) || 1;
@@ -437,8 +587,15 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
437
587
  }
438
588
 
439
589
  function frame(now) {
440
- var dt = Math.min(0.05, (now - (lastT || now)) / 1000);
590
+ // the first frame after a start counts as one 60fps frame
591
+ var dt = lastT ? Math.min(0.05, (now - lastT) / 1000) : 1 / 60;
441
592
  lastT = now;
593
+ // the springs, the damping, and every ease below are tuned per 60fps
594
+ // frame; k is how many of those this frame was, so a slow phone and a
595
+ // 120Hz screen run them in the same time. The spring runs in steps of
596
+ // at most one 60fps frame, so a long frame follows the same path.
597
+ var k = dt * 60, carry = Math.pow(CARRY, k);
598
+ var subs = Math.max(1, Math.ceil(k - 0.001)), sk = k / subs, damp = Math.pow(DAMP, sk);
442
599
  ctx.clearRect(0, 0, w, h);
443
600
  var swarming = mode === 'swarm' && !reduced;
444
601
  var toward = 1 - Math.pow(1 - SWARM_EASE, dt * 60);
@@ -447,8 +604,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
447
604
  clock += dt;
448
605
  if (!reduced && (mode === 'idle' || mode === 'swarm')) angle += dt * spin;
449
606
  wave.age += dt;
450
- light.x += (light.tx - light.x) * 0.06;
451
- light.y += (light.ty - light.y) * 0.06;
607
+ var follow = 1 - Math.pow(0.94, k);
608
+ light.x += (light.tx - light.x) * follow;
609
+ light.y += (light.ty - light.y) * follow;
452
610
  lz = Math.sqrt(Math.max(0, 1 - light.x * light.x - light.y * light.y));
453
611
  var r2 = RADIUS * RADIUS;
454
612
  var waveFront = wave.age * WAVE_SPEED;
@@ -458,10 +616,11 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
458
616
  // ready: the age runs on the clock, as on the homepage. Waiting: it
459
617
  // follows the real progress and stops short of the point.
460
618
  if (timed) age += dt / pace;
461
- else age += (ageTo - age) * 0.05;
619
+ else age += (ageTo - age) * (1 - Math.pow(0.95, k));
462
620
  }
463
621
  var cosA = Math.cos(angle), sinA = Math.sin(angle);
464
622
  var most = 0, sum = 0, wander = WANDER * R * swarmK;
623
+ counts.fill(0);
465
624
  for (var i = 0; i < dots.length; i++) {
466
625
  var d = dots[i];
467
626
  var fade = 1, shrink = 1;
@@ -490,14 +649,16 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
490
649
  if (dist2 < r2 && dist2 > 0.01) {
491
650
  var dist = Math.sqrt(dist2);
492
651
  var f = (1 - dist / RADIUS) * PUSH;
493
- d.vx += (dx / dist) * f * 0.2;
494
- d.vy += (dy / dist) * f * 0.2;
652
+ d.vx += (dx / dist) * f * 0.2 * k;
653
+ d.vy += (dy / dist) * f * 0.2 * k;
495
654
  }
496
655
  }
497
- d.vx += (homeX - d.sx) * SPRING;
498
- d.vy += (homeY - d.sy) * SPRING;
499
- d.vx *= DAMP; d.vy *= DAMP;
500
- d.sx += d.vx; d.sy += d.vy;
656
+ for (var j = 0; j < subs; j++) {
657
+ d.vx += (homeX - d.sx) * SPRING * sk;
658
+ d.vy += (homeY - d.sy) * SPRING * sk;
659
+ d.vx *= damp; d.vy *= damp;
660
+ d.sx += d.vx * sk; d.sy += d.vy * sk;
661
+ }
501
662
  if (reduced) { d.sx = cx + rx * R; d.sy = cy + d.y * R; }
502
663
  } else {
503
664
  var t = Math.min(1, Math.max(0, (age - d.dl) / 0.75));
@@ -505,7 +666,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
505
666
  var ox = (d.bx - cx) * (1 - e), oy = (d.by - cy) * (1 - e);
506
667
  var turn = e * 1.5;
507
668
  // the speed it had carries on and fades, then the spiral has it
508
- d.kx += d.vx; d.ky += d.vy; d.vx *= CARRY; d.vy *= CARRY;
669
+ d.kx += d.vx * k; d.ky += d.vy * k; d.vx *= carry; d.vy *= carry;
509
670
  d.sx = cx + ox * Math.cos(turn) - oy * Math.sin(turn) + d.kx * (1 - e);
510
671
  d.sy = cy + ox * Math.sin(turn) + oy * Math.cos(turn) + d.ky * (1 - e);
511
672
  fade = 1 - Math.pow(Math.max(0, (e - 0.8) / 0.2), 1.5);
@@ -517,13 +678,26 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
517
678
  var front = rz > 0;
518
679
  var l = Math.max(0, lit);
519
680
  var a = (front ? 0.16 + Math.pow(l, 1.3) * 0.84 : 0.05 + l * 0.08) * fade;
520
- if (a <= 0.003) continue;
521
- ctx.fillStyle = l > 0.93 ? 'rgba(253, 196, 84, ' + a.toFixed(3) + ')' : 'rgba(251, 130, 36, ' + a.toFixed(3) + ')';
522
- ctx.beginPath();
523
- ctx.arc(d.sx, d.sy, d.r * (front ? 0.6 + l * 0.7 : 0.6) * shrink, 0, Math.PI * 2);
524
- ctx.fill();
681
+ // a pointer at a corner lights past 1; those dots are the brightest level, never left out
682
+ var level = Math.min(LEVELS - 1, Math.round(a * (LEVELS - 1)));
683
+ if (level <= 0) { pb[i] = -1; continue; }
684
+ pb[i] = (l > 0.93 ? LEVELS : 0) + level;
685
+ counts[pb[i]] += 1;
686
+ px[i] = d.sx; py[i] = d.sy; pr[i] = d.r * (front ? 0.6 + l * 0.7 : 0.6) * shrink;
687
+ }
688
+ // where each level's dots start in order, then every dot in its level's place
689
+ for (var b = 0, at = 0; b < 2 * LEVELS; b++) { var n = counts[b]; counts[b] = at; at += n; }
690
+ for (var i2 = 0; i2 < dots.length; i2++) { if (pb[i2] >= 0) { order[counts[pb[i2]]] = i2; counts[pb[i2]] += 1; } }
691
+ // counts[b] is now where level b ends
692
+ for (var b2 = 0, from = 0; b2 < 2 * LEVELS; b2++) {
693
+ var to = counts[b2];
694
+ if (to > from) {
695
+ ctx.fillStyle = STYLES[b2];
696
+ for (var o = from; o < to; o++) { var j = order[o]; ctx.beginPath(); ctx.arc(px[j], py[j], pr[j], 0, Math.PI * 2); ctx.fill(); }
697
+ }
698
+ from = to;
525
699
  }
526
- stats.frame += 1; stats.mode = mode; stats.step = most; stats.mean = sum / Math.min(64, dots.length || 1);
700
+ stats.frame += 1; stats.mode = mode; stats.step = most; stats.mean = sum / Math.min(64, dots.length || 1); stats.ms = dt * 1000;
527
701
  if (mode === 'pull') {
528
702
  // where the dots land: one bright point that swells until the lava is born
529
703
  var arrived = Math.min(1, Math.max(0, (age - 0.3) / 0.7));
@@ -556,7 +730,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
556
730
  function start() { if (!raf) { lastT = 0; raf = requestAnimationFrame(frame); } }
557
731
  function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; }
558
732
 
559
- function place(s, x, y) { lava.style.transform = 'translate(' + (x - base / 2) + 'px, ' + (y - base / 2) + 'px) scale(' + s + ')'; }
733
+ // at: the frame time this place is for, when it is a step of a motion, kept on
734
+ // the lava so the motion can be measured on the clock it runs on
735
+ function place(s, x, y, at) { lava.style.transform = 'translate(' + (x - base / 2) + 'px, ' + (y - base / 2) + 'px) scale(' + s + ')'; lava.__at = at; }
560
736
  function restPoint() {
561
737
  var top = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-top')) || 0;
562
738
  return { x: document.documentElement.clientWidth / 2, y: window.scrollY + Math.max(16, top) + 8 + REST / 2 };
@@ -574,6 +750,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
574
750
  var contained = pace < 1;
575
751
  var growMs = quick ? ACT_GROW_MS : contained ? FAST_GROW_MS : (GROW_MS * pace);
576
752
  var settleMs = quick ? ACT_SETTLE_MS : contained ? FAST_SETTLE_MS : (SETTLE_MS * pace);
753
+ // after an act the card comes up at the bloom's peak; any other card once the lava has settled
754
+ planned('bloom', growMs);
755
+ if (!quick) planned('settle', contained ? settleMs * LAND : settleMs);
577
756
  // the point the dots drew, where the sphere is on screen (smaller and higher on the calm line)
578
757
  var vx = rect.left + rect.width / 2, vy = rect.top + rect.height / 2;
579
758
  var bloom = quick ? ACT_BLOOM : BLOOM;
@@ -598,7 +777,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
598
777
  var t0 = performance.now();
599
778
  var step = function (now) {
600
779
  var t = Math.min(1, (now - t0) / growMs);
601
- place(sMin + (sMax - sMin) * ease(t), origin.x, origin.y);
780
+ place(sMin + (sMax - sMin) * ease(t), origin.x, origin.y, now);
602
781
  lava.style.opacity = String(smooth(0.02, 0.22, t));
603
782
  core.style.opacity = String(1 - smooth(0.05, 0.4, t));
604
783
  dim.style.opacity = contained ? '0' : String(smooth(0.88, 1, t));
@@ -612,7 +791,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
612
791
  var s0 = performance.now();
613
792
  var settle = function (n2) {
614
793
  var u = ease(Math.min(1, (n2 - s0) / settleMs));
615
- place(sMax + (rest - sMax) * u, origin.x + (p.x - origin.x) * u, origin.y + (p.y - origin.y) * u);
794
+ place(sMax + (rest - sMax) * u, origin.x + (p.x - origin.x) * u, origin.y + (p.y - origin.y) * u, n2);
616
795
  dim.style.opacity = contained ? '0' : String(1 - smooth(0.35, 0.85, u));
617
796
  if (u < 1) { requestAnimationFrame(settle); return; }
618
797
  settled();
@@ -630,13 +809,13 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
630
809
  function rise(growMs, settleMs, sMin, sMax, rest) {
631
810
  var total = growMs + settleMs, k = growMs / total;
632
811
  var p = restPoint();
633
- var covered = false;
812
+ var covered = false, landed = false, landAt = k + LAND * (1 - k);
634
813
  var t0 = performance.now();
635
814
  var step = function (now) {
636
815
  var u = Math.min(1, (now - t0) / total);
637
816
  var s = u < k ? sMin + (sMax - sMin) * (0.5 - 0.5 * Math.cos(Math.PI * u / k)) : sMax + (rest - sMax) * ease((u - k) / (1 - k));
638
817
  var m = ease(u);
639
- place(s, origin.x + (p.x - origin.x) * m, origin.y + (p.y - origin.y) * m);
818
+ place(s, origin.x + (p.x - origin.x) * m, origin.y + (p.y - origin.y) * m, now);
640
819
  lava.style.opacity = String(smooth(0, 0.1, u));
641
820
  core.style.opacity = String(1 - smooth(0.04, 0.5, u));
642
821
  dim.style.opacity = '0';
@@ -648,6 +827,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
648
827
  canvas.style.visibility = 'hidden';
649
828
  if (onBorn) onBorn('cover');
650
829
  }
830
+ if (covered && !landed && u >= landAt) { landed = true; if (onBorn) onBorn('landing'); }
651
831
  if (u < 1) { requestAnimationFrame(step); return; }
652
832
  settled();
653
833
  };
@@ -663,6 +843,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
663
843
  // at rest it scrolls with the card
664
844
  lava.style.position = 'absolute';
665
845
  place(REST / base, p.x, p.y);
846
+ // its focus ring, 2px and 4px out on screen whatever the scale
847
+ lava.style.setProperty('--ring', (2 * base / REST).toFixed(2) + 'px');
848
+ lava.style.setProperty('--ring-off', (4 * base / REST).toFixed(2) + 'px');
666
849
  lava.style.visibility = 'visible';
667
850
  lava.style.opacity = '1';
668
851
  core.style.opacity = '0';
@@ -700,6 +883,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
700
883
  mode = fast ? 'swarm' : 'idle';
701
884
  bridgeAt = reduced ? 0 : performance.now();
702
885
  start();
886
+ if (!reduced) planned('beat', beatMs);
703
887
  setTimeout(cb, reduced ? 0 : beatMs);
704
888
  };
705
889
  if (reduced || lava.style.visibility !== 'visible') { reform(); return; }
@@ -709,12 +893,13 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
709
893
  lava.style.position = 'fixed';
710
894
  place(from.s, from.x, from.y);
711
895
  var sEnd = 30 / base;
896
+ planned('glide', glideMs);
712
897
  var t0 = performance.now();
713
898
  var step = function (now) {
714
899
  var k = Math.min(1, (now - t0) / glideMs);
715
900
  var t = fast ? 0.5 - 0.5 * Math.cos(Math.PI * k) : ease(k);
716
901
  if (now - t0 >= leaveMs) takeOff();
717
- place(from.s + (sEnd - from.s) * t, from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
902
+ place(from.s + (sEnd - from.s) * t, from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, now);
718
903
  core.style.opacity = String(smooth(0.3, 0.9, k));
719
904
  lava.style.opacity = String(1 - smooth(0.7, 1, k));
720
905
  if (k < 1) { requestAnimationFrame(step); return; }
@@ -725,7 +910,6 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
725
910
 
726
911
  measure();
727
912
  seed();
728
- start();
729
913
 
730
914
  // StippleOrb's pointer, ported with trackWindow on, as LetterOrb uses it.
731
915
  // Over the sphere the dots part around the pointer and spring back; the
@@ -757,6 +941,27 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
757
941
  }
758
942
  window.addEventListener('pointermove', lightFrom);
759
943
 
944
+ // A zoom, a turned phone, or a resized window: a small orb at rest moves to
945
+ // its place above the card, and the sphere takes its new size and pixel
946
+ // density, every dot keeping its place on it, so nothing jumps or softens.
947
+ // Off the page (a card is up) the sphere is measured when it comes back.
948
+ window.addEventListener('resize', function () {
949
+ if (lava.classList.contains('still') && lava.classList.contains('tap')) { var p = restPoint(); place(REST / base, p.x, p.y - window.scrollY); }
950
+ var ratio = Math.min(window.devicePixelRatio || 1, 2);
951
+ // a phone's bars sliding in and out send resizes too; only a real change redraws
952
+ if (!canvas.clientWidth || (canvas.clientWidth === w && canvas.clientHeight === h && ratio === dpr)) return;
953
+ var was = { cx: cx, cy: cy, R: R };
954
+ dpr = ratio;
955
+ measure();
956
+ var k = was.R ? R / was.R : 1;
957
+ for (var i = 0; i < dots.length; i++) {
958
+ var d = dots[i];
959
+ d.sx = cx + (d.sx - was.cx) * k; d.sy = cy + (d.sy - was.cy) * k;
960
+ d.bx = cx + (d.bx - was.cx) * k; d.by = cy + (d.by - was.cy) * k;
961
+ }
962
+ if (mode !== 'card') start();
963
+ });
964
+
760
965
  return {
761
966
  // the bet is ready: the homepage tap, on the clock
762
967
  // act: the owner acted on a card, so it is the one quick motion
@@ -767,6 +972,8 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
767
972
  pace = act ? ACT_PACE : fast ? FAST : 1;
768
973
  // after an act the dots spiral in from where the re-formed sphere is
769
974
  if (mode !== 'pull') startPull(fast || Boolean(act));
975
+ // the lava is born at 1.05 on the spiral's own clock, which runs at pace
976
+ planned('spiral', Math.max(0, 1.05 - age) * pace * 1000);
770
977
  timed = true;
771
978
  start();
772
979
  },
@@ -807,7 +1014,12 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
807
1014
  leaveMs: reduced ? 200 : LEAVE_MS,
808
1015
  // a bet is already up when the page loads: the settled orb, no motion
809
1016
  settled: function () { stop(); onBorn = null; settled(); },
1017
+ // the first look found no card: the big orb starts
1018
+ begin: start,
810
1019
  moving: function () { return mode === 'pull'; },
1020
+ // the page went to the background: no frames; back on screen, the sphere picks up
1021
+ pause: stop,
1022
+ resume: function () { if (mode !== 'card') start(); },
811
1023
  };
812
1024
  })();
813
1025
 
@@ -817,7 +1029,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
817
1029
  // card, and nothing from the card before survives a draw. Every tap posts
818
1030
  // the card's id and the option's id, or the words typed in its box, to
819
1031
  // /card; the Mac refuses anything else, and its answer is the next card.
820
- var bet = { shown: null, watching: null, tapAt: 0, noting: false, clock: null };
1032
+ var bet = { shown: null, watching: null, tapAt: 0, plan: [], waited: 0, noting: false, clock: null };
821
1033
  // while an act or a turn is under way the card takes no taps; the page
822
1034
  // marks it on the body too, so anyone reading the page can tell
823
1035
  var busyNow = false;
@@ -829,11 +1041,58 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
829
1041
  // what turns in place instead of the spiral: flipping the deck, more
830
1042
  // models, and who a swap is for are the same card moving, not an answer
831
1043
  var IN_PLACE = ['flip', 'more', 'scope'];
1044
+ // moving around the team pages turns in place too: a talk card, a swap
1045
+ // card, the deck, a way back, and the team after a swap are places to go,
1046
+ // not answers
1047
+ var AROUND = ['member', 'swap', 'deck', 'back', 'swap_to'];
1048
+
1049
+ // Safari shows a pressed button only when the page listens for touches
1050
+ document.addEventListener('touchstart', function () {}, { passive: true });
832
1051
 
833
1052
  function buzz() { if (navigator.vibrate) { try { navigator.vibrate(10); } catch (e) { /* not on this device */ } } }
834
1053
 
835
1054
  // a failed fetch reads like a refusal, so nothing ever hangs
836
1055
  function offline() { return { ok: false, http: 0, message: 'that did not reach the mac. try again.' }; }
1056
+ // A tap the page stopped waiting on is not a failure: no one can tell a
1057
+ // slow Mac from one that never heard it, so the page says what is true.
1058
+ // Only a network that failed before any answer says it did not get there.
1059
+ var STILL = 'the mac is still working on it. you will see the result here once it is done.';
1060
+ // the answer, or { slow } once ACT_WAIT_MS has passed; the real answer goes on.
1061
+ // An answer in time clears the wait, so a long session never piles up timers.
1062
+ function patient(answer) {
1063
+ var timer = 0;
1064
+ var slow = new Promise(function (resolve) { timer = setTimeout(function () { resolve({ ok: false, http: 0, slow: true, message: STILL }); }, ACT_WAIT_MS); });
1065
+ return Promise.race([answer, slow]).then(function (r) { clearTimeout(timer); return r; });
1066
+ }
1067
+ // Once the real answer to a tap on a card the page stopped waiting on
1068
+ // comes, it shows, if the owner is still on that card and nothing else is
1069
+ // moving; the look every few seconds brings the Mac's card in any case.
1070
+ function whenLate(answer, late, from) {
1071
+ answer.then(function (r) {
1072
+ if (!r.slow) return;
1073
+ late.then(function (l) {
1074
+ if (bet.busy || bet.noting || !sameCard(bet.shown, from) || !document.body.classList.contains('has-card')) return;
1075
+ if (l.ok && l.card && !sameCard(l.card, from)) { bet.busy = true; arrive(l.card); return; }
1076
+ if (l.ok && (l.home || !l.card)) { bet.busy = true; leave(function () { bet.busy = false; bet.shown = null; orb.calm(); line.set(l.home ? 'tap when you want another idea' : 'tap to think', false); }); return; }
1077
+ var note = box().querySelector('.act .note');
1078
+ if (note && note.textContent === STILL) note.textContent = l.ok ? '' : (l.message || 'that did not go through. try again.');
1079
+ });
1080
+ });
1081
+ }
1082
+ // the big orb stopped waiting on a slow answer: it says so, takes taps
1083
+ // again, and shows the card once the real answer brings one
1084
+ function stillOn(asked, show) {
1085
+ bet.busy = false;
1086
+ orb.relax();
1087
+ line.set(STILL, false);
1088
+ var idle = function () { return !bet.busy && !document.body.classList.contains('has-card'); };
1089
+ asked.then(function (r) {
1090
+ if (idle() && r.http === 200 && r.card) { bet.busy = true; line.set('', false); show(r.card); return; }
1091
+ if (idle()) line.set(r.http ? 'tap to think' : 'that did not go through. tap to try again.', !r.http);
1092
+ }, function () { if (idle()) line.set('that did not go through. tap to try again.', true); });
1093
+ }
1094
+ // a refusal because the card on screen is gone, with no card on the Mac to show instead
1095
+ function gone(r) { return Boolean(r && !r.ok && r.reason === 'gone' && !r.card); }
837
1096
 
838
1097
  function box() { return document.getElementById('bet'); }
839
1098
 
@@ -841,12 +1100,26 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
841
1100
  function draw(card) {
842
1101
  var b = box();
843
1102
  b.textContent = '';
1103
+ // a card the big orb could have made is on screen (an idea, the calm line, the Mac's
1104
+ // own card): the next big orb tap is a new one. The team, the deck, a talk card, and a
1105
+ // swap are opened by the owner over the Mac's card, so they never free it.
1106
+ if (OPENED_HERE.indexOf(card.kind) < 0) orbTap = null;
844
1107
  b.setAttribute('data-card', card.id);
845
1108
  b.setAttribute('data-kind', card.kind);
846
1109
  document.body.classList.toggle('wide', Boolean(card.table));
847
- if (card.count) b.appendChild(el('span', 'count', card.count));
1110
+ // a deck card's place, with previous and next beside it at the top, where they never move between cards
1111
+ var flips = card.count ? card.options.filter(function (o) { return o.action === 'flip' && !o.cell; }) : [];
1112
+ if (card.count) {
1113
+ var head = b.appendChild(el('div', 'deckhead'));
1114
+ head.appendChild(el('span', 'count', card.count));
1115
+ var pager = flips.length ? head.appendChild(el('span', 'links pager')) : null;
1116
+ flips.forEach(function (o, i) { if (i) pager.appendChild(document.createTextNode(' · ')); pager.appendChild(optionButton(o)); });
1117
+ }
848
1118
  if (card.label) b.appendChild(el('span', 'label ' + card.label.replace(/ /g, '-'), card.label));
849
1119
  var title = b.appendChild(el('h1', null, card.title));
1120
+ // on the team, what just changed (a swap) is the first thing under the title, above a long table
1121
+ var news = card.kind === 'team' && card.line;
1122
+ if (news) b.appendChild(el('p', 'mission', card.line)).classList.add('news');
850
1123
  // a reply of more than one line reads at the body size, in the same spot
851
1124
  if (card.kind === 'reply') {
852
1125
  var lines = document.createRange();
@@ -862,14 +1135,22 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
862
1135
  var thread = b.appendChild(el('div', 'thread'));
863
1136
  card.thread.forEach(function (t) { thread.appendChild(el('p', 'turn ' + (t.who === 'owner' ? 'you' : 'orb'), t.text)); });
864
1137
  }
865
- // who it is and what they do; the rating and the model live on the team table
866
- if (card.job && !card.table && !calm) b.appendChild(el('p', 'jobline', [card.job.member, card.job.role].filter(Boolean).join(' · ')));
1138
+ // who it is and what they do; the rating and the model live on the team table.
1139
+ // a talk card's title is already the name, so its line is the role alone
1140
+ if (card.job && !card.table && !calm) b.appendChild(el('p', 'jobline', [card.job.member !== card.title && card.job.member, card.job.role].filter(Boolean).join(' · ')));
867
1141
  var inCells = card.options.filter(function (o) { return o.cell; });
868
- if (card.table) b.appendChild(drawTable(card, inCells));
1142
+ if (card.table) {
1143
+ var grid = b.appendChild(drawTable(card, inCells));
1144
+ // whatever scrolls into view in the table lands beside the held names, never under them
1145
+ var held = grid.querySelector('tbody td.member');
1146
+ if (held && getComputedStyle(held).position === 'sticky') grid.style.scrollPaddingLeft = held.offsetWidth + 'px';
1147
+ moreRight(grid);
1148
+ grid.addEventListener('scroll', function () { moreRight(grid); }, { passive: true });
1149
+ }
869
1150
  if (card.time) b.appendChild(el('span', 'time', card.time));
870
- if (card.line) b.appendChild(el('p', 'mission', card.line));
1151
+ if (card.line && !news) b.appendChild(el('p', 'mission', card.line));
871
1152
  // the options in the card's own order; quiet ones next to each other share one line
872
- var rest = card.options.filter(function (o) { return !o.cell; });
1153
+ var rest = card.options.filter(function (o) { return !o.cell && flips.indexOf(o) < 0; });
873
1154
  var quiet = function (o) { return !o.style || o.style === 'quiet'; };
874
1155
  var act = el('div', 'act' + (rest.some(function (o) { return o.style === 'choice' || o.style === 'option'; }) ? ' answers' : ''));
875
1156
  var links = null;
@@ -877,7 +1158,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
877
1158
  rest.forEach(function (o) {
878
1159
  if (!quiet(o)) { links = null; act.appendChild(optionButton(o)); return; }
879
1160
  // a group of quiet options shares one line, headed by its words
880
- if (links && (o.group || null) === group) links.appendChild(document.createTextNode(' · '));
1161
+ if (links && (o.group || null) === group) links.appendChild(el('span', 'sep', ' · '));
881
1162
  else {
882
1163
  links = act.appendChild(el('div', 'links'));
883
1164
  group = o.group || null;
@@ -886,11 +1167,74 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
886
1167
  links.appendChild(optionButton(o));
887
1168
  });
888
1169
  // a quiet line under the options, empty until something does not go through
889
- act.appendChild(el('p', 'links note'));
1170
+ // a status: a screen reader hears the line why the moment it appears
1171
+ act.appendChild(el('p', 'links note')).setAttribute('role', 'status');
890
1172
  b.appendChild(act);
1173
+ breakLinks(act);
891
1174
  if (card.input) b.appendChild(inputLine(card.input));
892
1175
  if (calm) live(card, true);
1176
+ keepPairs(b);
1177
+ announce(card);
1178
+ }
1179
+
1180
+ // A screen reader hears each new card once as it lands: its title and its
1181
+ // why. The region empties first, so a card with the same words is heard too.
1182
+ var heard = { id: null, at: 0 };
1183
+ function announce(card) {
1184
+ // the card is named by its own title in a screen reader's list
1185
+ box().setAttribute('aria-label', String(card.title || ''));
1186
+ // a button in a table cell says what it does, not only the word in the cell
1187
+ card.options.forEach(function (o) {
1188
+ var who = o.cell && o.binds && o.binds.member;
1189
+ var btn = who && box().querySelector('[data-option="' + o.id + '"]');
1190
+ if (!btn) return;
1191
+ if (o.action === 'member') btn.setAttribute('aria-label', 'talk to ' + who);
1192
+ if (o.action === 'swap') btn.setAttribute('aria-label', who + "'s model, " + o.label + '. tap to change it');
1193
+ });
1194
+ if (heard.id === card.id) return;
1195
+ heard.id = card.id;
1196
+ var said = document.getElementById('said');
1197
+ var title = String(card.title || '');
1198
+ var words = card.body ? title + (/[.?!]$/.test(title) ? ' ' : '. ') + card.body : title;
1199
+ said.textContent = '';
1200
+ clearTimeout(heard.at);
1201
+ heard.at = setTimeout(function () { said.textContent = words; }, 60);
1202
+ }
1203
+
1204
+ // the fade on a table's right edge, while some of it is still off to the right
1205
+ function moreRight(grid) { grid.classList.toggle('more-right', grid.scrollLeft + grid.clientWidth < grid.scrollWidth - 1); }
1206
+ window.addEventListener('resize', function () { var g = box().querySelector('.grid'); if (g) moreRight(g); });
1207
+ // A row of small links that wraps never ends or starts a line with its dot:
1208
+ // a dot whose two links sit on different lines becomes the line break.
1209
+ function breakLinks(root) {
1210
+ var seps = root.querySelectorAll('.links .sep');
1211
+ Array.prototype.forEach.call(seps, function (s) { s.classList.remove('cut'); });
1212
+ var cut = Array.prototype.filter.call(seps, function (s) {
1213
+ var a = s.previousElementSibling, z = s.nextElementSibling;
1214
+ return a && z && z.getBoundingClientRect().top > a.getBoundingClientRect().top + 1;
1215
+ });
1216
+ cut.forEach(function (s) { s.classList.add('cut'); });
1217
+ }
1218
+ window.addEventListener('resize', function () { breakLinks(box()); });
1219
+ // A title, label, or line keeps its last two words together whenever the
1220
+ // two fit on one line, in whatever font the screen has, so a long last word
1221
+ // is not left alone. Two wider than the line wrap as usual, so nothing runs
1222
+ // past the card. The words stay the same to copy or to hear read aloud.
1223
+ function keepPairs(root) {
1224
+ Array.prototype.forEach.call(root.querySelectorAll('h1,.why,.mission,.t,.w,button.choice'), function (n) {
1225
+ var pair = n.lastChild && n.lastChild.nodeType === 1 && n.lastChild.classList.contains('pair') ? n.lastChild : null;
1226
+ if (!pair) {
1227
+ var t = n.firstChild, at = t && n.childNodes.length === 1 && t.nodeType === 3 ? t.data.search(/\\S+\\s+\\S+\\s*$/) : -1;
1228
+ if (at <= 0) return;
1229
+ pair = n.appendChild(el('span', 'pair'));
1230
+ pair.appendChild(t.splitText(at));
1231
+ }
1232
+ pair.classList.add('kept');
1233
+ var s = getComputedStyle(n), room = n.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight);
1234
+ if (!(room > 0) || pair.getBoundingClientRect().width > room + 0.5) pair.classList.remove('kept');
1235
+ });
893
1236
  }
1237
+ window.addEventListener('resize', function () { keepPairs(box()); });
894
1238
 
895
1239
  // a table: one row per line, a line between every cell; an option with a
896
1240
  // cell is drawn in that cell, and a tap anywhere on its row opens the
@@ -909,17 +1253,35 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
909
1253
  row.forEach(function (cell, j) {
910
1254
  var col = t.columns[j];
911
1255
  var td = tr.appendChild(el('td', col + (col === 'status' && t.status ? ' ' + String(t.status[i] || '').replace(/ /g, '-') : '')));
1256
+ td.setAttribute('data-col', col);
912
1257
  var o = mine.filter(function (x) { return x.cell.column === col; })[0];
913
1258
  if (o) {
914
1259
  var btn = td.appendChild(optionButton(o, true));
915
1260
  btn.addEventListener('click', function (e) { e.stopPropagation(); });
916
1261
  } else td.textContent = cell;
917
1262
  });
918
- if (mine.length) tr.addEventListener('click', function () { var first = tr.querySelector('button'); if (first) first.click(); });
1263
+ if (mine.length) tr.addEventListener('pointerdown', function (e) { tr.pressAt = { x: e.clientX, y: e.clientY }; });
1264
+ if (mine.length) tr.addEventListener('click', function (e) { rowTap(tr, e); });
919
1265
  });
920
1266
  return grid;
921
1267
  }
922
1268
 
1269
+ // a tap on a row: the button in the cell that was tapped, so a tap beside
1270
+ // a model's words still opens its swap; else the row's first
1271
+ function rowTap(tr, e) {
1272
+ // a press that moved more than DRAG_PX before it lifted is a drag, not a
1273
+ // tap, whether or not the browser left words selected
1274
+ var from = tr.pressAt;
1275
+ tr.pressAt = null;
1276
+ if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > DRAG_PX) return;
1277
+ // a press that dragged to select words in the row is reading, not a tap
1278
+ var sel = window.getSelection && window.getSelection();
1279
+ if (sel && !sel.isCollapsed && tr.contains(sel.anchorNode)) return;
1280
+ var at = e.composedPath()[0], cell = at.closest && at.closest('td');
1281
+ var b = (cell && cell.querySelector('button')) || tr.querySelector('button');
1282
+ if (b) b.click();
1283
+ }
1284
+
923
1285
  // One option, as the card says: big or quiet, a second tap when it asks
924
1286
  // for one, a box when it takes words. bare: no class of its own (in a cell).
925
1287
  function optionButton(o, bare) {
@@ -935,13 +1297,21 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
935
1297
  if (o.type) btn.classList.add('other');
936
1298
  if (o.action === 'none') { btn.disabled = true; return btn; }
937
1299
  if (o.confirm) {
938
- // the first tap arms this one button; the second sends; it goes quiet after five seconds
939
- var armed = false, quiet = null;
1300
+ // the first tap arms this one button; the second sends; it goes quiet after five seconds.
1301
+ // A second tap within ARM_GAP_MS is the same fumbled double tap, so it never sends.
1302
+ var armed = false, armedAt = 0, quiet = null, ARM_GAP_MS = 400;
1303
+ // Esc steps back from an armed button before anything else
1304
+ btn.disarm = function () {
1305
+ if (!armed) return false;
1306
+ clearTimeout(quiet); armed = false; btn.textContent = o.label; btn.classList.remove('armed');
1307
+ return true;
1308
+ };
940
1309
  btn.addEventListener('click', function () {
941
1310
  if (bet.busy) return;
1311
+ if (armed && performance.now() - armedAt < ARM_GAP_MS) return;
942
1312
  buzz();
943
1313
  if (!armed) {
944
- armed = true; btn.textContent = o.confirm; btn.classList.add('armed');
1314
+ armed = true; btn.textContent = o.confirm; btn.classList.add('armed'); armedAt = performance.now();
945
1315
  quiet = setTimeout(function () { armed = false; btn.textContent = o.label; btn.classList.remove('armed'); }, 5000);
946
1316
  return;
947
1317
  }
@@ -978,7 +1348,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
978
1348
  // ---- answering in place: the eyes stay in one spot ---------------------------------
979
1349
  // every row of the card dims but the one in use; no spot, none
980
1350
  function dimOthers(spot) {
981
- box().querySelectorAll('.act > *, .act .links > button, .sayline, .grid').forEach(function (n) {
1351
+ box().querySelectorAll('.act > *, .act .links > button, .sayline, .grid, .deckhead').forEach(function (n) {
982
1352
  n.classList.toggle('muted', Boolean(spot) && n !== spot && !n.contains(spot) && !spot.contains(n));
983
1353
  });
984
1354
  }
@@ -991,9 +1361,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
991
1361
  var input = spot.querySelector('input');
992
1362
  if (input) { input.readOnly = true; input.blur(); }
993
1363
  bet.busy = true;
994
- bet.tapAt = performance.now();
995
- document.body.removeAttribute('data-reveal-ms');
996
- return bet.tapAt;
1364
+ var at = tapped();
1365
+ planned('hold', HOLD_MS);
1366
+ return at;
997
1367
  }
998
1368
 
999
1369
  function afterHold(heldAt) {
@@ -1006,6 +1376,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1006
1376
  // the words to sent(text, field). Left empty, it turns back into the button.
1007
1377
  // stay: the calm line's box, open from the start without the cursor, and
1008
1378
  // open after every send.
1379
+ var FULL = 'that is as much as fits in the box.';
1009
1380
  function typeHere(btn, placeholder, maxLength, sent, stay) {
1010
1381
  var field = el('div', btn.classList.contains('choice') ? 'choice field' : 'field');
1011
1382
  // its exact height, to the subpixel, so nothing under it moves
@@ -1025,13 +1396,29 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1025
1396
  // a press on send keeps the cursor in the field, so it never closes first
1026
1397
  go.addEventListener('mousedown', function (e) { e.preventDefault(); });
1027
1398
  go.addEventListener('click', function () { buzz(); submit(); });
1399
+ // a full box says so under the card, so a long paste is never cut off unseen
1400
+ input.addEventListener('input', function () {
1401
+ var note = box().querySelector('.act .note');
1402
+ if (!note) return;
1403
+ if (input.value.length >= maxLength) note.textContent = FULL;
1404
+ else if (note.textContent === FULL) note.textContent = '';
1405
+ });
1028
1406
  input.addEventListener('keydown', function (e) {
1029
- if (e.key === 'Enter') { e.preventDefault(); submit(); }
1407
+ // Enter while a word is still being composed (a Japanese, Chinese, or Korean keyboard) is the keyboard's
1408
+ if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); submit(); }
1030
1409
  });
1031
- input.addEventListener('blur', function () {
1032
- if (stay || input.value.trim() || field.classList.contains('chosen') || !field.isConnected) return;
1033
- field.replaceWith(btn);
1034
- dimOthers(null);
1410
+ // the focus leaving the whole box, not moving inside it (Tab to send):
1411
+ // an empty box turns back into its button, and when the focus went
1412
+ // nowhere (Esc, a tap on the page) the button takes it back
1413
+ field.addEventListener('focusout', function () {
1414
+ setTimeout(function () {
1415
+ if (field.contains(document.activeElement)) return;
1416
+ if (stay || input.value.trim() || field.classList.contains('chosen') || !field.isConnected) return;
1417
+ var lost = focusFree();
1418
+ field.replaceWith(btn);
1419
+ dimOthers(null);
1420
+ if (lost) btn.focus({ preventScroll: true });
1421
+ }, 0);
1035
1422
  });
1036
1423
  field.appendChild(input);
1037
1424
  field.appendChild(go);
@@ -1048,12 +1435,15 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1048
1435
  // sent twice and the same words to someone else always go. getRandomValues
1049
1436
  // works on the plain http of the wifi link, where randomUUID does not.
1050
1437
  var sent = { key: null, id: null };
1438
+ function newSendId() {
1439
+ var n = new Uint32Array(2);
1440
+ crypto.getRandomValues(n);
1441
+ return ('000000' + n[0].toString(36)).slice(-7) + ('000000' + n[1].toString(36)).slice(-7);
1442
+ }
1051
1443
  function sendId(key) {
1052
1444
  if (sent.key !== key) {
1053
- var n = new Uint32Array(2);
1054
- crypto.getRandomValues(n);
1055
1445
  sent.key = key;
1056
- sent.id = ('000000' + n[0].toString(36)).slice(-7) + ('000000' + n[1].toString(36)).slice(-7);
1446
+ sent.id = newSendId();
1057
1447
  }
1058
1448
  return sent.id;
1059
1449
  }
@@ -1071,12 +1461,31 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1071
1461
  var from = bet.shown;
1072
1462
  body.card_id = from.id;
1073
1463
  if (body.text) body.send = sendId([from.id, option ? option.id : 'the box', body.text].join(' | '));
1464
+ // a tap carries its own id too, so trying again on a tap the page stopped
1465
+ // waiting on gets the first one's answer and never does a yes or a swap twice
1466
+ if (!body.send && option) body.send = sendId([from.id, option.id].join(' | '));
1074
1467
  var heldAt = hold(spot);
1075
1468
  var answer = api('/act', body).catch(offline);
1076
- if (option && IN_PLACE.indexOf(option.action) >= 0) {
1469
+ var late = answer;
1470
+ answer = patient(answer);
1471
+ whenLate(answer, late, from);
1472
+ var around = option && AROUND.indexOf(option.action) >= 0;
1473
+ // the way out of the team when it was opened from the big orb goes back to the big orb
1474
+ var homeward = Boolean(around && option.action === 'back' && from.kind === 'team' && teamFromHome);
1475
+ if (option && (IN_PLACE.indexOf(option.action) >= 0 || around)) {
1077
1476
  document.body.setAttribute('data-in-place', '');
1078
1477
  Promise.all([answer, afterHold(heldAt)]).then(function (both) {
1079
1478
  var r = both[0];
1479
+ if (homeward && r.ok) { teamFromHome = false; r = { ok: true, card: null }; }
1480
+ // nothing is up on the Mac: the big orb, as from any card
1481
+ if (gone(r)) { document.body.removeAttribute('data-in-place'); leave(function () { bet.busy = false; bet.shown = null; orb.calm(); line.set('tap to think', false); }); return; }
1482
+ // a refusal that brings the card the Mac has now: that card, at once, never the stale one again
1483
+ var now = !r.ok && r.card && !sameCard(r.card, from) ? r.card : null;
1484
+ // nothing left to show, the calm line (it has its own way in), or a width the page
1485
+ // would jump to: the one motion through the sphere, as from the orb
1486
+ var through = r.ok ? (around && (r.home || !r.card || widthJumps(r.card))) || isWait(r.card) : Boolean(now && (isWait(now) || widthJumps(now)));
1487
+ if (through) { document.body.removeAttribute('data-in-place'); flow(Promise.resolve(r), from, body.text, option); return; }
1488
+ if (now) { turnTo(now, function () { unsent(now, r, body.text, null); }); return; }
1080
1489
  if (!r.ok || !r.card) { bet.busy = false; document.body.removeAttribute('data-in-place'); unsent(from, r, body.text, option); return; }
1081
1490
  turnTo(r.card);
1082
1491
  });
@@ -1085,22 +1494,41 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1085
1494
  afterHold(heldAt).then(function () { flow(answer, from, body.text, option); });
1086
1495
  }
1087
1496
 
1497
+ // While the sphere waits on the Mac's answer its line says nothing, since
1498
+ // a tap there does nothing yet; past SLOW_MS it says the Mac is still at it.
1499
+ var SLOW_MS = 8000;
1500
+ function waitLine(answer) {
1501
+ // under a card the line is out of sight, so it empties at once and the next line is never held up by a fade no one sees
1502
+ if (document.body.classList.contains('has-card')) line.clear(); else line.set('', false);
1503
+ var slow = setTimeout(function () { line.set('still waiting on the mac', false); }, SLOW_MS);
1504
+ var done = function () { clearTimeout(slow); };
1505
+ answer.then(done, done);
1506
+ }
1507
+
1088
1508
  function flow(answer, from, typed, option) {
1509
+ waitLine(answer);
1089
1510
  var landed = null;
1090
1511
  var sphere = false;
1091
- var lineAt = 0;
1512
+ var lineAt = 0, waitFrom = 0;
1092
1513
  var land = function () {
1514
+ // the sphere is ready before the answer is in: it waits from now
1515
+ if (sphere && !landed) waitFrom = performance.now();
1093
1516
  if (!landed || !sphere) return;
1517
+ if (waitFrom) bet.waited += performance.now() - waitFrom;
1094
1518
  var r = landed;
1095
1519
  landed = null;
1096
1520
  var wait = r.ack && lineAt ? Math.max(0, AFTER_MS - (performance.now() - lineAt)) : 0;
1521
+ if (wait) planned('line', wait);
1097
1522
  setTimeout(function () {
1098
1523
  // nothing more to show: the sphere stays, with its line, and its dots settle into the idle drift
1099
- if (r.ok && (r.home || !r.card)) { bet.busy = false; bet.shown = null; orb.calm(); line.set(r.home ? 'not now. tap when you want another' : 'tap to think', false); return; }
1524
+ if (r.ok && (r.home || !r.card)) { bet.busy = false; bet.shown = null; orb.calm(); line.set(r.home ? 'tap when you want another idea' : 'tap to think', false); return; }
1525
+ // the Mac has no card up at all (it started again, or the card was
1526
+ // finished elsewhere): the old card is gone, so the big orb waits for a tap
1527
+ if (gone(r)) { bet.busy = false; bet.shown = null; orb.calm(); line.set('tap to think', false); return; }
1100
1528
  // the card the Mac has on screen now is the one that comes back; a
1101
1529
  // refusal comes back on it with one line why
1102
1530
  var then = r.card || from;
1103
- if (!then) { bet.busy = false; orb.calm(); line.set('that did not go through. tap to try again', true); return; }
1531
+ if (!then) { bet.busy = false; orb.calm(); line.set('that did not go through. tap to try again.', true); return; }
1104
1532
  if (!r.ok) { spiralTo(then, function () { unsent(then, r, typed, then.id === (from && from.id) ? option : null); }); return; }
1105
1533
  spiralTo(then, null);
1106
1534
  }, wait);
@@ -1117,6 +1545,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1117
1545
  // sphere; the card leaves the page once it has faded all the way. From the
1118
1546
  // calm line the sphere is already there: its words fade and it glides home.
1119
1547
  function leave(then, fast) {
1548
+ endOpening();
1120
1549
  if (waiting()) { leaveWait(then, fast); return; }
1121
1550
  var b = box();
1122
1551
  var to = sphereSpot();
@@ -1128,11 +1557,13 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1128
1557
  b.hidden = true;
1129
1558
  b.classList.remove('leave', 'fast', 'holding');
1130
1559
  document.body.classList.remove('has-card', 'wide');
1560
+ line.appear();
1131
1561
  window.scrollTo(0, 0);
1132
1562
  };
1133
1563
  // reduced motion: no travel; the card and the small orb fade, then the sphere is there
1134
1564
  if (reduced) {
1135
1565
  document.getElementById('lava').style.opacity = '0';
1566
+ planned('leave', 200);
1136
1567
  setTimeout(function () { swap(); stageFade(); orb.melt(then, to, function () {}, fast); }, 200);
1137
1568
  return;
1138
1569
  }
@@ -1140,14 +1571,27 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1140
1571
  }
1141
1572
 
1142
1573
  // under reduced motion the sphere never travels: it fades in where it now sits
1574
+ // and its line fades in with it
1143
1575
  function stageFade() {
1144
1576
  if (!reduced) return;
1577
+ [document.getElementById('stage'), document.getElementById('line')].forEach(function (n) {
1578
+ n.style.transition = 'none';
1579
+ n.style.opacity = '0';
1580
+ void n.offsetWidth;
1581
+ n.style.transition = 'opacity 0.2s linear';
1582
+ n.style.opacity = '';
1583
+ });
1584
+ }
1585
+
1586
+ // Under reduced motion a card that comes while the sphere shows waits for
1587
+ // the sphere and its line to fade out, so they are never gone in one frame;
1588
+ // stageFade brings them back. True when there was a sphere to fade.
1589
+ function fadeSphere() {
1145
1590
  var stage = document.getElementById('stage');
1146
- stage.style.transition = 'none';
1147
- stage.style.opacity = '0';
1148
- void stage.offsetWidth;
1149
- stage.style.transition = 'opacity 0.2s linear';
1150
- stage.style.opacity = '';
1591
+ if (!reduced || !stage.offsetHeight || Number(getComputedStyle(stage).opacity) < 0.05) return false;
1592
+ [stage, document.getElementById('line')].forEach(function (n) { n.style.transition = 'opacity 0.2s linear'; n.style.opacity = '0'; });
1593
+ planned('out', 200);
1594
+ return true;
1151
1595
  }
1152
1596
 
1153
1597
  // from the sphere: the dots spiral in and the card grows out of the point.
@@ -1156,7 +1600,11 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1156
1600
  line.set('', false);
1157
1601
  if (isWait(card)) { enterWait(card); if (after) after(); return; }
1158
1602
  // reduced motion: no spiral; the card fades in where it goes
1159
- if (reduced) { orb.settled(); showCard(card, true); bet.busy = false; if (after) after(); return; }
1603
+ if (reduced) {
1604
+ var show = function () { orb.settled(); showCard(card, true); bet.busy = false; if (after) after(); };
1605
+ if (fadeSphere()) setTimeout(show, 200); else show();
1606
+ return;
1607
+ }
1160
1608
  orb.play(function (phase) {
1161
1609
  // the card blooms where the dots land; what comes with it (a line why, the words back in their box) comes with it
1162
1610
  if (phase === 'cover') { showCard(card, true, true, true); if (after) after(); }
@@ -1185,12 +1633,35 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1185
1633
  }
1186
1634
 
1187
1635
  // ---- showing a card ---------------------------------------------------------------------
1188
- // the time from the tap to the card fully on screen, kept on the page so
1189
- // the speed can be measured
1636
+ // The time from the tap to the card fully on screen, kept on the page so
1637
+ // the speed can be measured. data-reveal-ms is the clock. data-reveal-plan
1638
+ // is each step of the motion in order with the length it is set to take,
1639
+ // and data-reveal-waited-ms is how long the sphere waited on the Mac's
1640
+ // answer, so late frames on a busy machine are told apart from the motion.
1641
+ var REVEAL = ['data-reveal-ms', 'data-reveal-plan', 'data-reveal-waited-ms'];
1642
+ function tapped() {
1643
+ bet.tapAt = performance.now();
1644
+ bet.plan = [];
1645
+ bet.waited = 0;
1646
+ REVEAL.forEach(function (a) { document.body.removeAttribute(a); });
1647
+ return bet.tapAt;
1648
+ }
1649
+ function planned(name, ms) { if (bet.tapAt) bet.plan.push(name + ' ' + Math.round(ms)); }
1190
1650
  function measured() {
1191
- if (bet.tapAt) document.body.setAttribute('data-reveal-ms', String(Math.round(performance.now() - bet.tapAt)));
1651
+ if (bet.tapAt) {
1652
+ document.body.setAttribute('data-reveal-plan', bet.plan.join(', '));
1653
+ document.body.setAttribute('data-reveal-waited-ms', String(Math.round(bet.waited)));
1654
+ document.body.setAttribute('data-reveal-ms', String(Math.round(performance.now() - bet.tapAt)));
1655
+ }
1192
1656
  bet.tapAt = 0;
1193
1657
  }
1658
+ // how long a card's own fade up is set to take, read from its style
1659
+ function fadeMs(n) {
1660
+ var s = getComputedStyle(n);
1661
+ var i = Math.max(0, s.transitionProperty.split(', ').indexOf('opacity'));
1662
+ var at = function (list) { var v = list.split(', '); return (parseFloat(v[i % v.length]) || 0) * 1000; };
1663
+ return at(s.transitionDelay) + at(s.transitionDuration);
1664
+ }
1194
1665
 
1195
1666
  // fast: the quick fade from the big orb; act: the short one after an act
1196
1667
  function showCard(card, animate, fast, act) {
@@ -1210,10 +1681,12 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1210
1681
  draw(card);
1211
1682
  if (card.watch) watchWait(); else stopWatch();
1212
1683
  if (!animate) { measured(); return; }
1213
- var done = function (e) { if (e.propertyName === 'opacity') { b.removeEventListener('transitionend', done); measured(); } };
1684
+ // the fade ends the marks of the tap that brought this card, never those of a tap made since
1685
+ var startedAt = bet.tapAt;
1686
+ var done = function (e) { if (e.propertyName === 'opacity') { b.removeEventListener('transitionend', done); if (bet.tapAt === startedAt) measured(); } };
1214
1687
  b.addEventListener('transitionend', done);
1215
1688
  void b.offsetWidth;
1216
- requestAnimationFrame(function () { b.classList.add('up'); });
1689
+ requestAnimationFrame(function () { b.classList.add('up'); planned('fade', fadeMs(b)); });
1217
1690
  }
1218
1691
 
1219
1692
  // ---- the calm line: the dots keep moving while the next idea is written -----------------
@@ -1239,7 +1712,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1239
1712
  var screenTop = document.getElementById('screen').getBoundingClientRect().top;
1240
1713
  var h = stage.offsetHeight || 1, top0 = screenTop + stage.offsetTop;
1241
1714
  if (hidden) body.classList.remove('waiting');
1242
- var k = Math.min(1, 300 / h, 0.62 * Math.min(window.innerWidth, window.innerHeight) / h);
1715
+ // and on a short screen (a phone on its side) small enough that the sphere,
1716
+ // the card's room, and a margin above and below fit, so the footer never lands on the card
1717
+ var k = Math.min(1, 300 / h, 0.62 * Math.min(window.innerWidth, window.innerHeight) / h, Math.max(80, window.innerHeight - WAIT_ROOM - 48) / h);
1243
1718
  var top = Math.max(24, (window.innerHeight - (k * h + WAIT_ROOM)) / 2);
1244
1719
  var s = body.style;
1245
1720
  s.setProperty('--wait-k', k.toFixed(4));
@@ -1250,9 +1725,34 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1250
1725
  requestAnimationFrame(function () { requestAnimationFrame(function () { stage.style.transition = ''; }); });
1251
1726
  }
1252
1727
 
1728
+ // The phone keyboard opened (the part of the screen still showing shrank)
1729
+ // over the box being typed in: the box comes back into view. A box the
1730
+ // phone already brought into view is left where it is.
1731
+ if (window.visualViewport) window.visualViewport.addEventListener('resize', function () {
1732
+ var input = document.activeElement;
1733
+ if (!input || input.tagName !== 'INPUT' || !box().contains(input)) return;
1734
+ var vv = window.visualViewport, r = input.getBoundingClientRect();
1735
+ if (r.bottom > vv.offsetTop + vv.height - 8 || r.top < vv.offsetTop) input.scrollIntoView({ block: 'center', inline: 'nearest' });
1736
+ });
1737
+
1253
1738
  // a turned phone: the calm line's sphere and card find their place again
1254
1739
  window.addEventListener('resize', function () { if (waiting()) placeWait(false); });
1255
1740
 
1741
+ // The calm line's card takes no room of its own. On a screen too short for
1742
+ // it (a phone on its side, a note and its answer open) the footer moves
1743
+ // into the card's own room under its words instead of sitting on them,
1744
+ // and the page scrolls to it; the sphere and the card never move.
1745
+ function fitWait() {
1746
+ var foot = document.getElementById('foot');
1747
+ var b = box();
1748
+ var need = waiting() && !b.hidden ? b.offsetTop + b.offsetHeight : 0;
1749
+ var over = need > document.getElementById('screen').clientHeight;
1750
+ foot.style.top = over ? Math.round(need - 64) + 'px' : '';
1751
+ foot.style.bottom = over ? 'auto' : '';
1752
+ }
1753
+ if (typeof ResizeObserver === 'function') new ResizeObserver(fitWait).observe(box());
1754
+ window.addEventListener('resize', fitWait);
1755
+
1256
1756
  // atOnce: the page opened on it, so it is simply there
1257
1757
  function enterWait(card, atOnce) {
1258
1758
  var b = box();
@@ -1286,7 +1786,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1286
1786
  bet.shown = card;
1287
1787
  b.setAttribute('data-card', card.id);
1288
1788
  var h = b.querySelector('h1');
1289
- if (h.textContent !== card.title) h.textContent = card.title;
1789
+ if (h.textContent !== card.title) { h.textContent = card.title; keepPairs(b); }
1290
1790
  var lines = Array.isArray(card.progress) ? card.progress : [];
1291
1791
  var newest = lines.length ? lines[lines.length - 1] : null;
1292
1792
  stepTo(step, newest && typeof newest.line === 'string' ? newest.line : '', atOnce);
@@ -1368,10 +1868,17 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1368
1868
  bet.noting = true;
1369
1869
  field.classList.add('chosen');
1370
1870
  input.readOnly = true;
1871
+ // a note that takes a while reads as on its way, in send's own spot
1872
+ var go = field.querySelector('.send');
1873
+ var slow = setTimeout(function () { go.textContent = 'sending'; go.classList.add('reading'); }, 600);
1371
1874
  var note = box().querySelector('.act .note');
1372
1875
  if (note) note.textContent = '';
1373
- api('/act', body).catch(offline).then(function (r) {
1876
+ var told = api('/act', body).catch(offline);
1877
+ patient(told).then(function (r) {
1374
1878
  bet.noting = false;
1879
+ clearTimeout(slow); go.textContent = 'send'; go.classList.remove('reading');
1880
+ // a note the page stopped waiting on: its words stay, and once the Mac has it the box clears
1881
+ if (r.slow) told.then(function (l) { if (!l.ok || !waiting() || bet.noting) return; if (input.value.trim() === text) input.value = ''; var n = box().querySelector('.act .note'); if (n && n.textContent === STILL) n.textContent = ''; if (l.card && sameCard(l.card, bet.shown)) live(l.card); });
1375
1882
  field.classList.remove('chosen');
1376
1883
  input.readOnly = false;
1377
1884
  // the owner went back to the big orb meanwhile: the Mac has the note, and nothing here moves
@@ -1391,6 +1898,8 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1391
1898
  if (!fast) orb.calm();
1392
1899
  b.classList.remove('wait-in');
1393
1900
  b.classList.add('leave', 'fast');
1901
+ // under reduced motion the sphere fades out with the words, then fades in at its own place
1902
+ fadeSphere();
1394
1903
  setTimeout(function () {
1395
1904
  b.hidden = true;
1396
1905
  b.classList.remove('leave', 'fast', 'holding', 'up');
@@ -1415,6 +1924,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1415
1924
  document.getElementById('orb').setAttribute('aria-label', 'the atris orb. tap to think of one new idea for the business');
1416
1925
  };
1417
1926
  if (reduced) {
1927
+ fadeSphere();
1418
1928
  setTimeout(function () { off(); orb.settled(); showCard(card, true); bet.busy = false; if (after) after(); }, 200);
1419
1929
  return;
1420
1930
  }
@@ -1452,16 +1962,37 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1452
1962
  // TURN_IN_MS, starting 80ms in, so its title lands where the old title
1453
1963
  // was. The box eases to its new height. No layout jump.
1454
1964
  var TURN_IN_MS = 320, DRIFT = 4;
1455
- function turnTo(card) {
1965
+ // painted: what goes on the new card as it is drawn (a line why), so its height is measured with it
1966
+ function turnTo(card, painted) {
1456
1967
  var b = box();
1457
1968
  b.classList.remove('holding');
1969
+ var otherPlace = b.getAttribute('data-kind') !== card.kind;
1458
1970
  bet.shown = card;
1459
1971
  document.body.setAttribute('data-in-place', '');
1460
- turnInPlace(b, function () { draw(card); }, function () { bet.busy = false; document.body.removeAttribute('data-in-place'); measured(); if (card.watch) watchWait(); else stopWatch(); });
1972
+ turnInPlace(b, function () { draw(card); if (painted) painted(); }, function () { bet.busy = false; document.body.removeAttribute('data-in-place'); measured(); if (card.watch) watchWait(); else stopWatch(); });
1973
+ // another page scrolled down: its top comes back on screen, as a spiral would bring it
1974
+ if (otherPlace && b.getBoundingClientRect().top < 0) window.scrollTo({ top: 0, behavior: reduced ? 'auto' : 'smooth' });
1975
+ }
1976
+
1977
+ // a card with a table is drawn wider; on a screen wider than a plain card
1978
+ // the page would jump sideways under a turn
1979
+ function widthJumps(card) {
1980
+ var narrow = 34 * parseFloat(getComputedStyle(document.documentElement).fontSize);
1981
+ return window.innerWidth > narrow && Boolean(card.table) !== document.body.classList.contains('wide');
1461
1982
  }
1462
1983
 
1463
1984
  function turnInPlace(b, paint, after) {
1464
- if (reduced) { b.style.minHeight = ''; paint(); after(); return; }
1985
+ endOpening();
1986
+ if (reduced) {
1987
+ // under reduced motion the card fades out, changes, and fades back in,
1988
+ // and the footer steps aside while the card's height changes
1989
+ document.body.removeAttribute('data-in-place');
1990
+ b.style.minHeight = '';
1991
+ b.classList.remove('up');
1992
+ planned('turn', 400);
1993
+ setTimeout(function () { paint(); void b.offsetWidth; b.classList.add('up'); setTimeout(after, 200); }, 200);
1994
+ return;
1995
+ }
1465
1996
  var from = b.offsetHeight;
1466
1997
  b.style.height = from + 'px';
1467
1998
  b.style.minHeight = '';
@@ -1487,12 +2018,14 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1487
2018
  b.classList.remove('turn-in');
1488
2019
  b.style.height = to + 'px';
1489
2020
  ghost.classList.add('gone');
2021
+ var turnMs = 80 + TURN_IN_MS + 20;
2022
+ planned('turn', turnMs);
1490
2023
  setTimeout(function () {
1491
2024
  ghost.remove();
1492
2025
  b.classList.remove('turning');
1493
2026
  b.style.height = '';
1494
2027
  after();
1495
- }, 80 + TURN_IN_MS + 20);
2028
+ }, turnMs);
1496
2029
  }
1497
2030
 
1498
2031
  // While the calm line shows, look every WAIT_LOOK_MS: its live words are
@@ -1501,24 +2034,58 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1501
2034
  // waits while the owner types there (the box has the cursor or any words)
1502
2035
  // or a note is on its way, and on the big orb it waits for their tap.
1503
2036
  var WAIT_LOOK_MS = 1500;
2037
+ // One ask for the card at a time: a look while one is out shares its
2038
+ // answer, so asks never pile up while the Mac sleeps and land all at once.
2039
+ // An ask with no answer in ASK_WAIT_MS is called off and its slot freed,
2040
+ // so a Mac that wakes is asked fresh, never through a request lost to its sleep.
2041
+ var ASK_WAIT_MS = 4000;
2042
+ var asking = null, dropAsk = function () {};
2043
+ function askCard() {
2044
+ if (asking) return asking;
2045
+ var ctl = typeof AbortController === 'function' ? new AbortController() : null;
2046
+ var mine = null;
2047
+ var free = function () { clearTimeout(late); if (asking === mine) { asking = null; dropAsk = function () {}; } };
2048
+ var late = setTimeout(function () { free(); if (ctl) ctl.abort(); }, ASK_WAIT_MS);
2049
+ mine = asking = api('/card', null, ctl && ctl.signal).then(function (s) { free(); return s; }, function (e) { free(); throw e; });
2050
+ dropAsk = function () { free(); if (ctl) ctl.abort(); };
2051
+ return mine;
2052
+ }
1504
2053
  function typingOnCard() {
1505
2054
  var input = box().querySelector('.field input');
1506
2055
  return Boolean(input && (input === document.activeElement || input.value.trim()));
1507
2056
  }
2057
+ // A finger or the mouse is down on the page, or lifted a moment ago: a
2058
+ // card the Mac changed never swaps in under it, so a press always lands
2059
+ // on the card it began on. A finger that scrolls lets go (pointercancel).
2060
+ var pressed = { down: false, at: 0 }, PRESS_STILL_MS = 600;
2061
+ document.addEventListener('pointerdown', function () { pressed.down = true; }, true);
2062
+ ['pointerup', 'pointercancel'].forEach(function (type) { document.addEventListener(type, function () { pressed.down = false; pressed.at = performance.now(); }, true); });
2063
+ function pressing() { return pressed.down || performance.now() - pressed.at < PRESS_STILL_MS; }
1508
2064
  function stopWatch() { if (bet.watching) { clearInterval(bet.watching); bet.watching = null; } }
2065
+ // the calm line's reveal the page gave up on, while it is still unanswered
2066
+ var lostReveal = null;
1509
2067
  function watchWait() {
1510
2068
  if (bet.watching) return;
1511
- var quiet = function () { return !bet.busy && !bet.noting && document.body.classList.contains('has-card') && !typingOnCard(); };
2069
+ var quiet = function () { return !bet.busy && !bet.noting && document.body.classList.contains('has-card') && !typingOnCard() && !pressing(); };
1512
2070
  bet.watching = setInterval(function () {
1513
2071
  if (!bet.shown || !bet.shown.watch) { stopWatch(); return; }
1514
- if (bet.busy || bet.noting) return;
1515
- api('/card').then(function (s) {
2072
+ if (bet.busy || bet.noting || document.hidden) return;
2073
+ askCard().then(function (s) {
1516
2074
  if (s.http !== 200 || !s.card || !bet.shown || !bet.shown.watch || bet.busy || bet.noting) return;
1517
2075
  if (sameCard(s.card, bet.shown)) live(s.card);
1518
2076
  else if (quiet()) { bet.busy = true; arrive(s.card); return; }
1519
- if (!quiet() || !(s.queue > 0)) return;
2077
+ if (!quiet() || !(s.queue > 0) || bet.asking) return;
1520
2078
  bet.busy = true;
1521
- api('/act', { orb: true, card_id: bet.shown.id }).catch(offline).then(function (r) {
2079
+ // one ask at a time: a slow one frees the page, and the looks bring its card.
2080
+ // One the page gives up on (a post on a dead link never answers) frees the
2081
+ // next, which carries its id while it is unanswered, so the Mac answers once
2082
+ var cardId = bet.shown.id;
2083
+ var mine = { card: cardId, id: lostReveal && lostReveal.card === cardId ? lostReveal.id : newSendId() };
2084
+ var ask = api('/act', { orb: true, card_id: cardId, send: mine.id }).catch(offline);
2085
+ bet.asking = mine;
2086
+ ask.then(function () { if (bet.asking === mine) bet.asking = null; if (lostReveal && lostReveal.id === mine.id) lostReveal = null; });
2087
+ patient(ask).then(function (r) {
2088
+ if (r.slow) { if (bet.asking === mine) bet.asking = null; lostReveal = mine; bet.busy = false; return; }
1522
2089
  if (r.ok && r.card && !sameCard(r.card, bet.shown)) { arrive(r.card); return; }
1523
2090
  bet.busy = false;
1524
2091
  if (r.ok && r.card) live(r.card);
@@ -1530,25 +2097,42 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1530
2097
  // ---- the orb ----------------------------------------------------------------------------
1531
2098
  // The big orb: the next card, with the spiral, as on page open. An idea
1532
2099
  // that landed behind the calm line is the next one, so it is never passed.
2100
+ // A tap on the big orb carries its own id, and every tap after it carries the
2101
+ // same id until a card lands on screen (draw), by any path: its answer, a look,
2102
+ // or the calm line. An answer that comes late, is cut off, or fails never frees
2103
+ // it, so a retry is answered with the card the first tap made and nothing is
2104
+ // passed. Only the tap's own answer saying there is nothing to show frees it too.
2105
+ var orbTap = null;
2106
+ // the cards the owner opens from the footer, over whatever card the Mac has
2107
+ var OPENED_HERE = ['team', 'deck', 'member', 'swap'];
1533
2108
  function think() {
1534
2109
  if (bet.busy) return;
1535
2110
  // on the calm line the dots are the orb on the card: a tap goes back to the big one
1536
2111
  if (waiting()) { goHome(); return; }
1537
2112
  buzz();
1538
2113
  bet.busy = true;
1539
- bet.tapAt = performance.now();
1540
- document.body.removeAttribute('data-reveal-ms');
2114
+ tapped();
1541
2115
  var failed = function () {
1542
2116
  bet.busy = false;
1543
2117
  orb.relax();
1544
- line.set('that did not go through. tap to try again', true);
2118
+ line.set('that did not go through. tap to try again.', true);
1545
2119
  };
1546
- api('/act', { orb: true, card_id: bet.shown ? bet.shown.id : null }).then(function (r) {
1547
- if (r.http !== 200 || !r.card) { if (r.http === 200) { bet.busy = false; orb.relax(); line.set('tap to think', false); return; } failed(); return; }
2120
+ var tap = orbTap || (orbTap = newSendId());
2121
+ var asked = api('/act', { orb: true, card_id: bet.shown ? bet.shown.id : null, send: tap });
2122
+ waitLine(asked);
2123
+ patient(asked).then(function (r) {
2124
+ if (r.slow) { stillOn(asked, function (card) { if (isWait(card)) { enterWait(card); return; } orb.play(function (phase) { if (phase === 'settled') { bet.busy = false; showCard(card, true, true); } }, true); }); return; }
2125
+ // the Mac said, whole, that it has nothing to show: that is what the tap made, so the next tap is a new one
2126
+ if (r.http !== 200 || !r.card) { if (r.http === 200) { orbTap = null; bet.busy = false; orb.relax(); line.set('tap to think', false); return; } failed(); return; }
1548
2127
  line.set('', false);
1549
2128
  // nothing ready yet: the calm line, with the sphere where it is and its dots moving
1550
2129
  if (isWait(r.card)) { enterWait(r.card); return; }
1551
- orb.play(function (phase) { if (phase === 'settled') { bet.busy = false; showCard(r.card, true, true); } }, true);
2130
+ // the big orb moves once the answer is in
2131
+ bet.waited = performance.now() - bet.tapAt;
2132
+ // the card fades up as the lava eases into its spot, or once it is there
2133
+ var up = false;
2134
+ var play = function () { orb.play(function (phase) { if (!up && (phase === 'landing' || phase === 'settled')) { up = true; showCard(r.card, true, true); } if (phase === 'settled') bet.busy = false; }, true); };
2135
+ if (fadeSphere()) setTimeout(play, 200); else play();
1552
2136
  }, failed);
1553
2137
  }
1554
2138
 
@@ -1572,6 +2156,15 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1572
2156
  if (document.body.classList.contains('has-card')) goHome(); else think();
1573
2157
  }
1574
2158
 
2159
+ // a press on the big orb that pushed its dots around (it moved more than
2160
+ // DRAG_PX before it lifted) is play, not a tap, so it thinks of nothing
2161
+ var pressAt = null, DRAG_PX = 10;
2162
+ document.getElementById('orb').addEventListener('pointerdown', function (e) { pressAt = { x: e.clientX, y: e.clientY }; });
2163
+ document.getElementById('orb').addEventListener('click', function (e) {
2164
+ if (pressAt && Math.hypot(e.clientX - pressAt.x, e.clientY - pressAt.y) > DRAG_PX) e.stopImmediatePropagation();
2165
+ pressAt = null;
2166
+ }, true);
2167
+
1575
2168
  var canvas = document.getElementById('orb');
1576
2169
  var lavaTap = document.getElementById('lava');
1577
2170
  [[canvas, think], [lavaTap, tapSmallOrb]].forEach(function (pair) {
@@ -1582,31 +2175,89 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1582
2175
  });
1583
2176
 
1584
2177
  // first look: a card already on screen shows settled, anything else is the sphere
2178
+ // the first card's fade in, ended the moment the card's own motion starts
2179
+ // (a quick flip or answer), so it never covers that motion's fades
2180
+ var openFades = [];
2181
+ function endOpening() { openFades.forEach(function (a) { a.cancel(); }); openFades = []; }
2182
+
2183
+ // until it answers the page shows nothing; then the card and its small orb
2184
+ // fade in, or the big orb starts with its line, never one over the other.
2185
+ // The card the Mac served with the page opens it at once. Else the first
2186
+ // answer to come, from the one shared ask, opens the page; with none in
2187
+ // OPEN_WAIT_MS the big orb opens, so a look that stalls never leaves the
2188
+ // page blank. A page that opened without a good look takes the card from
2189
+ // the next good one (lateLook).
2190
+ var OPEN_WAIT_MS = 2500;
2191
+ var opened = false, openTimer = null, looked = false;
1585
2192
  function firstLook() {
1586
- api('/card').then(function (s) {
1587
- if (s.http !== 200) return;
1588
- if (s.card && isWait(s.card)) { enterWait(s.card, true); return; }
1589
- if (s.card) { orb.settled(); showCard(s.card, false); return; }
1590
- line.set(s.status === 'skipped' ? 'not now. tap when you want another' : 'tap to think', false);
1591
- });
2193
+ var held = null;
2194
+ try { held = JSON.parse(document.getElementById('first-look').textContent || 'null'); } catch (e) { held = null; }
2195
+ if (held && typeof held.needs_you === 'number') drawNeeds(held.needs_you);
2196
+ if (held) { openWith(Object.assign(held, { http: 200 })); return; }
2197
+ openTimer = setTimeout(function () { openWith(null); }, OPEN_WAIT_MS);
2198
+ askCard().then(openWith, function () { /* a refresh, or the wait, opens the page */ });
2199
+ }
2200
+ function openWith(s) {
2201
+ if (opened) return;
2202
+ opened = true;
2203
+ clearTimeout(openTimer);
2204
+ var open = function () { document.body.classList.remove('opening'); };
2205
+ var fadeIn = function (n) { if (n.animate) openFades.push(n.animate([{ opacity: 0 }, { opacity: 1 }], { duration: reduced ? 200 : 300, easing: 'ease-out' })); };
2206
+ // only an answer that says what is on the Mac decides what the page shows
2207
+ if (!s || s.http !== 200 || s.ok === false) { orb.begin(); open(); return; }
2208
+ looked = true;
2209
+ if (s.card && isWait(s.card)) { enterWait(s.card, true); open(); return; }
2210
+ if (s.card) { line.clear(); orb.settled(); showCard(s.card, false); fadeIn(box()); fadeIn(document.getElementById('lava')); open(); return; }
2211
+ orb.begin();
2212
+ line.set(s.status === 'skipped' ? 'tap when you want another idea' : 'tap to think', false);
2213
+ open();
2214
+ }
2215
+ // the first good look, once the page opened on none: a card shows settled, anything else is the sphere's line
2216
+ function lateLook(s) {
2217
+ looked = true;
2218
+ if (s.card && isWait(s.card)) { enterWait(s.card, true); return; }
2219
+ if (s.card) { orb.settled(); showCard(s.card, false); return; }
2220
+ line.set(s.status === 'skipped' ? 'tap when you want another idea' : 'tap to think', false);
1592
2221
  }
1593
2222
 
1594
2223
  // ---- the footer: the team and the yeses waiting, both cards like any other ---------------
1595
2224
  // From any card, or the big orb: the Mac serves the team table or the job
1596
2225
  // deck as the next card, and its back option returns to where the owner was.
2226
+ // the team and the deck opened from the big orb close back to it; the card stays where it is on the Mac
2227
+ var teamFromHome = false;
1597
2228
  function openFrom(which) {
1598
2229
  if (bet.busy) return;
2230
+ // moving between the team pages keeps where the owner came from; opening them from anywhere else sets it
2231
+ var onTeam = document.body.classList.contains('has-card') && bet.shown && ['team', 'member', 'swap', 'deck'].indexOf(bet.shown.kind) >= 0;
2232
+ if (!onTeam) teamFromHome = !document.body.classList.contains('has-card');
1599
2233
  buzz();
1600
2234
  bet.busy = true;
1601
- bet.tapAt = performance.now();
2235
+ tapped();
1602
2236
  var answer = api('/act', { open: which, card_id: bet.shown ? bet.shown.id : null }).catch(offline);
2237
+ var late = answer;
2238
+ answer = patient(answer);
2239
+ // already on one of the team pages: the footer moves around them in place, as their way back does
2240
+ var from = bet.shown;
2241
+ if (document.body.classList.contains('has-card') && from && ['team', 'member', 'swap', 'deck'].indexOf(from.kind) >= 0) {
2242
+ whenLate(answer, late, from);
2243
+ document.body.setAttribute('data-in-place', '');
2244
+ answer.then(function (r) {
2245
+ if (r.ok && r.card && !widthJumps(r.card) && !isWait(r.card)) { turnTo(r.card); return; }
2246
+ document.body.removeAttribute('data-in-place');
2247
+ flow(Promise.resolve(r), from, null, null);
2248
+ });
2249
+ return;
2250
+ }
1603
2251
  if (!document.body.classList.contains('has-card')) {
1604
2252
  answer.then(function (r) {
1605
- if (!r.ok || !r.card) { bet.busy = false; line.set((r && r.message) || 'that did not go through. tap to try again', true); return; }
2253
+ if (r.slow) { stillOn(late, function (card) { spiralTo(card, null); }); return; }
2254
+ if (!r.ok || !r.card) { bet.busy = false; line.set((r && r.message) || 'that did not go through. tap to try again.', true); return; }
2255
+ bet.waited = performance.now() - bet.tapAt;
1606
2256
  spiralTo(r.card, null);
1607
2257
  });
1608
2258
  return;
1609
2259
  }
2260
+ whenLate(answer, late, bet.shown);
1610
2261
  flow(answer, bet.shown, null, null);
1611
2262
  }
1612
2263
  document.getElementById('working').addEventListener('click', function () { openFrom('team'); });
@@ -1633,10 +2284,55 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1633
2284
  field.blur();
1634
2285
  return;
1635
2286
  }
2287
+ var armedBtn = box().querySelector('.armed');
2288
+ if (armedBtn && armedBtn.disarm && armedBtn.disarm()) { e.preventDefault(); return; }
1636
2289
  var back = optionFor('back');
1637
2290
  if (back) { e.preventDefault(); useOption(back); }
1638
2291
  }, true);
1639
2292
 
2293
+ // Where the focus goes. A new card: when the focus left with the old card
2294
+ // (or sits on the orb), it goes to the new card's first action, never its
2295
+ // typing box, so a keyboard or a screen reader carries on from there. Back
2296
+ // on the big orb, a focus that was lost goes to the orb itself.
2297
+ function focusFree() {
2298
+ var a = document.activeElement;
2299
+ return !a || a === document.body || !a.isConnected || a.id === 'orb' || a.id === 'lava';
2300
+ }
2301
+ function landFocus() {
2302
+ if (!document.body.classList.contains('has-card') || !focusFree()) return;
2303
+ var first = box().querySelector('button:not([disabled]):not(.send)');
2304
+ if (first) first.focus({ preventScroll: true });
2305
+ }
2306
+ new MutationObserver(function () {
2307
+ var orbNode = document.getElementById('orb');
2308
+ if (document.body.classList.contains('has-card') || orbNode.style.visibility === 'hidden' || !focusFree() || document.activeElement === orbNode) return;
2309
+ orbNode.focus({ preventScroll: true });
2310
+ }).observe(document.getElementById('orb'), { attributes: true, attributeFilter: ['style'] });
2311
+
2312
+ // The phone's back button (or swipe): a card keeps one step of the
2313
+ // browser's history. Back on a card with a way back takes it, as Esc does;
2314
+ // back on any other card goes to the big orb, as the small orb does; back
2315
+ // on the big orb leaves the page, as it always did. While a motion is
2316
+ // under way nothing moves and the step stays for the next press. Chrome
2317
+ // skips a step a page adds before the first tap, so it waits for one.
2318
+ function holdBack() {
2319
+ if (!document.body.classList.contains('has-card') || (history.state && history.state.orb)) return;
2320
+ if (navigator.userActivation && !navigator.userActivation.hasBeenActive) return;
2321
+ history.pushState({ orb: 1 }, '');
2322
+ }
2323
+ new MutationObserver(function () { holdBack(); landFocus(); }).observe(box(), { attributes: true, attributeFilter: ['data-card'] });
2324
+ ['pointerdown', 'keydown'].forEach(function (type) { document.addEventListener(type, holdBack, true); });
2325
+ window.addEventListener('popstate', function () {
2326
+ if (history.state && history.state.orb) return;
2327
+ if (!document.body.classList.contains('has-card') || !bet.shown) { history.back(); return; }
2328
+ if (bet.busy || bet.noting) { holdBack(); return; }
2329
+ var field = document.activeElement;
2330
+ if (field && field.tagName === 'INPUT') { field.value = ''; field.blur(); holdBack(); return; }
2331
+ var back = optionFor('back');
2332
+ if (back) { holdBack(); useOption(back); return; }
2333
+ goHome();
2334
+ });
2335
+
1640
2336
  // the arrows and a sideways swipe flip the job deck, unless the owner is typing
1641
2337
  document.addEventListener('keydown', function (e) {
1642
2338
  var step = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
@@ -1648,7 +2344,13 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1648
2344
  useOption(o);
1649
2345
  });
1650
2346
  var swipe = null;
1651
- box().addEventListener('touchstart', function (e) { swipe = { x: e.touches[0].clientX, y: e.touches[0].clientY }; }, { passive: true });
2347
+ // a drag inside the wide table scrolls it, and one in a box moves the cursor: neither flips
2348
+ function swipeFrom(e) {
2349
+ var t = e.composedPath()[0], grid = t.closest && t.closest('.grid');
2350
+ if ((grid && grid.scrollWidth > grid.clientWidth + 1) || (t.closest && t.closest('.field'))) return null;
2351
+ return { x: e.touches[0].clientX, y: e.touches[0].clientY };
2352
+ }
2353
+ box().addEventListener('touchstart', function (e) { swipe = swipeFrom(e); }, { passive: true });
1652
2354
  box().addEventListener('touchend', function (e) {
1653
2355
  if (!swipe) return;
1654
2356
  var dx = e.changedTouches[0].clientX - swipe.x, dy = e.changedTouches[0].clientY - swipe.y;
@@ -1666,20 +2368,68 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1666
2368
  // Mac's card has changed (a new id: its options are not the ones shown),
1667
2369
  // it turns in place, or grows out of the dots on the calm line, unless the
1668
2370
  // owner is typing or a motion is under way.
2371
+ var deadLink = false;
1669
2372
  function refresh() {
1670
- return api('/card').then(function (s) {
1671
- if (s.http !== 200) { line.set('this link stopped working. start gm phone again on the mac.', true); return; }
2373
+ if (document.hidden) return Promise.resolve();
2374
+ return askCard().then(function (s) {
2375
+ // the first answer to come, from here or the first look, opens the page
2376
+ openWith(s);
2377
+ // only a refused link is a dead one; a Mac that errs or is starting again says nothing
2378
+ if (s.http !== 200 && s.http !== 403) { unanswered(); return; }
2379
+ answered();
2380
+ if (s.http === 403) deadLink = true;
2381
+ if (s.http !== 200) { line.set('this link stopped working. open a new one from the mac.', true); return; }
1672
2382
  drawWorking(s.team_working || 0);
2383
+ if (deadLink) { deadLink = false; if (!bet.busy && !document.body.classList.contains('has-card')) line.set('tap to think', false); }
2384
+ if (!looked && s.ok !== false && !bet.busy && !bet.shown && !document.body.classList.contains('has-card')) { lateLook(s); return; }
2385
+ if (pressing()) return;
1673
2386
  if (s.card && bet.shown && !sameCard(s.card, bet.shown) && !bet.busy && !bet.noting && !typingOnCard() && document.body.classList.contains('has-card')) {
1674
2387
  bet.busy = true;
1675
2388
  arrive(s.card);
1676
2389
  }
1677
- }).catch(function () { /* the next refresh tries again */ });
2390
+ }).catch(function () { unanswered(); /* the next refresh tries again */ });
2391
+ }
2392
+
2393
+ // The Mac stopped answering (asleep, or its server quit): two looks in a
2394
+ // row that get nothing, about ten seconds, and one quiet line says so, in
2395
+ // the card when one is up, else under the big orb. While it is down the
2396
+ // page asks every DOWN_LOOK_MS instead of every LOOK_MS, and the first
2397
+ // good look takes the line away.
2398
+ var LOOK_MS = 5000, DOWN_LOOK_MS = 15000, QUIET = 'the mac is not answering. still trying.';
2399
+ var missed = 0;
2400
+ function quietNote() { return document.body.classList.contains('has-card') ? box().querySelector('.act .note') : null; }
2401
+ function unanswered() {
2402
+ missed += 1;
2403
+ if (missed !== 2) return;
2404
+ var note = quietNote();
2405
+ if (note) { if (!note.textContent) note.textContent = QUIET; }
2406
+ else if (!bet.busy) line.set(QUIET, true);
1678
2407
  }
2408
+ function answered() {
2409
+ if (missed < 2) { missed = 0; return; }
2410
+ missed = 0;
2411
+ var note = quietNote();
2412
+ if (note && note.textContent === QUIET) note.textContent = '';
2413
+ if (!document.body.classList.contains('has-card') && !bet.busy && document.getElementById('line').textContent === QUIET) line.set('tap to think', false);
2414
+ }
2415
+ function lookAgain() { setTimeout(function () { refresh().then(lookAgain, lookAgain); }, missed >= 2 ? DOWN_LOOK_MS : LOOK_MS); }
2416
+ // the phone was locked, the tab was away, or the wifi came back: a look
2417
+ // still waiting on the old link is called off and a fresh one goes at
2418
+ // once, so a tap never lands on a card the Mac has passed. In the
2419
+ // background the page does no work: the orb draws nothing and no look
2420
+ // goes to the Mac (refresh and the calm line's watch wait while hidden).
2421
+ var lookFresh = function () { dropAsk(); refresh(); };
2422
+ document.addEventListener('visibilitychange', function () {
2423
+ if (document.hidden) { orb.pause(); return; }
2424
+ orb.resume();
2425
+ lookFresh();
2426
+ });
2427
+ window.addEventListener('pageshow', function (e) { if (e.persisted) lookFresh(); });
2428
+ window.addEventListener('online', lookFresh);
1679
2429
 
1680
2430
  firstLook();
1681
2431
  refresh();
1682
- setInterval(refresh, 5000);
2432
+ lookAgain();
1683
2433
  })();
1684
2434
  </script>
1685
2435
  </body>