@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.
@@ -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
- * `renderBookingForm`'s (`@jtakeit/kit/booking`); a hand-written form keeps
27
- * its `data-*` hooks and input `name`s.
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
- /** @template {HTMLElement} T @param {string} sel @returns {T} */
106
- const q = (sel) => /** @type {any} */ (form.querySelector(sel));
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 = q('[data-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(() => form.classList.remove('booking--unsaid'), 3000);
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
- /** @param {string} text */
298
- function say(text) {
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.closest('.booking__service');
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
- let note = row?.querySelector('.booking__service-off') ?? null;
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.querySelector('.booking__service-name');
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 name = box.closest('.booking__service')?.querySelector('.booking__service-name');
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('.booking__slot')) {
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) box.addEventListener('change', () => { showTotal(); void load(); });
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
- if (name === '') { /** @type {HTMLInputElement} */ (q('[name=name]')).focus(); return; }
733
- if (phone === '' && email === '') { say(words.needContact); return; }
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
- for (const el of /** @type {NodeListOf<HTMLElement>} */ (form.querySelectorAll('.field, .booking__times, .booking__services, [data-submit], [data-challenge]'))) el.hidden = true;
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
- setTimeout(() => { copy.textContent = words.copy; }, 1600);
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 ok = true;
73
+ /** @type {HTMLInputElement | null} */ let first = null;
53
74
  for (const field of ['name', 'contact']) {
54
- const input = /** @type {HTMLInputElement | null} */ (form.elements.namedItem(field));
75
+ const input = inputOf(field);
55
76
  if (!input?.value.trim()) {
56
77
  showError(field, input?.dataset.message ?? '');
57
- ok = false;
78
+ first ??= input;
58
79
  }
59
80
  }
60
- if (!ok) return;
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;
@@ -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
- .names{display:flex;gap:4px;margin-top:6px}.names span{flex:1;min-width:0;font-size:10px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.names .current{color:var(--ink);font-weight:600}.names .done{color:var(--muted)}
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 &lt;session_id&gt; &lt;watch_key&gt;</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' : progress?.changedAt ? `read from the files, changed ${ago(progress.changedAt)}` : 'read from the files',
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
- <div class="names" aria-hidden="true">${where.phases.map((p, i) => `<span class="${p.state}">${i + 1}. ${PHASE_NAMES[p.key]}</span>`).join('')}</div>
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