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