atris 3.74.9 → 3.74.11

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
@@ -374,6 +513,14 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
374
513
  var count = window.innerWidth < 768 ? 5500 : 11000;
375
514
  var w = 0, h = 0, R = 0, cx = 0, cy = 0;
376
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);
377
524
  // idle: the sphere drifts. pull: the dots spiral in by age. card: still.
378
525
  var mode = 'idle';
379
526
  var angle = 0;
@@ -410,6 +557,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
410
557
  // random points on a sphere (normalised gaussians), so it reads as stipple
411
558
  var gauss = function () { return Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random()); };
412
559
  dots = [];
560
+ px = new Float32Array(count); py = new Float32Array(count); pr = new Float32Array(count); pb = new Int16Array(count); order = new Int32Array(count);
413
561
  for (var i = 0; i < count; i++) {
414
562
  var gx = gauss(), gy = gauss(), gz = gauss();
415
563
  var n = Math.hypot(gx, gy, gz) || 1;
@@ -439,8 +587,15 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
439
587
  }
440
588
 
441
589
  function frame(now) {
442
- 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;
443
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);
444
599
  ctx.clearRect(0, 0, w, h);
445
600
  var swarming = mode === 'swarm' && !reduced;
446
601
  var toward = 1 - Math.pow(1 - SWARM_EASE, dt * 60);
@@ -449,8 +604,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
449
604
  clock += dt;
450
605
  if (!reduced && (mode === 'idle' || mode === 'swarm')) angle += dt * spin;
451
606
  wave.age += dt;
452
- light.x += (light.tx - light.x) * 0.06;
453
- 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;
454
610
  lz = Math.sqrt(Math.max(0, 1 - light.x * light.x - light.y * light.y));
455
611
  var r2 = RADIUS * RADIUS;
456
612
  var waveFront = wave.age * WAVE_SPEED;
@@ -460,10 +616,11 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
460
616
  // ready: the age runs on the clock, as on the homepage. Waiting: it
461
617
  // follows the real progress and stops short of the point.
462
618
  if (timed) age += dt / pace;
463
- else age += (ageTo - age) * 0.05;
619
+ else age += (ageTo - age) * (1 - Math.pow(0.95, k));
464
620
  }
465
621
  var cosA = Math.cos(angle), sinA = Math.sin(angle);
466
622
  var most = 0, sum = 0, wander = WANDER * R * swarmK;
623
+ counts.fill(0);
467
624
  for (var i = 0; i < dots.length; i++) {
468
625
  var d = dots[i];
469
626
  var fade = 1, shrink = 1;
@@ -492,14 +649,16 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
492
649
  if (dist2 < r2 && dist2 > 0.01) {
493
650
  var dist = Math.sqrt(dist2);
494
651
  var f = (1 - dist / RADIUS) * PUSH;
495
- d.vx += (dx / dist) * f * 0.2;
496
- 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;
497
654
  }
498
655
  }
499
- d.vx += (homeX - d.sx) * SPRING;
500
- d.vy += (homeY - d.sy) * SPRING;
501
- d.vx *= DAMP; d.vy *= DAMP;
502
- 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
+ }
503
662
  if (reduced) { d.sx = cx + rx * R; d.sy = cy + d.y * R; }
504
663
  } else {
505
664
  var t = Math.min(1, Math.max(0, (age - d.dl) / 0.75));
@@ -507,7 +666,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
507
666
  var ox = (d.bx - cx) * (1 - e), oy = (d.by - cy) * (1 - e);
508
667
  var turn = e * 1.5;
509
668
  // the speed it had carries on and fades, then the spiral has it
510
- 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;
511
670
  d.sx = cx + ox * Math.cos(turn) - oy * Math.sin(turn) + d.kx * (1 - e);
512
671
  d.sy = cy + ox * Math.sin(turn) + oy * Math.cos(turn) + d.ky * (1 - e);
513
672
  fade = 1 - Math.pow(Math.max(0, (e - 0.8) / 0.2), 1.5);
@@ -519,11 +678,24 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
519
678
  var front = rz > 0;
520
679
  var l = Math.max(0, lit);
521
680
  var a = (front ? 0.16 + Math.pow(l, 1.3) * 0.84 : 0.05 + l * 0.08) * fade;
522
- if (a <= 0.003) continue;
523
- ctx.fillStyle = l > 0.93 ? 'rgba(253, 196, 84, ' + a.toFixed(3) + ')' : 'rgba(251, 130, 36, ' + a.toFixed(3) + ')';
524
- ctx.beginPath();
525
- ctx.arc(d.sx, d.sy, d.r * (front ? 0.6 + l * 0.7 : 0.6) * shrink, 0, Math.PI * 2);
526
- 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;
527
699
  }
