Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/apply.html

29.2 KiB, 1 run

created by r2519314175:1035, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1<!DOCTYPE html>
2<html lang="en-GB" data-theme="dark" data-tone="dark" data-ink="light">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6<title>Apply to test Daimond</title>
7<meta name="description" content="Ask for a Daimond beta passcode, or leave your address for the day the beta opens.">
8
9<!-- WHY THERE IS A FORM AT ALL. The landing page's beta buttons used to be
10 `mailto:` links, which means a literal address in the markup of a public
11 page. That is scraped within days and cannot be unpublished. The buttons
12 lead here instead, and an applicant lands as a row in the operator console
13 rather than in a mailbox the gateway cannot see.
14
15 WHY IT IS ON THIS ORIGIN AND NOT THE LANDING'S. The call here is
16 same-origin: Steel front-proxies /api/* to the gateway on loopback, exactly
17 as the passcode redemption in js/passcode.js already relies on. So there is
18 no preflight, and nothing about this page depends on configuration.
19
20 The endpoint does now allow one cross-origin caller -- `allow_origin` on
21 the /api/beta/apply route in gateway/app.jdat -- so a form on daimond.app
22 could be made to work. It would depend on that line being present in the
23 app.jdat THE SERVER IS ACTUALLY RUNNING, which is not the same file as the
24 one in this repository and has drifted from it before. A form that fails
25 silently because a config key is missing on one host is exactly the failure
26 a person filling it in cannot see. This one has nothing to drift. -->
27
28<!-- Not indexed. This is a step in a flow rather than a destination: somebody
29 who lands on it straight from a search engine has none of the landing's
30 context about what the beta is or what a passcode does. `follow` so the
31 links out of it still count. -->
32<meta name="robots" content="noindex, follow">
33
34<!-- The palette, before the first paint.
35 Deliberately NOT the app's eleven-palette table. index.html carries a copy
36 of that table and dev/verify_theme.mjs holds the copy to the original; a
37 third copy would be a third thing to keep in step, for a page that is read
38 once. The two ends of the range are enough here, and the operating
39 system's own preference is the right signal for a page reached from a
40 public site by somebody who may never have opened the app. -->
41<script>
42(function () {
43 try {
44 var light = window.matchMedia
45 && window.matchMedia('(prefers-color-scheme: light)').matches;
46 if (!light) return; // the markup already says dark
47 var root = document.documentElement;
48 root.setAttribute('data-theme', 'light');
49 root.setAttribute('data-tone', 'light');
50 root.setAttribute('data-ink', 'dark');
51 } catch (e) { /* the dark default stands */ }
52})();
53</script>
54
55<link rel="icon" type="image/svg+xml" href="assets/daimond_mark.svg">
56<!-- The app's own palette and the guide's document styling, so this page reads
57 as part of Daimond without restating a single colour. Only the form itself
58 is new, and it is below rather than in a new stylesheet: one page, one
59 file, nothing else to deploy. -->
60<link rel="stylesheet" href="css/variables.css">
61<link rel="stylesheet" href="css/guide.css">
62<style>
63 /* ── The form ─────────────────────────────────────────────────────────── */
64
65 .ap-form { margin: 1.5rem 0 0; }
66
67 .ap-field { margin: 1.25rem 0; }
68
69 .ap-label {
70 display: block;
71 font-weight: 650;
72 color: var(--text-primary);
73 margin: 0 0 0.35rem;
74 }
75
76 .ap-in, .ap-ta {
77 width: 100%;
78 box-sizing: border-box;
79 font: inherit;
80 /* A 16px floor. iOS Safari zooms the whole page whenever it focuses a
81 field with smaller text, and a page that jumps as the keyboard opens
82 is a page a phone user has to pinch back before they can read what
83 they typed. `max` so a reader who has enlarged the type keeps it. */
84 font-size: max(1rem, 16px);
85 line-height: 1.45;
86 padding: 0.6rem 0.7rem;
87 color: var(--text-primary);
88 background: var(--bg-primary);
89 /* --border-strong, not --border: on a field the line IS the control's
90 edge rather than a divider between two visible things. The comment in
91 variables.css sets out why the app carries a second token for it. */
92 border: 1px solid var(--border-strong);
93 border-radius: var(--radius-sm);
94 }
95 .ap-ta { min-height: 5.5rem; resize: vertical; }
96 .ap-in::placeholder, .ap-ta::placeholder { color: var(--text-muted); }
97 .ap-in:focus, .ap-ta:focus {
98 outline: 2px solid var(--accent);
99 outline-offset: 1px;
100 border-color: var(--accent);
101 }
102 .ap-in[aria-invalid="true"] { border-color: var(--danger); }
103
104 .ap-hint {
105 margin: 0.35rem 0 0;
106 font-size: 0.88rem;
107 color: var(--text-muted);
108 }
109 .ap-fielderr {
110 margin: 0.35rem 0 0;
111 font-size: 0.9rem;
112 color: var(--danger);
113 }
114 .ap-fielderr:empty { display: none; }
115
116 /* ── The two intents ──────────────────────────────────────────────────── */
117
118 .ap-choice {
119 margin: 0;
120 padding: 0;
121 border: 0;
122 }
123 .ap-choice legend {
124 padding: 0;
125 font-weight: 650;
126 color: var(--text-primary);
127 }
128 .ap-opt {
129 display: flex;
130 gap: 0.65rem;
131 align-items: flex-start;
132 margin: 0.5rem 0;
133 padding: 0.7rem 0.85rem;
134 background: var(--bg-secondary);
135 border: 1px solid var(--border);
136 border-radius: var(--radius);
137 cursor: pointer;
138 }
139 /* The chosen one, said with colour AND a heavier edge, because a fill alone
140 is the one cue a person who cannot separate those hues does not get. */
141 .ap-opt:has(input:checked) {
142 border-color: var(--accent);
143 background: var(--accent-soft);
144 }
145 .ap-opt:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
146 .ap-opt input { margin: 0.25rem 0 0; flex: none; accent-color: var(--accent); }
147 .ap-opt b { display: block; }
148 .ap-opt span { color: var(--text-secondary); }
149
150 /* ── The button ───────────────────────────────────────────────────────── */
151
152 .ap-btn {
153 font: inherit;
154 font-size: max(1rem, 16px);
155 font-weight: 700;
156 color: var(--on-fill, #fff);
157 background: var(--accent);
158 border: 1px solid var(--accent);
159 border-radius: var(--radius-sm);
160 padding: 0.7rem 1.4rem;
161 /* The thumb's floor. Most of the people answering this are on a phone. */
162 min-height: 48px;
163 cursor: pointer;
164 }
165 .ap-btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
166 .ap-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
167 /* Waiting, not broken. */
168 .ap-btn[disabled] { opacity: 0.55; cursor: default; background: var(--accent); }
169 @media (max-width: 560px) { .ap-btn { width: 100%; } }
170
171 /* ── What came back ───────────────────────────────────────────────────── */
172
173 /* One region for every answer, so a reader never has to look in two places
174 to find out what happened. It is a live region: the outcome of a submit
175 is the whole point of the page and a screen reader must hear it. */
176 .ap-say {
177 margin: 1.25rem 0 0;
178 padding: 0.8rem 1rem;
179 border: 1px solid var(--border);
180 border-left: 3px solid var(--text-muted);
181 border-radius: var(--radius-sm);
182 background: var(--bg-tertiary);
183 color: var(--text-secondary);
184 }
185 .ap-say:empty { display: none; }
186 /* The header is sticky, so anything scrolled to the very top of the
187 scrollport lands underneath it. Two of these are scrolled to by name:
188 the answer, and a field being sent back for correction. */
189 .ap-say, .ap-field { scroll-margin-top: 6rem; }
190 .ap-say :first-child { margin-top: 0; }
191 .ap-say :last-child { margin-bottom: 0; }
192 .ap-say strong { color: var(--text-primary); }
193 .ap-say.ok { border-left-color: var(--ok); }
194 .ap-say.bad { border-left-color: var(--danger); }
195 .ap-say:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
196
197 /* The word "(optional)" sitting inside a field's own label. */
198 .ap-optional {
199 font-weight: 400;
200 font-size: 0.88rem;
201 color: var(--text-muted);
202 }
203
204 /* A quiet line above the form for somebody who has been here before. */
205 .ap-before {
206 margin: 1rem 0 0;
207 font-size: 0.9rem;
208 color: var(--text-muted);
209 }
210 .ap-before:empty { display: none; }
211
212 [hidden] { display: none !important; }
213</style>
214</head>
215<body>
216
217<header class="site-head">
218 <div class="site-head-inner">
219 <!-- Back where they came from. The brand on the guide goes to the guide's
220 own index; here the reader arrived from the public site and that is
221 the only other page they know about. -->
222 <a class="brand" href="https://daimond.app/" aria-label="Daimond, back to daimond.app">
223 <img class="wordmark wm-dark" src="assets/daimond_word.svg" alt="Daimond" translate="no">
224 <img class="wordmark wm-light" src="assets/daimond_word_dark.svg" alt="Daimond" translate="no">
225 <span class="brand-sub">Beta</span>
226 </a>
227 </div>
228</header>
229
230<main>
231
232 <!-- Two headings and two ledes, both in the markup rather than written in by
233 script. The copy on this page is copy, and it belongs where the rest of
234 the site's copy is; the script only chooses which of the two is showing.
235 The "apply" pair ships visible because that is the default intent. -->
236 <div data-for="test">
237 <h1>Apply to test Daimond</h1>
238 <p class="lede">Daimond is in closed beta. Accounts are opened by passcode, so you cannot sign up on your own yet. Leave your address and we will send you one if there is room.</p>
239 </div>
240 <div data-for="waitlist" hidden>
241 <h1>Join the Daimond waitlist</h1>
242 <p class="lede">Daimond is in closed beta and not open for sign-up. Leave your address and we will write to you on the day that changes.</p>
243 </div>
244
245 <p class="ap-before" id="ap-before"></p>
246
247 <form class="ap-form" id="ap-form" novalidate>
248
249 <fieldset class="ap-choice">
250 <legend>What are you asking for?</legend>
251 <label class="ap-opt">
252 <input type="radio" name="kind" value="test" checked>
253 <span><b>A passcode now.</b> You want to use Daimond while the beta is closed.</span>
254 </label>
255 <label class="ap-opt">
256 <input type="radio" name="kind" value="waitlist">
257 <span><b>Word when it opens.</b> You would rather wait until anyone can sign up.</span>
258 </label>
259 </fieldset>
260
261 <div class="ap-field">
262 <label class="ap-label" for="ap-name">
263 Your name
264 <span class="ap-optional">(optional)</span>
265 </label>
266 <!-- Not decoration. The gateway carries this through to the passcode
267 it mints for an accepted applicant, as that code's label, which
268 is what the operator sees beside it afterwards. 80 characters is
269 BAPP_NAME_MAX, which is the label's own width. -->
270 <input class="ap-in" id="ap-name" name="name" type="text"
271 autocomplete="name" maxlength="80"
272 aria-describedby="ap-name-hint">
273 <p class="ap-hint" id="ap-name-hint">What to call you in a reply. Whatever you go by is fine.</p>
274 </div>
275
276 <div class="ap-field">
277 <label class="ap-label" for="ap-email">Your email address</label>
278 <!-- `type="email"` for the keyboard it summons on a phone, and
279 `novalidate` on the form so the browser's own bubble does not
280 pre-empt the message this page writes. -->
281 <input class="ap-in" id="ap-email" name="email" type="email"
282 inputmode="email" autocomplete="email" enterkeyhint="send"
283 autocapitalize="off" spellcheck="false" required
284 maxlength="254" placeholder="you@example.com"
285 aria-describedby="ap-email-hint">
286 <p class="ap-fielderr" id="ap-email-err" role="alert"></p>
287 <p class="ap-hint" id="ap-email-hint">The only thing we need. It is used to write to you about the beta, and for nothing else.</p>
288 </div>
289
290 <div class="ap-field">
291 <label class="ap-label" for="ap-note">
292 <span data-for="test">What would you use Daimond for?</span>
293 <span data-for="waitlist" hidden>Anything you would like to tell us</span>
294 <span class="ap-optional">(optional)</span>
295 </label>
296 <!-- 600 is BAPP_NOTE_MAX. Held here as well as there, because a
297 paragraph refused after it was typed is a paragraph lost. -->
298 <textarea class="ap-ta" id="ap-note" name="note" rows="4" maxlength="600"
299 aria-describedby="ap-note-hint"></textarea>
300 <p class="ap-hint" id="ap-note-hint">
301 <span data-for="test">A sentence is plenty. Places in each wave are limited, and this is what we read when we choose.</span>
302 <span data-for="waitlist" hidden>Nothing here is required. It is read, not filed against you.</span>
303 </p>
304 </div>
305
306 <button class="ap-btn" id="ap-send" type="submit">
307 <span data-for="test">Send my application</span>
308 <span data-for="waitlist" hidden>Add me to the waitlist</span>
309 </button>
310
311 </form>
312
313 <!-- Everything the page has to say about what just happened, in one place.
314 `tabindex="-1"` so focus can be moved here: a keyboard or screen-reader
315 user who presses the button must end up at the answer rather than back
316 at the top of the document. -->
317 <div class="ap-say" id="ap-say" role="status" aria-live="polite" tabindex="-1"></div>
318
319 <noscript>
320 <div class="note stop">
321 <p><strong>This form needs JavaScript.</strong> It posts what you type straight to Daimond's server, and there is nothing on this page that can do that without it. Switch it on for this site and reload, and the form will work.</p>
322 </div>
323 </noscript>
324
325 <h2>What happens next</h2>
326
327 <ol class="steps" data-for="test">
328 <li>Your address arrives in the operator console at Oxedyne, where a person reads it. It goes nowhere else.</li>
329 <li>If there is room in the current wave, we email you a passcode: twelve characters, in three groups.</li>
330 <li>You open Daimond, put the passcode in, and the account is made on that device. Each passcode works once, on one device.</li>
331 </ol>
332
333 <ol class="steps" data-for="waitlist" hidden>
334 <li>Your address arrives in the operator console at Oxedyne, where a person reads it. It goes nowhere else.</li>
335 <li>We write to you once, on the day Daimond opens for sign-up.</li>
336 <li>That is all. There is no newsletter and no second message.</li>
337 </ol>
338
339 <div class="note" data-for="test">
340 <p>We cannot promise a place. The waves are small, and an application we cannot take this time is held for the next one rather than thrown away.</p>
341 </div>
342
343 <div class="note">
344 <p><strong>What is kept:</strong> the address you type, the note if you write one, and the date. It sits on Oxedyne's own server. It is not sold and not passed to anyone. Ask, in a reply to anything we send you, and the record is deleted.</p>
345 </div>
346
347 <p>Already have a passcode? <a href="./">Open Daimond</a> and put it in. Not sure what any of this is? <a href="https://daimond.app/">Read the site</a> first.</p>
348
349</main>
350
351<footer class="site-foot">
352 <nav aria-label="Elsewhere">
353 <a href="https://daimond.app/">daimond.app</a>
354 <a href="./">Open Daimond</a>
355 <a href="guide/">Guide</a>
356 </nav>
357 <span>© Complement Pty Ltd, trading as Oxedyne.</span>
358</footer>
359
360<script>
361(function () {
362 'use strict';
363
364 /* ── The gateway call ─────────────────────────────────────────────────────
365 THE ONE PLACE THE REQUEST IS BUILT. Path, method, headers and body, all
366 here and nowhere else, so a change to the contract is one edit rather than
367 a search.
368
369 The other end is gateway/src/handlers/beta.rs, whose header states it:
370 request `{email, name?, note?, intent?}`, reply `{ok:true,
371 status:"received"}`. `intent` is exactly `test` or `waitlist`; anything
372 else is a 400, and an absent one means `test`. The three field caps below
373 are that file's BAPP_EMAIL_MAX, BAPP_NAME_MAX and BAPP_NOTE_MAX, so the
374 browser refuses what the gateway would refuse, in the field rather than
375 after a round trip.
376
377 Same-origin, so `/api/beta/apply` is Steel's proxy to the gateway on
378 loopback: no preflight, and none of that route's CORS configuration is in
379 play. No `x-daimond-api` header either -- common.rs treats a client that
380 names no version as current, and copying the app's version constant into
381 a second file is exactly the pair of numbers that eventually disagree. */
382 var ENDPOINT = '/api/beta/apply';
383
384 function send(intent, email, name, note) {
385 return fetch(ENDPOINT, {
386 method: 'POST',
387 credentials: 'same-origin',
388 headers: { 'content-type': 'application/json' },
389 body: JSON.stringify({
390 intent: intent,
391 email: email,
392 name: name,
393 note: note,
394 }),
395 });
396 }
397
398 /* ── The page ─────────────────────────────────────────────────────────── */
399
400 var form = document.getElementById('ap-form');
401 var nameIn = document.getElementById('ap-name');
402 var email = document.getElementById('ap-email');
403 var note = document.getElementById('ap-note');
404 var btn = document.getElementById('ap-send');
405 var say = document.getElementById('ap-say');
406 var mailEr = document.getElementById('ap-email-err');
407 var before = document.getElementById('ap-before');
408
409 /// What this browser last sent, so a return visit is not a blank form
410 /// pretending nothing ever happened. The DATE and the INTENT only -- never
411 /// the address. A shared or borrowed machine must not carry somebody's email
412 /// around in localStorage for the sake of a nicety.
413 var SENT = 'daimond-applied';
414
415 /// Which of the two the page is set to. `test` unless the query says
416 /// otherwise, because the landing's larger, primary button is the apply one.
417 function wanted() {
418 var q = '';
419 try { q = new URLSearchParams(location.search).get('for') || ''; } catch (e) { q = ''; }
420 return q === 'waitlist' ? 'waitlist' : 'test';
421 }
422
423 /// The intent the form is currently set to.
424 function kind() {
425 var on = form.querySelector('input[name="kind"]:checked');
426 return on && on.value === 'waitlist' ? 'waitlist' : 'test';
427 }
428
429 /// Show the half of the copy that belongs to this intent, and hide the other.
430 ///
431 /// Everything keyed `data-for` is a pair: one for the passcode and one for
432 /// the waitlist. Nothing is written from here -- the words are all in the
433 /// markup above -- so a change of copy is a change to the HTML alone.
434 function dress(k) {
435 var all = document.querySelectorAll('[data-for]');
436 for (var i = 0; i < all.length; i++) {
437 all[i].hidden = all[i].getAttribute('data-for') !== k;
438 }
439 document.title = k === 'waitlist'
440 ? 'Join the Daimond waitlist'
441 : 'Apply to test Daimond';
442 // Keep the address bar honest, so a link copied off this page opens on
443 // the same half of it. `replaceState` rather than a push: switching the
444 // radio is not somewhere the Back button should have to return through.
445 try {
446 var u = new URL(location.href);
447 u.searchParams.set('for', k);
448 history.replaceState(null, '', u.toString());
449 } catch (e) { /* an engine without it simply keeps the old query */ }
450 }
451
452 /// Does this look like an address at all?
453 ///
454 /// Deliberately loose. The gateway decides what it will accept and this is
455 /// only here to catch the empty box and the missing `@` before a person
456 /// waits on a round trip to be told. A strict pattern here would refuse
457 /// perfectly legal addresses that the server would have taken.
458 function plausible(s) {
459 return /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(s);
460 }
461
462 /// Put a message in the one region, in the given state, and -- for an answer
463 /// rather than a progress note -- take the reader to it. `html` is written by
464 /// this file only; nothing a user typed and nothing the server said is ever
465 /// passed through it as markup.
466 ///
467 /// `land` is false while the request is in flight. Moving focus off the
468 /// button to say "Sending…" would take the keyboard away from the control
469 /// the reader is standing on and then take it somewhere else again a moment
470 /// later; the live region announces the progress without any of that.
471 function tell(state, html, land) {
472 say.className = 'ap-say' + (state ? ' ' + state : '');
473 say.innerHTML = html;
474 if (land === false) return;
475 try { say.focus(); } catch (e) { /* not focusable in this engine */ }
476 }
477
478 /// The gateway's own sentence, when it sent one worth reading.
479 ///
480 /// The house shape for a refusal is `{ok:false, reason, error}` -- see
481 /// gateway/src/handlers/passcode.rs, which this endpoint sits beside. The
482 /// `error` field is English written for a person, so it is preferred over
483 /// anything invented here; `reason` is the machine word and is never shown.
484 function said(j) {
485 var s = j && typeof j.error === 'string' ? j.error.trim() : '';
486 return s ? esc(s) : '';
487 }
488
489 /// Text from the server, made safe to place in the page.
490 function esc(s) {
491 return String(s)
492 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
493 }
494
495 /// Nothing was recorded. Said in the same words every time, because the one
496 /// thing a person needs after a failure is to know whether to do it again.
497 var NOTHING = 'Nothing was recorded, so try again in a few minutes. '
498 + 'What you typed is still here.';
499
500 /// The page has done its job; put the form away and say what will happen.
501 ///
502 /// THERE IS NO "you have already applied" HERE, and there must not be. The
503 /// gateway answers a fresh application, a repeat and one inside its cooldown
504 /// with the same `{ok:true}` on purpose (see the module header of
505 /// gateway/src/handlers/beta.rs): an endpoint that told them apart would
506 /// answer "is this address known to Daimond" for any address anybody cared
507 /// to type. So this page cannot know, does not guess, and says the same
508 /// thing the server did.
509 function done(k) {
510 form.hidden = true;
511 before.textContent = '';
512 // The lede is an instruction -- "leave your address" -- and it has been
513 // followed. Leaving it above the confirmation would tell somebody to do
514 // the thing they have just done.
515 var lede = document.querySelectorAll('.lede');
516 for (var i = 0; i < lede.length; i++) lede[i].hidden = true;
517 var toApp = ' <a href="./">Open Daimond</a> when your passcode arrives.';
518 if (k === 'waitlist') {
519 tell('ok', '<p><strong>You are on the list.</strong> We will write to you '
520 + 'once, on the day Daimond opens for sign-up. Nothing else happens '
521 + 'on this page, and you can close it.</p>');
522 } else {
523 tell('ok', '<p><strong>Your application is in.</strong> A person at Oxedyne '
524 + 'reads it. If there is room in the current wave you will get a '
525 + 'passcode by email; if there is not, the application is held for the '
526 + 'next one.' + toApp + '</p>');
527 }
528 try {
529 localStorage.setItem(SENT, JSON.stringify({ intent: k, at: Date.now() }));
530 } catch (e) { /* private mode: the confirmation above is the whole record */ }
531 }
532
533 /// A line for somebody who has been here before, from this browser.
534 function recall() {
535 var raw = null;
536 try { raw = localStorage.getItem(SENT); } catch (e) { return; }
537 if (!raw) return;
538 var rec = null;
539 try { rec = JSON.parse(raw); } catch (e) { return; }
540 if (!rec || !rec.at) return;
541 var when = new Date(rec.at).toLocaleDateString('en-GB',
542 { day: 'numeric', month: 'long', year: 'numeric' });
543 before.textContent = 'You sent something from this browser on ' + when
544 + '. Sending another does no harm, and does not move you up any list.';
545 }
546
547 /// Hold the intent still while a request is in flight, or let it go again.
548 function lock(on) {
549 var radios = form.querySelectorAll('input[name="kind"]');
550 for (var i = 0; i < radios.length; i++) radios[i].disabled = !!on;
551 }
552
553 /// Put the form back the way it was, ready to be sent again. Called from
554 /// every failure arm, so no arm can forget half of it and leave a disabled
555 /// button over a message telling somebody to try again.
556 function revert(label) {
557 btn.disabled = false;
558 btn.innerHTML = label;
559 lock(false);
560 }
561
562 // ── Wiring ────────────────────────────────────────────────────────────────
563
564 form.addEventListener('change', function (e) {
565 if (e.target && e.target.name === 'kind') dress(kind());
566 });
567
568 form.addEventListener('submit', function (e) {
569 e.preventDefault();
570 go();
571 });
572
573 async function go() {
574 var addr = (email.value || '').trim();
575 var who = (nameIn.value || '').trim();
576 var body = (note.value || '').trim();
577 var k = kind();
578
579 mailEr.textContent = '';
580 email.removeAttribute('aria-invalid');
581
582 if (!addr) {
583 mailEr.textContent = 'Type the address we should write to.';
584 email.setAttribute('aria-invalid', 'true');
585 email.focus();
586 return;
587 }
588 if (!plausible(addr)) {
589 mailEr.textContent = 'That does not look like an email address. '
590 + 'Check it and try again.';
591 email.setAttribute('aria-invalid', 'true');
592 email.focus();
593 return;
594 }
595
596 // The button carries both labels and only shows one; "Sending…" replaces
597 // them for the duration, so the markup is put back rather than rebuilt.
598 // The intent is frozen with it: the two labels the restore brings back
599 // are the ones that were right when the request went out, and a radio
600 // changed mid-flight would leave them describing the other half.
601 var was = btn.innerHTML;
602 btn.disabled = true;
603 btn.textContent = 'Sending…';
604 lock(true);
605 tell('', '<p>Sending…</p>', false);
606
607 var r;
608 try {
609 r = await send(k, addr, who, body);
610 } catch (err) {
611 // The request never left this device. That is a different fact from a
612 // refusal, and it is the one that decides whether trying again is
613 // worth anything -- so it is never reported as the server saying no.
614 revert(was);
615 tell('bad', '<p><strong>That did not get out.</strong> Daimond\'s server '
616 + 'could not be reached from this device, so nothing arrived and '
617 + 'nothing was recorded. Check your connection and try again.</p>');
618 return;
619 }
620
621 var j = null;
622 try { j = await r.json(); } catch (e2) { j = null; }
623
624 // Taken. `ok:true` is the contract; a 2xx with a body this page cannot
625 // parse is NOT treated as success, because the one thing worse than a
626 // failure here is telling somebody they are on a list they are not on.
627 if (r.ok && j && j.ok === true) { done(k); return; }
628
629 revert(was);
630
631 var msg = said(j);
632
633 // A refusal this page knows the shape of is said in this page's own
634 // words, and the server's sentence is dropped -- the two say the same
635 // thing, and printing both reads as a stutter. The server's sentence is
636 // kept for the refusals below, where it is the only thing that knows
637 // WHICH field was wrong, and for anything unrecognised.
638 if (r.status === 429) {
639 tell('bad', '<p><strong>Too many attempts from this connection.</strong> '
640 + 'Wait a few minutes and try again. Nothing was recorded.</p>');
641 return;
642 }
643 // 413 sits with the 400s rather than with the retries: the body was too
644 // big, and pressing the button again with the same words in the box
645 // sends the same body. The gateway's sentence names what was too long.
646 if (r.status === 400 || r.status === 413 || r.status === 422) {
647 tell('bad', '<p><strong>The server would not take that.</strong> '
648 + (msg || 'Check what you typed and try again.') + ' '
649 + 'Nothing was recorded.</p>');
650 return;
651 }
652 // The form is shut, or its store is at its ceiling. Not a fault the
653 // reader can wait out over a coffee, so it does not tell them to: the
654 // gateway's own sentence says what is going on, and the advice is to
655 // come back rather than to press the button again.
656 if (r.status === 503) {
657 tell('bad', '<p><strong>'
658 + (msg || 'Daimond is not taking applications just now.')
659 + '</strong> Nothing was recorded. Try again another day, or watch '
660 + '<a href="https://daimond.app/">daimond.app</a> for the beta '
661 + 'opening.</p>');
662 return;
663 }
664 // Not deployed here, or not deployed yet. Worth its own sentence: it is
665 // the one failure a person can do absolutely nothing about, and it must
666 // not read as though they typed something wrong.
667 if (r.status === 404 || r.status === 405 || r.status === 501) {
668 tell('bad', '<p><strong>This server is not taking applications.</strong> '
669 + 'The form reached it and it had nowhere to put this, which is our '
670 + 'fault and not yours. ' + NOTHING + '</p>');
671 return;
672 }
673 // A 2xx that did not say `ok:true`. If it named a reason, it refused; if
674 // it did not, this page genuinely does not know whether the application
675 // landed, and saying either "sent" or "refused" would be a guess about
676 // the one thing the reader came here to find out.
677 if (r.ok) {
678 tell('bad', msg
679 ? '<p><strong>The server refused that.</strong> ' + msg + ' ' + NOTHING + '</p>'
680 : '<p><strong>The server answered, but not in a way this page '
681 + 'understands.</strong> So it cannot tell you whether your '
682 + 'application was recorded or not. Try again in a few minutes; '
683 + 'if the first one did land, a second changes nothing.</p>');
684 return;
685 }
686 tell('bad', '<p><strong>The server refused that.</strong> '
687 + (msg || ('It answered HTTP ' + r.status + '.')) + ' ' + NOTHING + '</p>');
688 }
689
690 // The intent the link carried, then the line for a returning visitor.
691 (function start() {
692 var k = wanted();
693 var on = form.querySelector('input[name="kind"][value="' + k + '"]');
694 if (on) on.checked = true;
695 dress(k);
696 recall();
697 })();
698
699})();
700</script>
701
702</body>
703</html>