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.
- package/atris/atris.md +78 -17
- package/atris.md +1 -10
- package/commands/auth.js +4 -3
- package/commands/computer.js +9 -7
- package/commands/context-sync.js +2 -1
- package/commands/integrations.js +2 -1
- package/commands/member.js +3 -3
- package/commands/ninety-nine.js +90 -30
- package/commands/orb.js +1 -1
- package/commands/pull.js +3 -3
- package/commands/push.js +2 -2
- package/commands/skill.js +5 -0
- package/commands/soul.js +2 -1
- package/commands/stream.js +3 -2
- package/commands/strings.js +1 -1
- package/commands/unknowns.js +1 -1
- package/commands/workflow.js +1 -1
- package/commands/zen.js +4 -3
- package/lib/auto-accept-certified.js +2 -3
- package/lib/autoland.js +4 -3
- package/lib/campaign-loop.js +12 -4
- package/lib/card.js +1 -1
- package/lib/chat-log-scan.js +1 -1
- package/lib/first-minute.js +1 -0
- package/lib/gm-bet.js +51 -48
- package/lib/gm-card.js +46 -19
- package/lib/gm-phone-icon.js +80 -0
- package/lib/gm-phone-page.js +803 -96
- package/lib/gm-phone.js +15 -4
- package/lib/journal.js +1 -1
- package/lib/noninteractive.js +5 -1
- package/lib/roster-swap.js +8 -3
- package/lib/task-decision.js +1 -1
- package/lib/values.js +25 -10
- package/lib/zen-apply.js +23 -2
- package/lib/zen.js +99 -4
- package/package.json +1 -1
- package/utils/auth.js +69 -7
package/lib/gm-phone-page.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
light.
|
|
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.
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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 *=
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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 = (
|
|
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
|
-
|
|
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 ? '
|
|
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.
|
|
1163
|
-
stage.style.opacity = '0';
|
|
1164
|
-
|
|
1165
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1714
|
-
|
|
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
|
-
|
|
2432
|
+
lookAgain();
|
|
1726
2433
|
})();
|
|
1727
2434
|
</script>
|
|
1728
2435
|
</body>
|