@jtakeit/kit 0.6.0 → 0.7.1
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/README.md +19 -7
- package/booking.css +23 -10
- package/elements/booking-ui.js +91 -21
- package/elements/booking.js +170 -29
- package/elements/lead.js +26 -4
- package/elements/writing.js +109 -7
- package/index.mjs +2 -1
- package/lib/booking/hooks.mjs +91 -0
- package/lib/booking/render.mjs +101 -41
- package/lib/booking/words.mjs +8 -0
- package/lib/lint.mjs +37 -0
- package/lib/phases.mjs +22 -1
- package/package.json +5 -1
- package/types/booking.d.ts +21 -0
- package/types/elements-booking.d.ts +1 -0
- package/types/hooks.d.ts +12 -0
- package/types/index.d.ts +4 -2
- package/types/phases.d.ts +1 -0
package/elements/booking.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* POST /api/book → { id, state, amount_minor, currency, token, manage_path, telegram_url }
|
|
15
15
|
* POST /api/pay → { url } — only when amount_minor > 0
|
|
16
16
|
*
|
|
17
|
+
* Every request names the protocol version: `X-Jtk-Booking: 1` (BOOKING_PROTOCOL).
|
|
18
|
+
*
|
|
17
19
|
* The visitor's own page — cancel, move, add to calendar — is served by the
|
|
18
20
|
* platform at `manage_path`; this form only has to link to it.
|
|
19
21
|
*
|
|
@@ -22,9 +24,14 @@
|
|
|
22
24
|
* Import this module once on the page and every `<form data-booking>` is
|
|
23
25
|
* wired: the ones inside a `<jtk-booking>` by the element's own lifecycle,
|
|
24
26
|
* which also covers markup the panel places later (`fl:placed`); the ones
|
|
25
|
-
* written bare by a walk of the document at load. The markup is
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* written bare by a walk of the document at load. The markup is the site's —
|
|
28
|
+
* the scaffold's `BookingForm.astro` written out, or `renderBookingForm`'s
|
|
29
|
+
* (`@jtakeit/kit/booking`) — and the element finds everything in it by a
|
|
30
|
+
* `data-*` hook or an input's `name`, never by a class: the list is
|
|
31
|
+
* `@jtakeit/kit/booking/hooks`. A hook left out is said in the console and
|
|
32
|
+
* its field is never shown; the CSS the behaviour needs (`[hidden]`, the
|
|
33
|
+
* steps, the native control under a drawn one) and the ARIA it depends on are
|
|
34
|
+
* put on the page by the element, so a form restyled from nothing keeps them.
|
|
28
35
|
*
|
|
29
36
|
* ── a stay of several nights ────────────────────────────────────────────────
|
|
30
37
|
*
|
|
@@ -47,6 +54,40 @@
|
|
|
47
54
|
import { money } from '../lib/money.mjs';
|
|
48
55
|
import { wordsFor } from '../lib/booking/words.mjs';
|
|
49
56
|
import { dressBooking } from './booking-ui.js';
|
|
57
|
+
import { lacking } from '../lib/booking/hooks.mjs';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The rules the behaviour cannot work without, whatever the site's own CSS
|
|
61
|
+
* says — put on the page once, by the element, at the lowest specificity
|
|
62
|
+
* that still wins where it must. Not a look: every state here is shown and
|
|
63
|
+
* hidden by the `hidden` attribute, the two steps by `data-stage`, a drawn
|
|
64
|
+
* control over a native one by `data-replaced`; a form an agent dressed from
|
|
65
|
+
* nothing lost all three with `booking.css` (12 October 2026). The first two
|
|
66
|
+
* are `!important`, as `hidden` is: a site's `display: grid` on a piece of
|
|
67
|
+
* the second step drew it on the first.
|
|
68
|
+
*/
|
|
69
|
+
const BASE = `
|
|
70
|
+
[data-booking] [hidden] { display: none !important; }
|
|
71
|
+
[data-booking][data-unsaid] :is([data-say], [data-say-wait]) { visibility: hidden; }
|
|
72
|
+
:where([data-booking]) :where([data-replaced], .is-replaced, [data-sr], .booking__sr) {
|
|
73
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
74
|
+
clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
75
|
+
}
|
|
76
|
+
:where([data-booking]) :where([data-replaced], .is-replaced) { opacity: 0; pointer-events: none; }
|
|
77
|
+
[data-booking][data-stage='1'] [data-step='2'], [data-booking][data-stage='2'] [data-step='1'],
|
|
78
|
+
[data-booking][data-stage='done'] [data-step-ui] { display: none !important; }
|
|
79
|
+
:where([data-booking] :focus-visible) { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
function putBase() {
|
|
83
|
+
if (typeof document === 'undefined' || document.querySelector('style[data-jtk-booking]')) return;
|
|
84
|
+
const style = document.createElement('style');
|
|
85
|
+
style.dataset.jtkBooking = '';
|
|
86
|
+
style.textContent = BASE;
|
|
87
|
+
// First in <head>, so a site's own rule of the same weight wins.
|
|
88
|
+
document.head.prepend(style);
|
|
89
|
+
}
|
|
90
|
+
let wiredCount = 0;
|
|
50
91
|
|
|
51
92
|
// The words that depend on the kind of business, as the platform says them:
|
|
52
93
|
// its availability answer carries the form's, in every language, the site's
|
|
@@ -90,10 +131,25 @@ export function lasting(minutes, words) {
|
|
|
90
131
|
* Wires one form. Idempotent: a form already wired is left alone.
|
|
91
132
|
* @param {HTMLFormElement} form
|
|
92
133
|
*/
|
|
134
|
+
/**
|
|
135
|
+
* The booking protocol's version this element speaks, named on every request
|
|
136
|
+
* (the platform's wiki/79). A page is a file in a build: when the platform
|
|
137
|
+
* changes, a site keeps the kit it was built with, and the platform answers it
|
|
138
|
+
* by this number for as long as a live site speaks it — so it moves only with
|
|
139
|
+
* a change the platform cannot make by adding, and the platform says which
|
|
140
|
+
* versions it answers when it refuses one.
|
|
141
|
+
*/
|
|
142
|
+
export const BOOKING_PROTOCOL = 1;
|
|
143
|
+
const SPOKEN = { 'x-jtk-booking': String(BOOKING_PROTOCOL) };
|
|
144
|
+
|
|
93
145
|
export function wireBooking(form) {
|
|
94
146
|
if (form.dataset.jtkWired === '1') return;
|
|
95
147
|
form.dataset.jtkWired = '1';
|
|
96
148
|
|
|
149
|
+
putBase();
|
|
150
|
+
const missing = lacking(form);
|
|
151
|
+
if (missing.length > 0) console.error(`booking form: it has no ${missing.join(', ')}, and cannot book without them — the hooks are listed in @jtakeit/kit/booking/hooks`);
|
|
152
|
+
|
|
97
153
|
/** @type {Record<string, string>} */
|
|
98
154
|
// The form's own words under the page's: a form rendered by an older kit
|
|
99
155
|
// carries none of the newer ones.
|
|
@@ -102,8 +158,10 @@ export function wireBooking(form) {
|
|
|
102
158
|
// once there is one, wins — an owner may switch without a rebuild.
|
|
103
159
|
let daily = form.dataset.scale === 'daily';
|
|
104
160
|
const api = form.dataset.api ?? '/api/';
|
|
105
|
-
|
|
106
|
-
|
|
161
|
+
// A hook the form left out is an element of its kind that is never on the
|
|
162
|
+
// page: the field it stands for is never shown, and nothing here throws.
|
|
163
|
+
/** @template {HTMLElement} T @param {string} sel @param {string} [tag] @returns {T} */
|
|
164
|
+
const q = (sel, tag = 'div') => /** @type {any} */ (form.querySelector(sel) ?? document.createElement(tag));
|
|
107
165
|
const lang = form.dataset.locale ?? 'en';
|
|
108
166
|
const pageLang = () => document.documentElement.lang || 'en';
|
|
109
167
|
|
|
@@ -111,6 +169,12 @@ export function wireBooking(form) {
|
|
|
111
169
|
const select = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=service]'));
|
|
112
170
|
const boxes = /** @type {HTMLInputElement[]} */ ([...form.querySelectorAll('input[type=checkbox][name=service]')]);
|
|
113
171
|
const total = /** @type {HTMLElement | null} */ (form.querySelector('[data-total]'));
|
|
172
|
+
// A checkbox's row and the service's name in it, by their hooks; the
|
|
173
|
+
// classes of an older kit's markup where a form has no hooks.
|
|
174
|
+
/** @param {HTMLInputElement} box */
|
|
175
|
+
const rowOf = (box) => /** @type {HTMLElement | null} */ (box.closest('[data-service-row], .booking__service, label'));
|
|
176
|
+
/** @param {Element} row */
|
|
177
|
+
const nameIn = (row) => /** @type {HTMLElement | null} */ (row.querySelector('[data-service-name], .booking__service-name'));
|
|
114
178
|
const picked = () => (select ? (select.value ? [select.value] : []) : boxes.filter((b) => b.checked).map((b) => b.value));
|
|
115
179
|
function showTotal() {
|
|
116
180
|
if (!total) return;
|
|
@@ -126,14 +190,14 @@ export function wireBooking(form) {
|
|
|
126
190
|
total.hidden = false;
|
|
127
191
|
}
|
|
128
192
|
const resource = /** @type {HTMLSelectElement | null} */ (form.querySelector('[name=resource]'));
|
|
129
|
-
/** @type {HTMLSelectElement} */ const party = q('[name=party]');
|
|
130
|
-
/** @type {HTMLSelectElement} */ const quantity = q('[name=quantity]');
|
|
193
|
+
/** @type {HTMLSelectElement} */ const party = q('[name=party]', 'select');
|
|
194
|
+
/** @type {HTMLSelectElement} */ const quantity = q('[name=quantity]', 'select');
|
|
131
195
|
/** @type {HTMLElement} */ const quantityField = q('[data-quantity-field]');
|
|
132
|
-
/** @type {HTMLSelectElement} */ const hours = q('[name=hours]');
|
|
196
|
+
/** @type {HTMLSelectElement} */ const hours = q('[name=hours]', 'select');
|
|
133
197
|
/** @type {HTMLElement} */ const hoursField = q('[data-hours-field]');
|
|
134
198
|
/** @type {HTMLElement} */ const partyField = q('[data-party-field]');
|
|
135
|
-
/** @type {HTMLInputElement} */ const day = q('[name=day]');
|
|
136
|
-
/** @type {HTMLElement | null} */ const ends =
|
|
199
|
+
/** @type {HTMLInputElement} */ const day = q('[name=day]', 'input');
|
|
200
|
+
/** @type {HTMLElement | null} */ const ends = form.querySelector('[data-ends]');
|
|
137
201
|
// The day's floor is the browser's today, not the build's: `min` was
|
|
138
202
|
// written when the site was built, and a site built on Friday is read on
|
|
139
203
|
// Saturday (10 October 2026).
|
|
@@ -156,11 +220,25 @@ export function wireBooking(form) {
|
|
|
156
220
|
/** @type {HTMLElement} */ const error = q('[data-booking-error]');
|
|
157
221
|
/** @type {HTMLButtonElement} */ const submit = q('[data-submit]');
|
|
158
222
|
/** @type {HTMLElement} */ const challenge = q('[data-challenge]');
|
|
159
|
-
/** @type {HTMLInputElement} */ const back = q('[name=back]');
|
|
223
|
+
/** @type {HTMLInputElement} */ const back = q('[name=back]', 'input');
|
|
160
224
|
/** @type {HTMLElement} */ const backField = q('[data-back-field]');
|
|
161
225
|
/** @type {HTMLElement} */ const lengthHint = q('[data-length]');
|
|
162
226
|
/** @type {HTMLElement} */ const stayTotal = q('[data-stay-total]');
|
|
163
227
|
|
|
228
|
+
// The ARIA the behaviour depends on, whatever the copy kept: the hint is
|
|
229
|
+
// read as it changes, the refusal as it appears, the answer takes the focus
|
|
230
|
+
// and is read, and a refusal is tied to its fields by the error's id.
|
|
231
|
+
if (!hint.hasAttribute('aria-live')) hint.setAttribute('aria-live', 'polite');
|
|
232
|
+
if (!error.getAttribute('role')) error.setAttribute('role', 'alert');
|
|
233
|
+
if (!error.id) error.id = `jtk-booking-error-${++wiredCount}`;
|
|
234
|
+
{
|
|
235
|
+
/** @type {HTMLElement} */ const answerBox = q('[data-done]');
|
|
236
|
+
if (!answerBox.getAttribute('role')) answerBox.setAttribute('role', 'status');
|
|
237
|
+
if (!answerBox.hasAttribute('tabindex')) answerBox.tabIndex = -1;
|
|
238
|
+
const keepSaid = form.querySelector('[data-keep-said]');
|
|
239
|
+
if (keepSaid && !keepSaid.getAttribute('role')) keepSaid.setAttribute('role', 'status');
|
|
240
|
+
}
|
|
241
|
+
|
|
164
242
|
/** @param {Record<string, Record<string, string>>} [all] */
|
|
165
243
|
function hear(all) {
|
|
166
244
|
const mine = all?.[lang] ?? all?.en;
|
|
@@ -172,11 +250,15 @@ export function wireBooking(form) {
|
|
|
172
250
|
el.textContent = words[el.dataset.say ?? ''] ?? el.textContent;
|
|
173
251
|
}
|
|
174
252
|
}
|
|
253
|
+
unsay();
|
|
254
|
+
}
|
|
255
|
+
function unsay() {
|
|
175
256
|
form.classList.remove('booking--unsaid');
|
|
257
|
+
delete form.dataset.unsaid;
|
|
176
258
|
}
|
|
177
259
|
// Held back at most this long: a platform that is slow to answer is not
|
|
178
260
|
// a reason for a form without a button.
|
|
179
|
-
setTimeout(
|
|
261
|
+
setTimeout(unsay, 3000);
|
|
180
262
|
{
|
|
181
263
|
const first = select?.options[0]?.value ?? boxes[0]?.value ?? '';
|
|
182
264
|
const from = new Date();
|
|
@@ -185,10 +267,11 @@ export function wireBooking(form) {
|
|
|
185
267
|
to.setDate(to.getDate() + 1);
|
|
186
268
|
if (first) {
|
|
187
269
|
const params = new URLSearchParams({ service: first, from: from.toISOString(), to: to.toISOString() });
|
|
188
|
-
void fetch(api + 'availability?' + params.toString(), { cache: 'no-store' })
|
|
270
|
+
void fetch(api + 'availability?' + params.toString(), { cache: 'no-store', headers: SPOKEN })
|
|
189
271
|
.then((r) => (r.ok ? r.json() : {}))
|
|
190
272
|
.then((data) => {
|
|
191
273
|
hear(data.words);
|
|
274
|
+
hearCombine(data.combine);
|
|
192
275
|
sayEmptyServices(data.empty_services ?? []);
|
|
193
276
|
})
|
|
194
277
|
.catch(() => hear());
|
|
@@ -207,6 +290,23 @@ export function wireBooking(form) {
|
|
|
207
290
|
// sign made up while it is asked (11 October 2026).
|
|
208
291
|
let currency = form.dataset.currency || '';
|
|
209
292
|
|
|
293
|
+
// One service or several, as the platform answers it now: the owner flips
|
|
294
|
+
// `combine` in the panel and a checklist built while it was on keeps to one
|
|
295
|
+
// choice once it is off, rather than offering two the booking refuses.
|
|
296
|
+
let single = false;
|
|
297
|
+
/** @param {unknown} said */
|
|
298
|
+
function hearCombine(said) {
|
|
299
|
+
if (typeof said !== 'boolean' || boxes.length === 0) return;
|
|
300
|
+
single = !said;
|
|
301
|
+
if (!single) return;
|
|
302
|
+
let kept = false;
|
|
303
|
+
for (const box of boxes) {
|
|
304
|
+
if (box.checked && kept) box.checked = false;
|
|
305
|
+
if (box.checked) kept = true;
|
|
306
|
+
}
|
|
307
|
+
showTotal();
|
|
308
|
+
}
|
|
309
|
+
|
|
210
310
|
/** @type {string | null} */ let chosen = null;
|
|
211
311
|
let zone = '';
|
|
212
312
|
let turnstileToken = '';
|
|
@@ -223,7 +323,7 @@ export function wireBooking(form) {
|
|
|
223
323
|
|
|
224
324
|
// The widget, if this host has one. Asked at runtime so the site carries
|
|
225
325
|
// no key: which widget covers a host is the platform's to decide.
|
|
226
|
-
void fetch(api + 'turnstile', { cache: 'no-store' })
|
|
326
|
+
void fetch(api + 'turnstile', { cache: 'no-store', headers: SPOKEN })
|
|
227
327
|
.then((r) => r.json())
|
|
228
328
|
.then((data) => {
|
|
229
329
|
if (!data.sitekey) return;
|
|
@@ -294,10 +394,25 @@ export function wireBooking(form) {
|
|
|
294
394
|
function quantityChosen() {
|
|
295
395
|
return quantityField.hidden ? 1 : Number(quantity.value) || 1;
|
|
296
396
|
}
|
|
297
|
-
|
|
298
|
-
|
|
397
|
+
// The fields the last refusal was about: marked invalid and described by
|
|
398
|
+
// the sentence, so a screen reader reads it on the field itself and not
|
|
399
|
+
// only as it appears (WCAG 3.3.1, 12 October 2026).
|
|
400
|
+
/** @type {HTMLElement[]} */ let blamed = [];
|
|
401
|
+
/** @param {string} text @param {HTMLElement[]} [fields] the fields it is about */
|
|
402
|
+
function say(text, fields = []) {
|
|
299
403
|
error.textContent = text;
|
|
300
404
|
error.hidden = text === '';
|
|
405
|
+
for (const field of blamed) {
|
|
406
|
+
field.removeAttribute('aria-invalid');
|
|
407
|
+
const rest = (field.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id && id !== error.id);
|
|
408
|
+
if (rest.length) field.setAttribute('aria-describedby', rest.join(' '));
|
|
409
|
+
else field.removeAttribute('aria-describedby');
|
|
410
|
+
}
|
|
411
|
+
blamed = text === '' || !error.id ? [] : fields;
|
|
412
|
+
for (const field of blamed) {
|
|
413
|
+
field.setAttribute('aria-invalid', 'true');
|
|
414
|
+
field.setAttribute('aria-describedby', [field.getAttribute('aria-describedby'), error.id].filter(Boolean).join(' '));
|
|
415
|
+
}
|
|
301
416
|
}
|
|
302
417
|
|
|
303
418
|
// A refusal as a sentence in the visitor's language. The platform answers
|
|
@@ -485,7 +600,7 @@ export function wireBooking(form) {
|
|
|
485
600
|
const can = who && !who.startsWith('class:') && !who.startsWith('empty:') && does.has(who) ? does.get(who) : null;
|
|
486
601
|
let moved = false;
|
|
487
602
|
for (const box of boxes) {
|
|
488
|
-
const row = box
|
|
603
|
+
const row = rowOf(box);
|
|
489
604
|
const off = can !== null && !can.has(box.value);
|
|
490
605
|
if (off && !box.disabled) {
|
|
491
606
|
box.disabled = true;
|
|
@@ -496,12 +611,14 @@ export function wireBooking(form) {
|
|
|
496
611
|
offByMe.delete(box);
|
|
497
612
|
}
|
|
498
613
|
row?.classList.toggle('is-off', offByMe.has(box));
|
|
499
|
-
|
|
614
|
+
if (row) row.toggleAttribute('data-off', offByMe.has(box));
|
|
615
|
+
let note = row?.querySelector('[data-service-off], .booking__service-off') ?? null;
|
|
500
616
|
if (offByMe.has(box) && row) {
|
|
501
|
-
const nameEl = row
|
|
617
|
+
const nameEl = nameIn(row);
|
|
502
618
|
if (!note && nameEl) {
|
|
503
619
|
note = document.createElement('span');
|
|
504
620
|
note.className = 'booking__service-off';
|
|
621
|
+
note.setAttribute('data-service-off', '');
|
|
505
622
|
nameEl.insertAdjacentElement('afterend', note);
|
|
506
623
|
}
|
|
507
624
|
if (note) note.textContent = words.notWith.replace('{who}', name.split(' ')[0] || name);
|
|
@@ -550,7 +667,8 @@ export function wireBooking(form) {
|
|
|
550
667
|
const box = boxes.find((b) => b.value === one.slug);
|
|
551
668
|
if (box && !box.disabled) {
|
|
552
669
|
box.disabled = true;
|
|
553
|
-
const
|
|
670
|
+
const row = rowOf(box);
|
|
671
|
+
const name = row ? nameIn(row) : null;
|
|
554
672
|
if (name) name.textContent = said;
|
|
555
673
|
if (box.checked) { box.checked = false; showTotal(); moved = true; }
|
|
556
674
|
}
|
|
@@ -579,6 +697,7 @@ export function wireBooking(form) {
|
|
|
579
697
|
return;
|
|
580
698
|
}
|
|
581
699
|
hint.textContent = '…';
|
|
700
|
+
slots.setAttribute('aria-busy', 'true');
|
|
582
701
|
|
|
583
702
|
const from = new Date(day.value + 'T00:00:00');
|
|
584
703
|
const to = new Date(from);
|
|
@@ -596,11 +715,12 @@ export function wireBooking(form) {
|
|
|
596
715
|
if (n !== null) params.set('count', String(n));
|
|
597
716
|
|
|
598
717
|
try {
|
|
599
|
-
const answer = await fetch(api + 'availability?' + params.toString(), { cache: 'no-store' });
|
|
718
|
+
const answer = await fetch(api + 'availability?' + params.toString(), { cache: 'no-store', headers: SPOKEN }).finally(() => slots.removeAttribute('aria-busy'));
|
|
600
719
|
if (!answer.ok) throw new Error(await answer.text());
|
|
601
720
|
if (mine !== asking) return;
|
|
602
721
|
const data = await answer.json();
|
|
603
722
|
hear(data.words);
|
|
723
|
+
hearCombine(data.combine);
|
|
604
724
|
lastClasses = data.classes ?? [];
|
|
605
725
|
lastResources = data.resources ?? [];
|
|
606
726
|
offerChoices(lastClasses, lastResources, data.empty_classes ?? []);
|
|
@@ -646,6 +766,7 @@ export function wireBooking(form) {
|
|
|
646
766
|
const button = document.createElement('button');
|
|
647
767
|
button.type = 'button';
|
|
648
768
|
button.className = 'booking__slot';
|
|
769
|
+
button.dataset.slot = '';
|
|
649
770
|
// Which one is chosen, said as well as drawn.
|
|
650
771
|
button.setAttribute('aria-pressed', 'false');
|
|
651
772
|
button.textContent = daily
|
|
@@ -660,7 +781,7 @@ export function wireBooking(form) {
|
|
|
660
781
|
if (hint.textContent === words.retime) hint.textContent = '';
|
|
661
782
|
sayEnds(slot);
|
|
662
783
|
offerQuantity(data.quantity_max ?? 1, (slot.free ?? []).length);
|
|
663
|
-
for (const other of slots.querySelectorAll('
|
|
784
|
+
for (const other of slots.querySelectorAll('[data-slot]')) {
|
|
664
785
|
other.classList.remove('is-chosen');
|
|
665
786
|
other.setAttribute('aria-pressed', 'false');
|
|
666
787
|
}
|
|
@@ -705,7 +826,13 @@ export function wireBooking(form) {
|
|
|
705
826
|
// The look — the steps, the week, the parts of the day, the drawn choice
|
|
706
827
|
// — over the form, where the form asks for it.
|
|
707
828
|
if (form.dataset.ui) dressBooking(form, words);
|
|
708
|
-
for (const box of boxes)
|
|
829
|
+
for (const box of boxes) {
|
|
830
|
+
box.addEventListener('change', () => {
|
|
831
|
+
if (single && box.checked) for (const other of boxes) if (other !== box) other.checked = false;
|
|
832
|
+
showTotal();
|
|
833
|
+
void load();
|
|
834
|
+
});
|
|
835
|
+
}
|
|
709
836
|
resource?.addEventListener('change', () => { greyByResource(); void load(); });
|
|
710
837
|
greyByResource();
|
|
711
838
|
// A new first date keeps the stay's length: five nights from the 10th
|
|
@@ -729,8 +856,14 @@ export function wireBooking(form) {
|
|
|
729
856
|
const email = String(data.get('email') ?? '').trim();
|
|
730
857
|
if (picked().length === 0) { say(words.pickService); return; }
|
|
731
858
|
if (!chosen) { say(words.pick); return; }
|
|
732
|
-
|
|
733
|
-
if (
|
|
859
|
+
/** @type {HTMLInputElement} */ const nameInput = q('[name=name]');
|
|
860
|
+
if (name === '') { say(words.needName, [nameInput]); nameInput.focus(); return; }
|
|
861
|
+
if (phone === '' && email === '') {
|
|
862
|
+
/** @type {HTMLInputElement} */ const phoneInput = q('[name=phone]');
|
|
863
|
+
say(words.needContact, [phoneInput, q('[name=email]')]);
|
|
864
|
+
phoneInput.focus();
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
734
867
|
|
|
735
868
|
submit.disabled = true;
|
|
736
869
|
submit.textContent = words.sending;
|
|
@@ -763,7 +896,7 @@ export function wireBooking(form) {
|
|
|
763
896
|
}
|
|
764
897
|
const answer = await fetch(api + 'book', {
|
|
765
898
|
method: 'POST',
|
|
766
|
-
headers: { 'content-type': 'application/json' },
|
|
899
|
+
headers: { 'content-type': 'application/json', ...SPOKEN },
|
|
767
900
|
body: JSON.stringify(body),
|
|
768
901
|
});
|
|
769
902
|
if (answer.status === 409) { say(words.taken); await load(); return; }
|
|
@@ -783,7 +916,11 @@ export function wireBooking(form) {
|
|
|
783
916
|
* @param {{ state: string; start: string; ends: string; amount_minor: number; currency: string; id: string; manage_path: string; telegram_url: string; resource?: string; class?: string; group?: { quantity: number; amount_minor: number; bookings: { resource: string; resource_name?: string }[] } }} made
|
|
784
917
|
*/
|
|
785
918
|
function done(made) {
|
|
786
|
-
|
|
919
|
+
// Everything that asks, gone; the answer stays. By the hooks — the
|
|
920
|
+
// classes are an older kit's markup.
|
|
921
|
+
for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('[data-field], [data-times], [data-services], [data-submit], [data-challenge], .field, .booking__times, .booking__services'))) {
|
|
922
|
+
if (!el.contains(form.querySelector('[data-done]'))) el.hidden = true;
|
|
923
|
+
}
|
|
787
924
|
/** @type {HTMLElement} */ const box = q('[data-done]');
|
|
788
925
|
box.hidden = false;
|
|
789
926
|
// After the lines are written below — see the end of this function.
|
|
@@ -824,10 +961,14 @@ export function wireBooking(form) {
|
|
|
824
961
|
link.setAttribute('aria-label', words.manage);
|
|
825
962
|
link.addEventListener('focus', () => link.select());
|
|
826
963
|
copy.textContent = words.copy;
|
|
964
|
+
/** @type {HTMLElement | null} */ const told = q('[data-keep-said]');
|
|
827
965
|
copy.addEventListener('click', async () => {
|
|
828
966
|
try { await navigator.clipboard.writeText(href); } catch { link.select(); document.execCommand('copy'); }
|
|
829
967
|
copy.textContent = words.copied;
|
|
830
|
-
|
|
968
|
+
// Said as well as drawn: a button's own words changing under the
|
|
969
|
+
// focus is read by some screen readers and not by others.
|
|
970
|
+
if (told) told.textContent = words.copied;
|
|
971
|
+
setTimeout(() => { copy.textContent = words.copy; if (told) told.textContent = ''; }, 1600);
|
|
831
972
|
});
|
|
832
973
|
open.href = href;
|
|
833
974
|
open.textContent = words.open;
|
|
@@ -851,7 +992,7 @@ export function wireBooking(form) {
|
|
|
851
992
|
event.preventDefault();
|
|
852
993
|
const answer = await fetch(api + 'pay', {
|
|
853
994
|
method: 'POST',
|
|
854
|
-
headers: { 'content-type': 'application/json' },
|
|
995
|
+
headers: { 'content-type': 'application/json', ...SPOKEN },
|
|
855
996
|
body: JSON.stringify({ booking: made.id, return: made.manage_path || '/' }),
|
|
856
997
|
});
|
|
857
998
|
if (!answer.ok) { say(await reason(answer)); return; }
|
package/elements/lead.js
CHANGED
|
@@ -27,18 +27,39 @@ export function wireLead(form) {
|
|
|
27
27
|
if (!status || !button) return;
|
|
28
28
|
const original = label?.textContent ?? '';
|
|
29
29
|
|
|
30
|
+
/*
|
|
31
|
+
* A sentence about a field is said on the field (WCAG 3.3.1): the input is
|
|
32
|
+
* marked invalid and described by the sentence, so a screen reader reads
|
|
33
|
+
* it when the field is reached and not only when it appears. It was drawn
|
|
34
|
+
* under the field and tied to nothing (12 October 2026).
|
|
35
|
+
*/
|
|
36
|
+
/** @param {string} field */
|
|
37
|
+
const inputOf = (field) => /** @type {HTMLInputElement | null} */ (form.elements.namedItem(field));
|
|
38
|
+
|
|
30
39
|
/** @param {string} field @param {string} message */
|
|
31
40
|
const showError = (field, message) => {
|
|
32
41
|
/** @type {HTMLElement | null} */ const el = form.querySelector(`[data-error-for="${field}"]`);
|
|
33
42
|
if (!el) return;
|
|
34
43
|
el.textContent = message;
|
|
35
44
|
el.hidden = !message;
|
|
45
|
+
const input = inputOf(field);
|
|
46
|
+
if (!input || !message) return;
|
|
47
|
+
if (!el.id) el.id = `${form.id || 'lead'}-${field}-error`;
|
|
48
|
+
input.setAttribute('aria-invalid', 'true');
|
|
49
|
+
const described = (input.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
|
|
50
|
+
if (!described.includes(el.id)) input.setAttribute('aria-describedby', [...described, el.id].join(' '));
|
|
36
51
|
};
|
|
37
52
|
|
|
38
53
|
const clearErrors = () => {
|
|
39
54
|
for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('[data-error-for]'))) {
|
|
40
55
|
el.textContent = '';
|
|
41
56
|
el.hidden = true;
|
|
57
|
+
const input = inputOf(el.dataset.errorFor ?? '');
|
|
58
|
+
if (!input) continue;
|
|
59
|
+
input.removeAttribute('aria-invalid');
|
|
60
|
+
const rest = (input.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id && id !== el.id);
|
|
61
|
+
if (rest.length) input.setAttribute('aria-describedby', rest.join(' '));
|
|
62
|
+
else input.removeAttribute('aria-describedby');
|
|
42
63
|
}
|
|
43
64
|
};
|
|
44
65
|
|
|
@@ -49,15 +70,16 @@ export function wireLead(form) {
|
|
|
49
70
|
form.removeAttribute('data-state');
|
|
50
71
|
|
|
51
72
|
const data = new FormData(form);
|
|
52
|
-
let
|
|
73
|
+
/** @type {HTMLInputElement | null} */ let first = null;
|
|
53
74
|
for (const field of ['name', 'contact']) {
|
|
54
|
-
const input =
|
|
75
|
+
const input = inputOf(field);
|
|
55
76
|
if (!input?.value.trim()) {
|
|
56
77
|
showError(field, input?.dataset.message ?? '');
|
|
57
|
-
|
|
78
|
+
first ??= input;
|
|
58
79
|
}
|
|
59
80
|
}
|
|
60
|
-
|
|
81
|
+
// And the visitor is taken to the first thing to fix (3.3.1).
|
|
82
|
+
if (first) { first.focus(); return; }
|
|
61
83
|
|
|
62
84
|
button.disabled = true;
|
|
63
85
|
if (label) label.textContent = form.dataset.sending ?? original;
|
package/elements/writing.js
CHANGED
|
@@ -14,15 +14,27 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Collapsed, it is a pill with the phase; a press opens it; the choice is
|
|
16
16
|
* remembered in this browser.
|
|
17
|
+
*
|
|
18
|
+
* ── the stage, while the page has no words ──────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* A scaffold just laid out is a page of empty fields and asterisks, and a
|
|
21
|
+
* developer who opens localhost to watch the agent saw that and nothing else
|
|
22
|
+
* (13 October 2026). So while the page being looked at has not one word in it
|
|
23
|
+
* yet, the element covers it with a stage: a page assembling itself, what the
|
|
24
|
+
* agent is on, what is left, the panel's steps meanwhile, and the promise that
|
|
25
|
+
* the page will appear here by itself. The moment the page has its first
|
|
26
|
+
* words, the stage goes and the page is loaded again — nobody presses
|
|
27
|
+
* anything. A page the files do not know (a listing, an entry) is never
|
|
28
|
+
* covered, nor is any page when the dev server cannot be read.
|
|
17
29
|
*/
|
|
18
|
-
import { PHASES, PHASE_NAMES, phasesOf, sayPhase, meanwhile } from '../lib/phases.mjs';
|
|
30
|
+
import { PHASES, PHASE_NAMES, phasesOf, sayPhase, meanwhile, blankHere } from '../lib/phases.mjs';
|
|
19
31
|
|
|
20
32
|
const STORE = 'jtk-writing:collapsed';
|
|
21
33
|
|
|
22
34
|
const CSS = `
|
|
23
35
|
:host{position:fixed;right:16px;bottom:16px;z-index:2147483000;font:13px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased;
|
|
24
|
-
--bg:#fbfaf7;--ink:#1d1b17;--ink2:#3f3b33;--muted:#6b665c;--faint:#9b958a;--line:#e6e1d6;--line2:#d8d2c4;--ok:#2f7d5a;--attn:#9a6b1c;--shadow:0 12px 36px rgba(30,25,15,.16),0 2px 6px rgba(30,25,15,.08)}
|
|
25
|
-
@media (prefers-color-scheme:dark){:host{--bg:#1c1b18;--ink:#f1ede5;--ink2:#d9d3c7;--muted:#a8a295;--faint:#716c62;--line:#33302a;--line2:#443f36;--ok:#5bb38a;--attn:#d4a24a;--shadow:0 12px 36px rgba(0,0,0,.5)}}
|
|
36
|
+
--paper:#f4f1ea;--glow:rgba(212,162,74,.16);--bg:#fbfaf7;--ink:#1d1b17;--ink2:#3f3b33;--muted:#6b665c;--faint:#9b958a;--line:#e6e1d6;--line2:#d8d2c4;--ok:#2f7d5a;--attn:#9a6b1c;--shadow:0 12px 36px rgba(30,25,15,.16),0 2px 6px rgba(30,25,15,.08)}
|
|
37
|
+
@media (prefers-color-scheme:dark){:host{--paper:#141311;--glow:rgba(212,162,74,.12);--bg:#1c1b18;--ink:#f1ede5;--ink2:#d9d3c7;--muted:#a8a295;--faint:#716c62;--line:#33302a;--line2:#443f36;--ok:#5bb38a;--attn:#d4a24a;--shadow:0 12px 36px rgba(0,0,0,.5)}}
|
|
26
38
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
27
39
|
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
|
28
40
|
a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line2);text-underline-offset:3px}
|
|
@@ -41,7 +53,9 @@ a:hover{text-decoration-color:var(--ink)}
|
|
|
41
53
|
.seg.done,.mini i.done{background:var(--ok)}.seg.current,.mini i.current{background:var(--attn)}.seg.current{animation:pulse 2.4s ease-in-out infinite}
|
|
42
54
|
.seg.unseen{background:transparent;border:1px solid var(--line2)}
|
|
43
55
|
@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){.seg.current,.card.enter{animation:none}}
|
|
44
|
-
|
|
56
|
+
/* One line under the strip: the phase it is in, whole, and the one after —
|
|
57
|
+
seven names in 340 px were seven ellipses (13 October 2026). */
|
|
58
|
+
.names{display:flex;gap:8px;align-items:baseline;margin-top:7px;font-size:12px}.names .current{color:var(--ink);font-weight:600}.names .next{color:var(--faint)}
|
|
45
59
|
.say{display:flex;gap:9px;align-items:flex-start;margin-top:12px;font-weight:600;font-size:13.5px;line-height:1.35}.say i{flex:none;width:8px;height:8px;border-radius:999px;background:var(--attn);margin-top:5px}
|
|
46
60
|
.todo{list-style:none;margin:6px 0 0 17px;color:var(--muted);font-size:12.5px}.todo li+li{margin-top:2px}
|
|
47
61
|
.rule{height:1px;background:var(--line);margin:12px 0 10px}
|
|
@@ -54,6 +68,35 @@ a:hover{text-decoration-color:var(--ink)}
|
|
|
54
68
|
.foot{margin-top:10px;color:var(--faint);font-size:11px}
|
|
55
69
|
`;
|
|
56
70
|
|
|
71
|
+
const STAGE_CSS = `
|
|
72
|
+
.stage{position:fixed;inset:0;z-index:2147482999;display:grid;place-items:center;overflow:auto;padding:32px 20px;
|
|
73
|
+
background:radial-gradient(1200px 600px at 50% -10%,var(--glow),transparent 60%),var(--paper);color:var(--ink);
|
|
74
|
+
font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;animation:fade .4s ease both}
|
|
75
|
+
.stage.leaving{animation:out .35s ease both}
|
|
76
|
+
@keyframes fade{from{opacity:0}to{opacity:1}}@keyframes out{to{opacity:0}}
|
|
77
|
+
.wrap{width:min(560px,100%);display:grid;gap:22px}
|
|
78
|
+
.sheet{position:relative;height:210px;border-radius:18px;background:var(--bg);border:1px solid var(--line);box-shadow:var(--shadow);overflow:hidden;padding:18px}
|
|
79
|
+
.bar{height:10px;width:34%;border-radius:999px}
|
|
80
|
+
.row{display:flex;gap:8px;margin-top:22px}.row b{display:block;height:9px;border-radius:999px}
|
|
81
|
+
.hero{height:46px;width:72%;border-radius:12px;margin-top:18px}
|
|
82
|
+
.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:18px}.cards b{display:block;height:46px;border-radius:10px}
|
|
83
|
+
.sk{background:linear-gradient(90deg,var(--line) 0%,var(--line2) 50%,var(--line) 100%);background-size:200% 100%;animation:shine 1.8s linear infinite;opacity:0;transform:translateY(6px)}
|
|
84
|
+
.sk{animation:shine 1.8s linear infinite,rise .6s cubic-bezier(.22,.61,.36,1) forwards}
|
|
85
|
+
.d1{animation-delay:0s,.1s}.d2{animation-delay:.1s,.35s}.d3{animation-delay:.2s,.6s}.d4{animation-delay:.3s,.85s}.d5{animation-delay:.4s,1.1s}.d6{animation-delay:.5s,1.35s}
|
|
86
|
+
@keyframes shine{to{background-position:-200% 0}}@keyframes rise{to{opacity:1;transform:none}}
|
|
87
|
+
.caret{position:absolute;right:18px;bottom:16px;display:flex;gap:5px}.caret i{width:6px;height:6px;border-radius:999px;background:var(--attn);animation:dot 1.2s ease-in-out infinite}
|
|
88
|
+
.caret i:nth-child(2){animation-delay:.15s}.caret i:nth-child(3){animation-delay:.3s}@keyframes dot{50%{opacity:.25;transform:translateY(-3px)}}
|
|
89
|
+
.kicker{font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--faint)}
|
|
90
|
+
.big{font-size:clamp(24px,4vw,32px);line-height:1.15;font-weight:650;letter-spacing:-.01em;margin-top:6px}
|
|
91
|
+
.now{display:flex;gap:10px;align-items:flex-start;margin-top:10px;color:var(--ink2)}.now i{flex:none;width:9px;height:9px;border-radius:999px;background:var(--attn);margin-top:7px;animation:pulse 2.4s ease-in-out infinite}
|
|
92
|
+
.promise{color:var(--muted);font-size:14px}
|
|
93
|
+
.right{margin:6px 0 0 19px;color:var(--muted);font-size:13px}
|
|
94
|
+
.stage .strip{margin-top:0}
|
|
95
|
+
.cols{display:grid;grid-template-columns:1fr 1fr;gap:18px}@media (max-width:560px){.cols{grid-template-columns:1fr}}
|
|
96
|
+
.stage .todo{margin:6px 0 0;padding-left:16px;list-style:disc}
|
|
97
|
+
@media (prefers-reduced-motion:reduce){.stage,.stage.leaving,.sk,.caret i,.now i{animation:none!important}.sk{opacity:1;transform:none}}
|
|
98
|
+
`;
|
|
99
|
+
|
|
57
100
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
58
101
|
|
|
59
102
|
function ago(iso) {
|
|
@@ -90,7 +133,7 @@ class JtkWriting extends HTMLElement {
|
|
|
90
133
|
connectedCallback() {
|
|
91
134
|
const root = this.attachShadow({ mode: 'open' });
|
|
92
135
|
const style = document.createElement('style');
|
|
93
|
-
style.textContent = CSS;
|
|
136
|
+
style.textContent = CSS + STAGE_CSS;
|
|
94
137
|
this.box = document.createElement('div');
|
|
95
138
|
root.append(style, this.box);
|
|
96
139
|
root.addEventListener('click', (event) => {
|
|
@@ -128,6 +171,19 @@ class JtkWriting extends HTMLElement {
|
|
|
128
171
|
const where = phasesOf(progress, half);
|
|
129
172
|
const n = where.index + 1;
|
|
130
173
|
|
|
174
|
+
// The stage, while this page has not one word in it (see the top).
|
|
175
|
+
const blank = !this.failed && blankHere(progress);
|
|
176
|
+
if (this.wasBlank && !blank && progress !== null) {
|
|
177
|
+
// Its first words: let the page be the page.
|
|
178
|
+
this.leave();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.wasBlank = blank;
|
|
182
|
+
if (blank) {
|
|
183
|
+
this.stage(where, progress, half);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
131
187
|
if (this.collapsed) {
|
|
132
188
|
this.show(`<button class="pill" data-toggle aria-expanded="false" aria-label="Where the site is: phase ${n} of 7">
|
|
133
189
|
<span class="mini">${where.phases.map((p) => `<i class="${p.state}"></i>`).join('')}</span>
|
|
@@ -148,15 +204,18 @@ class JtkWriting extends HTMLElement {
|
|
|
148
204
|
? `<p class="note">The platform could not be read: ${esc(platform.error)}</p>`
|
|
149
205
|
: `<p class="note">Not connected to the platform yet — in the working tree: <code>npx jtkit session <session_id> <watch_key></code>, both from the session's answer.</p>`;
|
|
150
206
|
|
|
207
|
+
const last = this.state?.lastChange ?? null;
|
|
151
208
|
const foot = [
|
|
152
|
-
this.failed ? 'the dev server did not answer'
|
|
209
|
+
this.failed ? 'the dev server did not answer'
|
|
210
|
+
: last ? `last change: ${last.path.split('/').slice(-2).join('/')} · ${ago(last.at)}`
|
|
211
|
+
: progress?.changedAt ? `read from the files, changed ${ago(progress.changedAt)}` : 'read from the files',
|
|
153
212
|
half?.until ? `platform through the session until ${until(half.until)}` : '',
|
|
154
213
|
].filter(Boolean).join(' · ');
|
|
155
214
|
|
|
156
215
|
this.show(`<section class="card${this.enter ? ' enter' : ''}" aria-label="Where the site is">
|
|
157
216
|
<div class="head"><span class="title">What the agent is doing</span><span class="count">phase ${n} of 7</span><button class="hide" data-toggle aria-label="Collapse">–</button></div>
|
|
158
217
|
<ol class="strip" aria-label="The seven phases">${where.phases.map((p, i) => `<li class="seg ${p.state}" title="${i + 1}. ${PHASE_NAMES[p.key]}"></li>`).join('')}</ol>
|
|
159
|
-
<
|
|
218
|
+
<p class="names"><span class="current">${n}. ${esc(PHASE_NAMES[where.current] ?? '')}</span>${where.index < 6 ? `<span class="next">next: ${esc(PHASE_NAMES[where.phases[where.index + 1].key])}</span>` : ''}</p>
|
|
160
219
|
<p class="say"><i></i><span>${esc(say)}</span></p>
|
|
161
220
|
${todo.length > 0 && where.current !== 'preview'
|
|
162
221
|
? `<ul class="todo">${todo.slice(0, 4).map((line) => `<li>${esc(line)}</li>`).join('')}${todo.length > 4 ? `<li>+ ${todo.length - 4} more — <code>jtkit progress</code></li>` : ''}</ul>`
|
|
@@ -169,6 +228,48 @@ class JtkWriting extends HTMLElement {
|
|
|
169
228
|
</section>`);
|
|
170
229
|
}
|
|
171
230
|
|
|
231
|
+
/** The stage over a page with no words yet: what is happening, and that it will appear. */
|
|
232
|
+
stage(where, progress, half) {
|
|
233
|
+
const say = sayPhase(where, progress, half);
|
|
234
|
+
const todo = (progress?.missing ?? []).slice(0, 4);
|
|
235
|
+
const steps = meanwhile(half);
|
|
236
|
+
const open = half ? steps.open : [];
|
|
237
|
+
document.documentElement.style.overflow = 'hidden';
|
|
238
|
+
this.show(`<section class="stage" role="status" aria-live="polite" aria-label="The site is being written">
|
|
239
|
+
<div class="wrap">
|
|
240
|
+
<div class="sheet" aria-hidden="true">
|
|
241
|
+
<div class="bar sk d1"></div>
|
|
242
|
+
<div class="hero sk d2"></div>
|
|
243
|
+
<div class="row"><b class="sk d3" style="width:58%"></b><b class="sk d3" style="width:24%"></b></div>
|
|
244
|
+
<div class="cards"><b class="sk d4"></b><b class="sk d5"></b><b class="sk d6"></b></div>
|
|
245
|
+
<div class="caret"><i></i><i></i><i></i></div>
|
|
246
|
+
</div>
|
|
247
|
+
<div>
|
|
248
|
+
<p class="kicker">Phase ${where.index + 1} of 7 · ${esc(PHASE_NAMES[where.current] ?? '')}</p>
|
|
249
|
+
<h1 class="big">The site is being written</h1>
|
|
250
|
+
<p class="now"><i></i><span>${esc(say)}</span></p>
|
|
251
|
+
${this.state?.lastChange ? `<p class="right">right now: <code>${esc(this.state.lastChange.path)}</code> · ${esc(ago(this.state.lastChange.at))}</p>` : ''}
|
|
252
|
+
</div>
|
|
253
|
+
<ol class="strip" aria-hidden="true">${where.phases.map((p) => `<li class="seg ${p.state}"></li>`).join('')}</ol>
|
|
254
|
+
<div class="cols">
|
|
255
|
+
${todo.length > 0 ? `<div><p class="label">Still to do</p><ul class="todo">${todo.map((line) => `<li>${esc(line)}</li>`).join('')}</ul></div>` : ''}
|
|
256
|
+
${open.length > 0
|
|
257
|
+
? `<div><p class="label">Meanwhile, in the panel</p><ul class="steps">${open.map((s) => `<li><i class="o"></i><span>${esc(s.name)}</span></li>`).join('')}</ul>${half?.panel ? `<a class="panel" href="${esc(half.panel)}" target="_blank" rel="noopener">Open the panel ↗</a>` : ''}</div>`
|
|
258
|
+
: ''}
|
|
259
|
+
</div>
|
|
260
|
+
<p class="promise">This page appears here by itself the moment it has its first words — leave the tab open.</p>
|
|
261
|
+
</div>
|
|
262
|
+
</section>`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The page has its words: the stage fades and the page is read again. */
|
|
266
|
+
leave() {
|
|
267
|
+
this.wasBlank = false;
|
|
268
|
+
const stage = this.box.querySelector('.stage');
|
|
269
|
+
stage?.classList.add('leaving');
|
|
270
|
+
setTimeout(() => location.reload(), stage ? 350 : 0);
|
|
271
|
+
}
|
|
272
|
+
|
|
172
273
|
/** Put this on the page, unless it is what is there already. */
|
|
173
274
|
show(html) {
|
|
174
275
|
const key = html.replace(' enter', '');
|
|
@@ -176,6 +277,7 @@ class JtkWriting extends HTMLElement {
|
|
|
176
277
|
this.shown = key;
|
|
177
278
|
this.box.innerHTML = html;
|
|
178
279
|
this.enter = false;
|
|
280
|
+
if (!html.includes('class="stage"')) document.documentElement.style.removeProperty('overflow');
|
|
179
281
|
}
|
|
180
282
|
}
|
|
181
283
|
|