528
700
  stats.frame += 1; stats.mode = mode; stats.step = most; stats.mean = sum / Math.min(64, dots.length || 1); stats.ms = dt * 1000;
529
701
  if (mode === 'pull') {
@@ -580,7 +752,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
580
752
  var settleMs = quick ? ACT_SETTLE_MS : contained ? FAST_SETTLE_MS : (SETTLE_MS * pace);
581
753
  // after an act the card comes up at the bloom's peak; any other card once the lava has settled
582
754
  planned('bloom', growMs);
583
- if (!quick) planned('settle', settleMs);
755
+ if (!quick) planned('settle', contained ? settleMs * LAND : settleMs);
584
756
  // the point the dots drew, where the sphere is on screen (smaller and higher on the calm line)
585
757
  var vx = rect.left + rect.width / 2, vy = rect.top + rect.height / 2;
586
758
  var bloom = quick ? ACT_BLOOM : BLOOM;
@@ -637,7 +809,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
637
809
  function rise(growMs, settleMs, sMin, sMax, rest) {
638
810
  var total = growMs + settleMs, k = growMs / total;
639
811
  var p = restPoint();
640
- var covered = false;
812
+ var covered = false, landed = false, landAt = k + LAND * (1 - k);
641
813
  var t0 = performance.now();
642
814
  var step = function (now) {
643
815
  var u = Math.min(1, (now - t0) / total);
@@ -655,6 +827,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
655
827
  canvas.style.visibility = 'hidden';
656
828
  if (onBorn) onBorn('cover');
657
829
  }
830
+ if (covered && !landed && u >= landAt) { landed = true; if (onBorn) onBorn('landing'); }
658
831
  if (u < 1) { requestAnimationFrame(step); return; }
659
832
  settled();
660
833
  };
@@ -670,6 +843,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
670
843
  // at rest it scrolls with the card
671
844
  lava.style.position = 'absolute';
672
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');
673
849
  lava.style.visibility = 'visible';
674
850
  lava.style.opacity = '1';
675
851
  core.style.opacity = '0';
@@ -734,7 +910,6 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
734
910
 
735
911
  measure();
736
912
  seed();
737
- start();
738
913
 
739
914
  // StippleOrb's pointer, ported with trackWindow on, as LetterOrb uses it.
740
915
  // Over the sphere the dots part around the pointer and spring back; the
@@ -766,6 +941,27 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
766
941
  }
767
942
  window.addEventListener('pointermove', lightFrom);
768
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
+
769
965
  return {
770
966
  // the bet is ready: the homepage tap, on the clock
771
967
  // act: the owner acted on a card, so it is the one quick motion
@@ -818,7 +1014,12 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
818
1014
  leaveMs: reduced ? 200 : LEAVE_MS,
819
1015
  // a bet is already up when the page loads: the settled orb, no motion
820
1016
  settled: function () { stop(); onBorn = null; settled(); },
1017
+ // the first look found no card: the big orb starts
1018
+ begin: start,
821
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(); },
822
1023
  };
823
1024
  })();
824
1025
 
@@ -840,11 +1041,58 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
840
1041
  // what turns in place instead of the spiral: flipping the deck, more
841
1042
  // models, and who a swap is for are the same card moving, not an answer
842
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 });
843
1051
 
844
1052
  function buzz() { if (navigator.vibrate) { try { navigator.vibrate(10); } catch (e) { /* not on this device */ } } }
845
1053
 
846
1054
  // a failed fetch reads like a refusal, so nothing ever hangs
847
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); }
848
1096
 
849
1097
  function box() { return document.getElementById('bet'); }
850
1098
 
@@ -852,12 +1100,26 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
852
1100
  function draw(card) {
853
1101
  var b = box();
854
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;
855
1107
  b.setAttribute('data-card', card.id);
856
1108
  b.setAttribute('data-kind', card.kind);
857
1109
  document.body.classList.toggle('wide', Boolean(card.table));
858
- 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
+ }
859
1118
  if (card.label) b.appendChild(el('span', 'label ' + card.label.replace(/ /g, '-'), card.label));
860
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');
861
1123
  // a reply of more than one line reads at the body size, in the same spot
862
1124
  if (card.kind === 'reply') {
863
1125
  var lines = document.createRange();
@@ -873,14 +1135,22 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
873
1135
  var thread = b.appendChild(el('div', 'thread'));
874
1136
  card.thread.forEach(function (t) { thread.appendChild(el('p', 'turn ' + (t.who === 'owner' ? 'you' : 'orb'), t.text)); });
875
1137
  }
876
- // who it is and what they do; the rating and the model live on the team table
877
- 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(' · ')));
878
1141
  var inCells = card.options.filter(function (o) { return o.cell; });
879
- 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
+ }
880
1150
  if (card.time) b.appendChild(el('span', 'time', card.time));
881
- if (card.line) b.appendChild(el('p', 'mission', card.line));
1151
+ if (card.line && !news) b.appendChild(el('p', 'mission', card.line));
882
1152
  // the options in the card's own order; quiet ones next to each other share one line
883
- 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; });
884
1154
  var quiet = function (o) { return !o.style || o.style === 'quiet'; };
885
1155
  var act = el('div', 'act' + (rest.some(function (o) { return o.style === 'choice' || o.style === 'option'; }) ? ' answers' : ''));
886
1156
  var links = null;
@@ -888,7 +1158,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
888
1158
  rest.forEach(function (o) {
889
1159
  if (!quiet(o)) { links = null; act.appendChild(optionButton(o)); return; }
890
1160
  // a group of quiet options shares one line, headed by its words
891
- if (links && (o.group || null) === group) links.appendChild(document.createTextNode(' · '));
1161
+ if (links && (o.group || null) === group) links.appendChild(el('span', 'sep', ' · '));
892
1162
  else {
893
1163
  links = act.appendChild(el('div', 'links'));
894
1164
  group = o.group || null;
@@ -897,11 +1167,74 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
897
1167
  links.appendChild(optionButton(o));
898
1168
  });
899
1169
  // a quiet line under the options, empty until something does not go through
900
- 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');
901
1172
  b.appendChild(act);
1173
+ breakLinks(act);
902
1174
  if (card.input) b.appendChild(inputLine(card.input));
903
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
+ });
904
1236
  }
1237
+ window.addEventListener('resize', function () { keepPairs(box()); });
905
1238
 
906
1239
  // a table: one row per line, a line between every cell; an option with a
907
1240
  // cell is drawn in that cell, and a tap anywhere on its row opens the
@@ -920,17 +1253,35 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
920
1253
  row.forEach(function (cell, j) {
921
1254
  var col = t.columns[j];
922
1255
  var td = tr.appendChild(el('td', col + (col === 'status' && t.status ? ' ' + String(t.status[i] || '').replace(/ /g, '-') : '')));
1256
+ td.setAttribute('data-col', col);
923
1257
  var o = mine.filter(function (x) { return x.cell.column === col; })[0];
924
1258
  if (o) {
925
1259
  var btn = td.appendChild(optionButton(o, true));
926
1260
  btn.addEventListener('click', function (e) { e.stopPropagation(); });
927
1261
  } else td.textContent = cell;
928
1262
  });
929
- 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); });
930
1265
  });
931
1266
  return grid;
932
1267
  }
933
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
+
934
1285
  // One option, as the card says: big or quiet, a second tap when it asks
935
1286
  // for one, a box when it takes words. bare: no class of its own (in a cell).
936
1287
  function optionButton(o, bare) {
@@ -946,13 +1297,21 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
946
1297
  if (o.type) btn.classList.add('other');
947
1298
  if (o.action === 'none') { btn.disabled = true; return btn; }
948
1299
  if (o.confirm) {
949
- // the first tap arms this one button; the second sends; it goes quiet after five seconds
950
- 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
+ };
951
1309
  btn.addEventListener('click', function () {
952
1310
  if (bet.busy) return;
1311
+ if (armed && performance.now() - armedAt < ARM_GAP_MS) return;
953
1312
  buzz();
954
1313
  if (!armed) {
955
- armed = true; btn.textContent = o.confirm; btn.classList.add('armed');
1314
+ armed = true; btn.textContent = o.confirm; btn.classList.add('armed'); armedAt = performance.now();
956
1315
  quiet = setTimeout(function () { armed = false; btn.textContent = o.label; btn.classList.remove('armed'); }, 5000);
957
1316
  return;
958
1317
  }
@@ -989,7 +1348,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
989
1348
  // ---- answering in place: the eyes stay in one spot ---------------------------------
990
1349
  // every row of the card dims but the one in use; no spot, none
991
1350
  function dimOthers(spot) {
992
- box().querySelectorAll('.act > *, .act .links > button, .sayline, .grid').forEach(function (n) {
1351
+ box().querySelectorAll('.act > *, .act .links > button, .sayline, .grid, .deckhead').forEach(function (n) {
993
1352
  n.classList.toggle('muted', Boolean(spot) && n !== spot && !n.contains(spot) && !spot.contains(n));
994
1353
  });
995
1354
  }
@@ -1017,6 +1376,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1017
1376
  // the words to sent(text, field). Left empty, it turns back into the button.
1018
1377
  // stay: the calm line's box, open from the start without the cursor, and
1019
1378
  // open after every send.
1379
+ var FULL = 'that is as much as fits in the box.';
1020
1380
  function typeHere(btn, placeholder, maxLength, sent, stay) {
1021
1381
  var field = el('div', btn.classList.contains('choice') ? 'choice field' : 'field');
1022
1382
  // its exact height, to the subpixel, so nothing under it moves
@@ -1036,13 +1396,29 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1036
1396
  // a press on send keeps the cursor in the field, so it never closes first
1037
1397
  go.addEventListener('mousedown', function (e) { e.preventDefault(); });
1038
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
+ });
1039
1406
  input.addEventListener('keydown', function (e) {
1040
- 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(); }
1041
1409
  });
1042
- input.addEventListener('blur', function () {
1043
- if (stay || input.value.trim() || field.classList.contains('chosen') || !field.isConnected) return;
1044
- field.replaceWith(btn);
1045
- 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);
1046
1422
  });
1047
1423
  field.appendChild(input);
1048
1424
  field.appendChild(go);
@@ -1059,12 +1435,15 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1059
1435
  // sent twice and the same words to someone else always go. getRandomValues
1060
1436
  // works on the plain http of the wifi link, where randomUUID does not.
1061
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
+ }
1062
1443
  function sendId(key) {
1063
1444
  if (sent.key !== key) {
1064
- var n = new Uint32Array(2);
1065
- crypto.getRandomValues(n);
1066
1445
  sent.key = key;
1067
- sent.id = ('000000' + n[0].toString(36)).slice(-7) + ('000000' + n[1].toString(36)).slice(-7);
1446
+ sent.id = newSendId();
1068
1447
  }
1069
1448
  return sent.id;
1070
1449
  }
@@ -1082,12 +1461,31 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1082
1461
  var from = bet.shown;
1083
1462
  body.card_id = from.id;
1084
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(' | '));
1085
1467
  var heldAt = hold(spot);
1086
1468
  var answer = api('/act', body).catch(offline);
1087
- 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)) {
1088
1476
  document.body.setAttribute('data-in-place', '');
1089
1477
  Promise.all([answer, afterHold(heldAt)]).then(function (both) {
1090
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; }
1091
1489
  if (!r.ok || !r.card) { bet.busy = false; document.body.removeAttribute('data-in-place'); unsent(from, r, body.text, option); return; }
1092
1490
  turnTo(r.card);
1093
1491
  });
@@ -1096,7 +1494,19 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1096
1494
  afterHold(heldAt).then(function () { flow(answer, from, body.text, option); });
1097
1495
  }
1098
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
+
1099
1508
  function flow(answer, from, typed, option) {
1509
+ waitLine(answer);
1100
1510
  var landed = null;
1101
1511
  var sphere = false;
1102
1512
  var lineAt = 0, waitFrom = 0;
@@ -1111,11 +1521,14 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1111
1521
  if (wait) planned('line', wait);
1112
1522
  setTimeout(function () {
1113
1523
  // nothing more to show: the sphere stays, with its line, and its dots settle into the idle drift
1114
- 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; }
1115
1528
  // the card the Mac has on screen now is the one that comes back; a
1116
1529
  // refusal comes back on it with one line why
1117
1530
  var then = r.card || from;
1118
- 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; }
1119
1532
  if (!r.ok) { spiralTo(then, function () { unsent(then, r, typed, then.id === (from && from.id) ? option : null); }); return; }
1120
1533
  spiralTo(then, null);
1121
1534
  }, wait);
@@ -1132,6 +1545,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1132
1545
  // sphere; the card leaves the page once it has faded all the way. From the
1133
1546
  // calm line the sphere is already there: its words fade and it glides home.
1134
1547
  function leave(then, fast) {
1548
+ endOpening();
1135
1549
  if (waiting()) { leaveWait(then, fast); return; }
1136
1550
  var b = box();
1137
1551
  var to = sphereSpot();
@@ -1143,6 +1557,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1143
1557
  b.hidden = true;
1144
1558
  b.classList.remove('leave', 'fast', 'holding');
1145
1559
  document.body.classList.remove('has-card', 'wide');
1560
+ line.appear();
1146
1561
  window.scrollTo(0, 0);
1147
1562
  };
1148
1563
  // reduced motion: no travel; the card and the small orb fade, then the sphere is there
@@ -1156,14 +1571,27 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1156
1571
  }
1157
1572
 
1158
1573
  // under reduced motion the sphere never travels: it fades in where it now sits
1574
+ // and its line fades in with it
1159
1575
  function stageFade() {
1160
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() {
1161
1590
  var stage = document.getElementById('stage');
1162
- stage.style.transition = 'none';
1163
- stage.style.opacity = '0';
1164
- void stage.offsetWidth;
1165
- stage.style.transition = 'opacity 0.2s linear';
1166
- 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;
1167
1595
  }
1168
1596
 
1169
1597
  // from the sphere: the dots spiral in and the card grows out of the point.
@@ -1172,7 +1600,11 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1172
1600
  line.set('', false);
1173
1601
  if (isWait(card)) { enterWait(card); if (after) after(); return; }
1174
1602
  // reduced motion: no spiral; the card fades in where it goes
1175
- 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
+ }
1176
1608
  orb.play(function (phase) {
1177
1609
  // the card blooms where the dots land; what comes with it (a line why, the words back in their box) comes with it
1178
1610
  if (phase === 'cover') { showCard(card, true, true, true); if (after) after(); }
@@ -1249,7 +1681,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1249
1681
  draw(card);
1250
1682
  if (card.watch) watchWait(); else stopWatch();
1251
1683
  if (!animate) { measured(); return; }
1252
- 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(); } };
1253
1687
  b.addEventListener('transitionend', done);
1254
1688
  void b.offsetWidth;
1255
1689
  requestAnimationFrame(function () { b.classList.add('up'); planned('fade', fadeMs(b)); });
@@ -1278,7 +1712,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1278
1712
  var screenTop = document.getElementById('screen').getBoundingClientRect().top;
1279
1713
  var h = stage.offsetHeight || 1, top0 = screenTop + stage.offsetTop;
1280
1714
  if (hidden) body.classList.remove('waiting');
1281
- 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);
1282
1718
  var top = Math.max(24, (window.innerHeight - (k * h + WAIT_ROOM)) / 2);
1283
1719
  var s = body.style;
1284
1720
  s.setProperty('--wait-k', k.toFixed(4));
@@ -1289,9 +1725,34 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1289
1725
  requestAnimationFrame(function () { requestAnimationFrame(function () { stage.style.transition = ''; }); });
1290
1726
  }
1291
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
+
1292
1738
  // a turned phone: the calm line's sphere and card find their place again
1293
1739
  window.addEventListener('resize', function () { if (waiting()) placeWait(false); });
1294
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
+
1295
1756
  // atOnce: the page opened on it, so it is simply there
1296
1757
  function enterWait(card, atOnce) {
1297
1758
  var b = box();
@@ -1325,7 +1786,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1325
1786
  bet.shown = card;
1326
1787
  b.setAttribute('data-card', card.id);
1327
1788
  var h = b.querySelector('h1');
1328
- if (h.textContent !== card.title) h.textContent = card.title;
1789
+ if (h.textContent !== card.title) { h.textContent = card.title; keepPairs(b); }
1329
1790
  var lines = Array.isArray(card.progress) ? card.progress : [];
1330
1791
  var newest = lines.length ? lines[lines.length - 1] : null;
1331
1792
  stepTo(step, newest && typeof newest.line === 'string' ? newest.line : '', atOnce);
@@ -1407,10 +1868,17 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1407
1868
  bet.noting = true;
1408
1869
  field.classList.add('chosen');
1409
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);
1410
1874
  var note = box().querySelector('.act .note');
1411
1875
  if (note) note.textContent = '';
1412
- api('/act', body).catch(offline).then(function (r) {
1876
+ var told = api('/act', body).catch(offline);
1877
+ patient(told).then(function (r) {
1413
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); });
1414
1882
  field.classList.remove('chosen');
1415
1883
  input.readOnly = false;
1416
1884
  // the owner went back to the big orb meanwhile: the Mac has the note, and nothing here moves
@@ -1430,6 +1898,8 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1430
1898
  if (!fast) orb.calm();
1431
1899
  b.classList.remove('wait-in');
1432
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();
1433
1903
  setTimeout(function () {
1434
1904
  b.hidden = true;
1435
1905
  b.classList.remove('leave', 'fast', 'holding', 'up');
@@ -1454,6 +1924,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1454
1924
  document.getElementById('orb').setAttribute('aria-label', 'the atris orb. tap to think of one new idea for the business');
1455
1925
  };
1456
1926
  if (reduced) {
1927
+ fadeSphere();
1457
1928
  setTimeout(function () { off(); orb.settled(); showCard(card, true); bet.busy = false; if (after) after(); }, 200);
1458
1929
  return;
1459
1930
  }
@@ -1491,16 +1962,37 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1491
1962
  // TURN_IN_MS, starting 80ms in, so its title lands where the old title
1492
1963
  // was. The box eases to its new height. No layout jump.
1493
1964
  var TURN_IN_MS = 320, DRIFT = 4;
1494
- 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) {
1495
1967
  var b = box();
1496
1968
  b.classList.remove('holding');
1969
+ var otherPlace = b.getAttribute('data-kind') !== card.kind;
1497
1970
  bet.shown = card;
1498
1971
  document.body.setAttribute('data-in-place', '');
1499
- 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');
1500
1982
  }
1501
1983
 
1502
1984
  function turnInPlace(b, paint, after) {
1503
- 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
+ }
1504
1996
  var from = b.offsetHeight;
1505
1997
  b.style.height = from + 'px';
1506
1998
  b.style.minHeight = '';
@@ -1542,24 +2034,58 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1542
2034
  // waits while the owner types there (the box has the cursor or any words)
1543
2035
  // or a note is on its way, and on the big orb it waits for their tap.
1544
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
+ }
1545
2053
  function typingOnCard() {
1546
2054
  var input = box().querySelector('.field input');
1547
2055
  return Boolean(input && (input === document.activeElement || input.value.trim()));
1548
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; }
1549
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;
1550
2067
  function watchWait() {
1551
2068
  if (bet.watching) return;
1552
- 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(); };
1553
2070
  bet.watching = setInterval(function () {
1554
2071
  if (!bet.shown || !bet.shown.watch) { stopWatch(); return; }
1555
- if (bet.busy || bet.noting) return;
1556
- api('/card').then(function (s) {
2072
+ if (bet.busy || bet.noting || document.hidden) return;
2073
+ askCard().then(function (s) {
1557
2074
  if (s.http !== 200 || !s.card || !bet.shown || !bet.shown.watch || bet.busy || bet.noting) return;
1558
2075
  if (sameCard(s.card, bet.shown)) live(s.card);
1559
2076
  else if (quiet()) { bet.busy = true; arrive(s.card); return; }
1560
- if (!quiet() || !(s.queue > 0)) return;
2077
+ if (!quiet() || !(s.queue > 0) || bet.asking) return;
1561
2078
  bet.busy = true;
1562
- 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; }
1563
2089
  if (r.ok && r.card && !sameCard(r.card, bet.shown)) { arrive(r.card); return; }
1564
2090
  bet.busy = false;
1565
2091
  if (r.ok && r.card) live(r.card);
@@ -1571,6 +2097,14 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1571
2097
  // ---- the orb ----------------------------------------------------------------------------
1572
2098
  // The big orb: the next card, with the spiral, as on page open. An idea
1573
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'];
1574
2108
  function think() {
1575
2109
  if (bet.busy) return;
1576
2110
  // on the calm line the dots are the orb on the card: a tap goes back to the big one
@@ -1581,16 +2115,24 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1581
2115
  var failed = function () {
1582
2116
  bet.busy = false;
1583
2117
  orb.relax();
1584
- 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);
1585
2119
  };
1586
- api('/act', { orb: true, card_id: bet.shown ? bet.shown.id : null }).then(function (r) {
1587
- 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; }
1588
2127
  line.set('', false);
1589
2128
  // nothing ready yet: the calm line, with the sphere where it is and its dots moving
1590
2129
  if (isWait(r.card)) { enterWait(r.card); return; }
1591
2130
  // the big orb moves once the answer is in
1592
2131
  bet.waited = performance.now() - bet.tapAt;
1593
- orb.play(function (phase) { if (phase === 'settled') { bet.busy = false; showCard(r.card, true, true); } }, true);
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();
1594
2136
  }, failed);
1595
2137
  }
1596
2138
 
@@ -1614,6 +2156,15 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1614
2156
  if (document.body.classList.contains('has-card')) goHome(); else think();
1615
2157
  }
1616
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
+
1617
2168
  var canvas = document.getElementById('orb');
1618
2169
  var lavaTap = document.getElementById('lava');
1619
2170
  [[canvas, think], [lavaTap, tapSmallOrb]].forEach(function (pair) {
@@ -1624,32 +2175,89 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1624
2175
  });
1625
2176
 
1626
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;
1627
2192
  function firstLook() {
1628
- api('/card').then(function (s) {
1629
- if (s.http !== 200) return;
1630
- if (s.card && isWait(s.card)) { enterWait(s.card, true); return; }
1631
- if (s.card) { orb.settled(); showCard(s.card, false); return; }
1632
- line.set(s.status === 'skipped' ? 'not now. tap when you want another' : 'tap to think', false);
1633
- });
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);
1634
2221
  }
1635
2222
 
1636
2223
  // ---- the footer: the team and the yeses waiting, both cards like any other ---------------
1637
2224
  // From any card, or the big orb: the Mac serves the team table or the job
1638
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;
1639
2228
  function openFrom(which) {
1640
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');
1641
2233
  buzz();
1642
2234
  bet.busy = true;
1643
2235
  tapped();
1644
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
+ }
1645
2251
  if (!document.body.classList.contains('has-card')) {
1646
2252
  answer.then(function (r) {
1647
- 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; }
1648
2255
  bet.waited = performance.now() - bet.tapAt;
1649
2256
  spiralTo(r.card, null);
1650
2257
  });
1651
2258
  return;
1652
2259
  }
2260
+ whenLate(answer, late, bet.shown);
1653
2261
  flow(answer, bet.shown, null, null);
1654
2262
  }
1655
2263
  document.getElementById('working').addEventListener('click', function () { openFrom('team'); });
@@ -1676,10 +2284,55 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1676
2284
  field.blur();
1677
2285
  return;
1678
2286
  }
2287
+ var armedBtn = box().querySelector('.armed');
2288
+ if (armedBtn && armedBtn.disarm && armedBtn.disarm()) { e.preventDefault(); return; }
1679
2289
  var back = optionFor('back');
1680
2290
  if (back) { e.preventDefault(); useOption(back); }
1681
2291
  }, true);
1682
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
+
1683
2336
  // the arrows and a sideways swipe flip the job deck, unless the owner is typing
1684
2337
  document.addEventListener('keydown', function (e) {
1685
2338
  var step = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
@@ -1691,7 +2344,13 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1691
2344
  useOption(o);
1692
2345
  });
1693
2346
  var swipe = null;
1694
- 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 });
1695
2354
  box().addEventListener('touchend', function (e) {
1696
2355
  if (!swipe) return;
1697
2356
  var dx = e.changedTouches[0].clientX - swipe.x, dy = e.changedTouches[0].clientY - swipe.y;
@@ -1709,20 +2368,68 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1709
2368
  // Mac's card has changed (a new id: its options are not the ones shown),
1710
2369
  // it turns in place, or grows out of the dots on the calm line, unless the
1711
2370
  // owner is typing or a motion is under way.
2371
+ var deadLink = false;
1712
2372
  function refresh() {
1713
- return api('/card').then(function (s) {
1714
- 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; }
1715
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;
1716
2386
  if (s.card && bet.shown && !sameCard(s.card, bet.shown) && !bet.busy && !bet.noting && !typingOnCard() && document.body.classList.contains('has-card')) {
1717
2387
  bet.busy = true;
1718
2388
  arrive(s.card);
1719
2389
  }
1720
- }).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);
1721
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);
1722
2429
 
1723
2430
  firstLook();
1724
2431
  refresh();
1725
- setInterval(refresh, 5000);
2432
+ lookAgain();
1726
2433
  })();
1727
2434
  </script>
1728
2435
  </body>