@jtakeit/kit 0.3.0 → 0.5.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 CHANGED
@@ -58,6 +58,15 @@ A form written by hand is wired too, as long as it keeps the `data-*` hooks
58
58
  and the input `name`s of the rendered one: `wireAll()` walks the document at
59
59
  load and again on the panel's `fl:placed`.
60
60
 
61
+ **By default the form arrives dressed** — two steps, the day as a week of
62
+ seven that never offers yesterday, the times by the part of the day, a drawn
63
+ choice of whom, a master chosen greying out what they do not do, the time kept
64
+ across changes, the end of the visit, a 24-hour clock, and the success showing
65
+ the booking's link whole with *Copy* (`@jtakeit/kit/elements/booking-ui`,
66
+ switched on by `renderBookingForm`'s `ui`, `false` for the plain form). Pass
67
+ `open` (the days the business opens) and each resource's `does` for the week
68
+ and the greying to know.
69
+
61
70
  **The look** is the site's to give. `@jtakeit/kit/booking.css` makes the form
62
71
  legible and nothing more; `field`, `field__input`, `booking__slot`,
63
72
  `booking__submit` are names to restyle with the site's own fields and
@@ -163,8 +172,35 @@ build that way yourself (`SITE_URL=https://x.invalid/p/check/`) and pass
163
172
  `--dist`. `@jtakeit/astro catalogue` is this tool, signing the file as the
164
173
  scaffold.
165
174
 
175
+ ## The page says where it is
176
+
177
+ ```
178
+ npx jtkit session <session_id> <watch_key> # --api https://api.stg.jtakeit.com on staging
179
+ npx jtkit session --forget
180
+ ```
181
+
182
+ Under `astro dev` every page carries a small card, bottom right: the seven
183
+ phases of a site being written — the brief, the layout, the writing, read
184
+ off the files; the judge's word, the repository, the build and the preview
185
+ from the platform — and the steps of the panel's setting up the developer
186
+ can take meanwhile, with the way there. The platform's half comes through
187
+ the dev session: `open_dev_session` answers with a `watch_key`, and this
188
+ command keeps it in `jtk/session.json`, out of git, for the dev server to
189
+ present. The key reads that one session's progress and nothing else, and
190
+ dies with the session. The card is `@jtakeit/kit/writing`; the rules it
191
+ draws by are `@jtakeit/kit/phases`, the panel's own; and nothing of it is in
192
+ a build.
193
+
166
194
  ## Also here
167
195
 
196
+ - `@jtakeit/kit/progress` — where the site is, read off the files: the brief's
197
+ fill, the pages written, the entries, the settings, what is missing. The
198
+ scaffold's dev server serves it as `/_jtk/progress.json` for the panel, and
199
+ `jtkit progress` prints the same list for the agent; a rule in one is a
200
+ rule in both, and nothing in it is declared.
201
+ - `@jtakeit/kit/phases` — the seven phases from the two halves, by the panel's
202
+ rules: a later phase counts only once every one before it does; written is
203
+ the still-to-do list being empty.
168
204
  - `@jtakeit/kit/money` — a price as the page says it: «2 300 ₴», no kopecks
169
205
  on a whole price, no sign where the site has not said its currency.
170
206
  - `@jtakeit/kit/booking/words` — the form's words per language, the salon's
package/bin/jtkit.mjs CHANGED
@@ -9,6 +9,10 @@
9
9
  * write jtk/catalogue.json from a declaration and check it against the built pages
10
10
  * jtkit lint [--dist dist] [--root .] [--site-url https://…]
11
11
  * the annotation lint the platform's build runs, on this working tree
12
+ * jtkit progress [--root .] [--json]
13
+ * where the site is, read off the files: the brief, the pages, the entries, what is missing
14
+ * jtkit session <session_id> <watch_key> [--api <url>] [--root .] | jtkit session --forget
15
+ * let the page the dev server serves read the platform's half through the dev session
12
16
  */
13
17
  import { pathToFileURL } from 'node:url';
14
18
  import { join, dirname, resolve } from 'node:path';
@@ -46,6 +50,46 @@ switch (command) {
46
50
  }
47
51
  break;
48
52
  }
53
+ case 'session': {
54
+ // The dev session's watch key, kept beside the content and out of git:
55
+ // the dev server presents it for one read-only answer — where the site
56
+ // is on the platform and what the developer can do in the panel — which
57
+ // the page then shows bottom right (elements/writing.js).
58
+ const { writeFileSync, unlinkSync, mkdirSync, existsSync } = await import('node:fs');
59
+ const root = resolve(flag('root', process.cwd()));
60
+ const file = join(root, 'jtk', 'session.json');
61
+ if (has('forget')) {
62
+ if (existsSync(file)) unlinkSync(file);
63
+ console.log('forgotten: the page no longer reads the platform');
64
+ break;
65
+ }
66
+ const takesValue = new Set(['--api', '--root']);
67
+ const positional = [];
68
+ for (let i = 0; i < rest.length; i++) {
69
+ if (takesValue.has(rest[i])) { i++; continue; }
70
+ if (rest[i].startsWith('--')) continue;
71
+ positional.push(rest[i]);
72
+ }
73
+ const [session, key] = positional;
74
+ if (!session || !key) {
75
+ console.error("jtkit session <session_id> <watch_key> [--api <url>] — both are in open_dev_session's answer");
76
+ process.exit(1);
77
+ }
78
+ const api = flag('api', process.env.JTK_API_URL || 'https://api.jtakeit.com').replace(/\/$/, '');
79
+ mkdirSync(dirname(file), { recursive: true });
80
+ writeFileSync(file, JSON.stringify({ session, key, api }, null, 2) + '\n');
81
+ console.log(`${relative(process.cwd(), file)} written — the page the dev server serves now shows the platform's half through session ${session} at ${api}. Not in git; jtkit session --forget drops it.`);
82
+ break;
83
+ }
84
+ case 'progress': {
85
+ // The same reading the scaffold's dev server serves the panel as
86
+ // /_jtk/progress.json — what the owner sees as missing is what this
87
+ // prints (jtakeit-core, wiki/73).
88
+ const { readProgress, sayProgress } = await import(pathToFileURL(join(HERE, '..', 'lib', 'progress.mjs')).href);
89
+ const progress = await readProgress(resolve(flag('root', '.')));
90
+ console.log(has('json') ? JSON.stringify(progress, null, 2) : sayProgress(progress));
91
+ process.exit(progress.missing.length === 0 ? 0 : 1);
92
+ }
49
93
  case 'lint': {
50
94
  // The annotation lint the platform's build runs, on this working tree:
51
95
  // `dist/` against `jtk/` and the catalogue, every finding at once,
package/booking.css CHANGED
@@ -6,7 +6,7 @@
6
6
  * buttons, the way the scaffold's booking.md asks. Keep the `data-*` hooks and
7
7
  * the `[hidden]` rule below whatever you do to the rest.
8
8
  */
9
- .booking { display: grid; gap: var(--space-4, 1rem); max-width: 34rem; }
9
+ .booking { display: grid; gap: var(--space-4, 1rem); max-width: 34rem; scroll-margin-top: 5rem; }
10
10
 
11
11
  /* The kind's words, until the platform has said them: the space kept, the
12
12
  salon's word not shown at a car rental. */
@@ -30,6 +30,60 @@
30
30
  .booking__done-line { font-weight: 600; margin: 0; }
31
31
  .booking__done-when { margin: 0; }
32
32
 
33
+ /* The look, by default (booking-ui.js): legible, and the site's to restyle.
34
+ Keep `.is-replaced` and `[data-stage]` whatever you do to the rest. */
35
+ .booking > * { min-width: 0; }
36
+ .booking__ends { margin: 0.35rem 0 0; }
37
+ .booking__service.is-off { opacity: 0.5; cursor: default; }
38
+ .booking__service-off { font-size: 0.8em; opacity: 0.75; }
39
+ /* the native control stays for the form; the drawn one is what is seen */
40
+ .booking .is-replaced { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); opacity: 0; pointer-events: none; }
41
+ /* the week of days */
42
+ .booking__days-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin: 0 0 0.4rem; }
43
+ .booking__days-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 0.3rem; }
44
+ .booking__day, .booking__days-arrow, .booking__part, .booking__next, .booking__back, .booking__dd-trigger, .booking__dd-option, .booking__keep-copy {
45
+ font: inherit; color: inherit; background: transparent; border: 1px solid var(--color-line, currentColor); border-radius: var(--radius-sm, 0.5rem); cursor: pointer;
46
+ }
47
+ .booking__day { display: grid; gap: 0.1rem; padding: 0.4rem 0.1rem; text-align: center; line-height: 1.2; }
48
+ .booking__day-wd { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
49
+ .booking__day:disabled, .booking__days-arrow:disabled { opacity: 0.35; cursor: default; }
50
+ .booking__day.is-chosen { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
51
+ .booking__days-arrow { padding: 0.3rem 0.6rem; }
52
+ /* the times by the part of the day */
53
+ .booking__parts { display: flex; flex-wrap: wrap; gap: 0.25rem; margin: 0 0 0.5rem; }
54
+ .booking__part { padding: 0.3rem 0.7rem; border-radius: 999px; }
55
+ .booking__part.is-current { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
56
+ .booking__part-n { font-size: 0.8em; opacity: 0.75; margin-left: 0.35rem; }
57
+ /* the two steps */
58
+ .booking__stepper { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0; padding: 0; list-style: none; opacity: 0.75; }
59
+ .booking__step { display: inline-flex; align-items: center; gap: 0.4rem; }
60
+ .booking__step-n { display: inline-grid; place-items: center; width: 1.4rem; height: 1.4rem; border: 1px solid currentColor; border-radius: 999px; font-size: 0.8em; }
61
+ .booking__step.is-current { opacity: 1; font-weight: 600; }
62
+ .booking__step.is-current .booking__step-n, .booking__step.is-done .booking__step-n { background: var(--color-ink, #111); color: var(--color-bg, #fff); border-color: var(--color-ink, #111); }
63
+ .booking[data-stage='1'] > [data-step='2'], .booking[data-stage='2'] > [data-step='1'] { display: none; }
64
+ .booking[data-stage='done'] > .booking__stepper, .booking[data-stage='done'] > .booking__summary, .booking[data-stage='done'] > .booking__need, .booking[data-stage='done'] > .booking__next { display: none; }
65
+ .booking__next { padding: 0.6rem 1.2rem; }
66
+ .booking__need { margin: 0; color: var(--color-danger, #b3261e); }
67
+ .booking__summary { display: grid; gap: 0.6rem; padding: 0.9rem 1rem; border: 1px solid var(--line, #e5e1d8); border-radius: var(--radius-sm, 0.5rem); }
68
+ .booking__summary-label { margin: 0; font-size: 0.75em; letter-spacing: 0.1em; text-transform: uppercase; opacity: 0.7; }
69
+ .booking__summary-list { display: grid; gap: 0.4rem; margin: 0; }
70
+ .booking__summary-row dt { font-size: 0.8em; opacity: 0.7; }
71
+ .booking__summary-row dd { margin: 0; }
72
+ .booking__back { justify-self: start; padding: 0.3rem 0.8rem; border-radius: 999px; }
73
+ /* the drawn choice of whom */
74
+ .booking__dd { position: relative; }
75
+ .booking__dd-trigger { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; width: 100%; padding: 0.55rem 0.75rem; text-align: left; }
76
+ .booking__dd-list { position: absolute; inset-inline: 0; top: calc(100% + 0.25rem); z-index: 5; display: grid; padding: 0.25rem; background: var(--color-bg, #fff); border: 1px solid var(--line, #e5e1d8); border-radius: var(--radius-sm, 0.5rem); box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.3); }
77
+ .booking__dd-option { border: 0; padding: 0.5rem 0.6rem; text-align: left; }
78
+ .booking__dd-option:hover, .booking__dd-option:focus-visible { background: var(--line, #e5e1d8); }
79
+ .booking__dd-option.is-chosen { font-weight: 600; }
80
+ .booking__dd-option:disabled { opacity: 0.45; cursor: default; }
81
+ /* the link, kept */
82
+ .booking__keep { display: grid; gap: 0.4rem; }
83
+ .booking__keep-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
84
+ .booking__keep-link { flex: 1; min-width: 12rem; font: inherit; }
85
+ .booking__keep-copy, .booking__keep-open { display: inline-flex; align-items: center; padding: 0.45rem 0.8rem; text-decoration: none; }
86
+
33
87
  /* `hidden` means hidden, whatever the element's class says: every state here
34
88
  is shown and hidden by the attribute, and a class that sets `display` would
35
89
  otherwise win (an agent's rehearsal, 30 September 2026). */
@@ -0,0 +1,371 @@
1
+ /**
2
+ * The booking form's look, by default: what a visitor on a phone between
3
+ * two appointments needs, drawn over the kit's own markup.
4
+ *
5
+ * steps two steps — what and when, then who is coming — with the choice
6
+ * summarised beside the name, and a way back
7
+ * week the day as a row of seven, rolling from today, closed days
8
+ * struck out, never yesterday
9
+ * parts the times by the part of the day — morning, afternoon, evening —
10
+ * one part showing at a time, so thirty pills become a dozen and
11
+ * nothing scrolls; a part of one or two times joins its neighbour
12
+ * select the choice of whom as a drawn list rather than the browser's own
13
+ *
14
+ * `renderBookingForm` turns all four on with `data-ui` on the form; a site
15
+ * passes `ui: false` for the plain form, or names the ones it wants. Every
16
+ * piece reads the element's hooks and leaves them where they are — the
17
+ * native day input is still what is submitted, the kit's pills are still the
18
+ * pills — so the protocol is untouched and `preflight` reads the same form.
19
+ *
20
+ * The look is the kit's reference and the site's to restyle: `booking.css`
21
+ * makes it legible, the scaffold's `global.css` makes it the site's.
22
+ */
23
+ import { PARTS, partsOf, parseDay, ymd, DAY_NAMES, to24 } from '../lib/booking/parts.mjs';
24
+
25
+ /**
26
+ * @param {HTMLFormElement} form
27
+ * @param {Record<string, string>} words
28
+ */
29
+ export function dressBooking(form, words) {
30
+ if (form.dataset.dressed === '1') return;
31
+ form.dataset.dressed = '1';
32
+ const wants = new Set((form.dataset.ui ?? '').split(/\s+/).filter(Boolean));
33
+ const lang = document.documentElement.lang || form.dataset.locale || 'en';
34
+ if (wants.has('select')) {
35
+ for (const select of /** @type {NodeListOf<HTMLSelectElement>} */ (form.querySelectorAll('select[name=resource], select[name=service]'))) drawnSelect(select);
36
+ }
37
+ if (wants.has('week')) {
38
+ const day = /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=day]'));
39
+ if (day) weekStrip(day, { open: (form.dataset.open ?? '').split(',').filter(Boolean), lang, words });
40
+ }
41
+ if (wants.has('parts')) partsOfDay(form, words);
42
+ if (wants.has('steps')) twoSteps(form, words, lang);
43
+ }
44
+
45
+ /**
46
+ * A week of days in a row: seven chips from the first day that can be
47
+ * booked, the month above them, arrows for the next and the previous week.
48
+ * The native input stays — hidden — and is what the form submits; a press on
49
+ * a day sets it and fires `change`, which is what the element listens to.
50
+ *
51
+ * @param {HTMLInputElement} input
52
+ * @param {{ open: string[]; lang: string; words: Record<string, string> }} options
53
+ */
54
+ export function weekStrip(input, options) {
55
+ if (input.dataset.week === '1') return;
56
+ input.dataset.week = '1';
57
+ const open = new Set(options.open.length > 0 ? options.open : DAY_NAMES);
58
+ // Today is the browser's, not the build's: `min` was written when the
59
+ // site was built, and a site built on Friday is read on Saturday.
60
+ const now = new Date();
61
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
62
+ const minSaid = parseDay(input.min);
63
+ const floor = minSaid && minSaid > today ? minSaid : today;
64
+ const max = parseDay(input.max) ?? new Date(floor.getFullYear(), floor.getMonth() + 2, floor.getDate());
65
+ const monthName = new Intl.DateTimeFormat(options.lang, { month: 'long', year: 'numeric' });
66
+ const weekday = new Intl.DateTimeFormat(options.lang, { weekday: 'short' });
67
+ const plus = (d, n) => { const out = new Date(d); out.setDate(out.getDate() + n); return out; };
68
+ const choosable = (d) => d >= floor && d <= max && open.has(DAY_NAMES[(d.getDay() + 6) % 7]);
69
+
70
+ const root = document.createElement('div');
71
+ root.className = 'booking__days';
72
+ root.setAttribute('role', 'group');
73
+ root.setAttribute('aria-label', input.labels?.[0]?.textContent?.trim() || options.words.day || 'Day');
74
+ input.classList.add('is-replaced');
75
+ input.setAttribute('aria-hidden', 'true');
76
+ input.tabIndex = -1;
77
+ input.insertAdjacentElement('afterend', root);
78
+ const chosenAtStart = parseDay(input.value);
79
+ let start = chosenAtStart && chosenAtStart >= floor && chosenAtStart > plus(floor, 6) ? chosenAtStart : new Date(floor);
80
+
81
+ function draw() {
82
+ const chosen = input.value;
83
+ const week = Array.from({ length: 7 }, (_, i) => plus(start, i));
84
+ const span = week[0].getMonth() === week[6].getMonth()
85
+ ? monthName.format(week[0])
86
+ : `${monthName.format(week[0]).split(' ')[0]} – ${monthName.format(week[6])}`;
87
+ root.innerHTML = `
88
+ <div class="booking__days-head">
89
+ <span class="booking__days-month">${span}</span>
90
+ <span class="booking__days-nav">
91
+ <button type="button" class="booking__days-arrow" data-go="-7" aria-label="${options.words.prevWeek ?? 'Previous week'}"${plus(start, -1) >= floor ? '' : ' disabled'}>‹</button>
92
+ <button type="button" class="booking__days-arrow" data-go="7" aria-label="${options.words.nextWeek ?? 'Next week'}"${plus(start, 7) <= max ? '' : ' disabled'}>›</button>
93
+ </span>
94
+ </div>
95
+ <div class="booking__days-row">
96
+ ${week.map((d) => {
97
+ const value = ymd(d);
98
+ const ok = choosable(d);
99
+ return `<button type="button" class="booking__day${value === chosen ? ' is-chosen' : ''}" data-day="${value}"${ok ? '' : ' disabled'} aria-pressed="${value === chosen}"><span class="booking__day-wd">${weekday.format(d)}</span><span class="booking__day-n">${d.getDate()}</span></button>`;
100
+ }).join('')}
101
+ </div>`;
102
+ }
103
+ root.addEventListener('click', (event) => {
104
+ const target = event.target instanceof Element ? event.target : null;
105
+ const arrow = target?.closest('[data-go]');
106
+ if (arrow instanceof HTMLButtonElement) {
107
+ start = plus(start, Number(arrow.dataset.go));
108
+ if (start < floor) start = new Date(floor);
109
+ draw();
110
+ return;
111
+ }
112
+ const dayButton = target?.closest('[data-day]');
113
+ if (dayButton instanceof HTMLButtonElement && !dayButton.disabled) {
114
+ input.value = dayButton.dataset.day ?? '';
115
+ input.dispatchEvent(new Event('change', { bubbles: true }));
116
+ draw();
117
+ }
118
+ });
119
+ input.addEventListener('change', draw);
120
+ draw();
121
+ }
122
+
123
+ /**
124
+ * The times by the part of the day, one part showing at a time. The element
125
+ * writes the pills; this sorts them as they arrive. A pill the element has
126
+ * chosen decides which part shows.
127
+ *
128
+ * @param {HTMLFormElement} form
129
+ * @param {Record<string, string>} words
130
+ */
131
+ export function partsOfDay(form, words) {
132
+ const slots = /** @type {HTMLElement | null} */ (form.querySelector('[data-slots]'));
133
+ if (!slots || slots.dataset.parts === '1') return;
134
+ slots.dataset.parts = '1';
135
+ const tabs = document.createElement('div');
136
+ tabs.className = 'booking__parts';
137
+ tabs.setAttribute('role', 'tablist');
138
+ tabs.hidden = true;
139
+ slots.insertAdjacentElement('beforebegin', tabs);
140
+ const timeOf = (pill) => to24((pill.textContent ?? '').trim()).slice(0, 5);
141
+ const hourOf = (pill) => Number(timeOf(pill).slice(0, 2));
142
+ /** @type {WeakMap<HTMLElement, string>} */
143
+ const parts = new WeakMap();
144
+ let current = '';
145
+
146
+ const show = () => {
147
+ for (const pill of /** @type {NodeListOf<HTMLElement>} */ (slots.querySelectorAll('.booking__slot'))) {
148
+ pill.hidden = current !== '' && parts.get(pill) !== current;
149
+ }
150
+ for (const tab of /** @type {NodeListOf<HTMLElement>} */ (tabs.querySelectorAll('.booking__part'))) {
151
+ tab.classList.toggle('is-current', tab.dataset.part === current);
152
+ tab.setAttribute('aria-selected', String(tab.dataset.part === current));
153
+ }
154
+ };
155
+ const sort = () => {
156
+ const pills = /** @type {HTMLElement[]} */ ([...slots.querySelectorAll('.booking__slot')]);
157
+ // A time on the pill is the platform's clock in the site's language;
158
+ // the hour read off it on 24 hours whatever that clock says.
159
+ const sorted = partsOf(pills.map(hourOf));
160
+ pills.forEach((pill, i) => parts.set(pill, sorted[i]));
161
+ const have = new Set(sorted);
162
+ const chosen = pills.find((pill) => pill.classList.contains('is-chosen'));
163
+ tabs.innerHTML = PARTS.filter((part) => have.has(part))
164
+ .map((part) => {
165
+ const own = pills.filter((pill) => parts.get(pill) === part).map(timeOf);
166
+ return `<button type="button" class="booking__part" role="tab" data-part="${part}">${words[part] ?? part}<span class="booking__part-n">${own[0]}–${own[own.length - 1]}</span></button>`;
167
+ })
168
+ .join('');
169
+ tabs.hidden = have.size < 2;
170
+ current = chosen ? parts.get(chosen) ?? '' : have.has(/** @type {never} */ (current)) ? current : [...have][0] ?? '';
171
+ show();
172
+ };
173
+ tabs.addEventListener('click', (event) => {
174
+ const tab = event.target instanceof Element ? event.target.closest('[data-part]') : null;
175
+ if (!(tab instanceof HTMLElement)) return;
176
+ current = tab.dataset.part ?? '';
177
+ show();
178
+ });
179
+ new MutationObserver(sort).observe(slots, { childList: true });
180
+ sort();
181
+ }
182
+
183
+ /**
184
+ * The form in two steps: the services, whom, the day and the time first; the
185
+ * name and the rest once those are chosen, with the choice summarised beside
186
+ * them and a way back. One form throughout — every field keeps its place,
187
+ * the element keeps reading them — so with scripts off it is the one form,
188
+ * whole. The success is a step of its own: the element shows its box, this
189
+ * takes the steps and the summary away.
190
+ *
191
+ * @param {HTMLFormElement} form
192
+ * @param {Record<string, string>} words
193
+ * @param {string} lang
194
+ */
195
+ export function twoSteps(form, words, lang) {
196
+ if (form.dataset.steps === '1') return;
197
+ form.dataset.steps = '1';
198
+ const firstHalf = new Set(['service', 'resource', 'day', 'back', 'hours', 'party', 'quantity', 'class']);
199
+ for (const child of /** @type {HTMLElement[]} */ ([...form.children])) {
200
+ const control = child.querySelector('input, select, textarea');
201
+ const name = control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement ? control.name : '';
202
+ const first = child.matches('[data-times]') || firstHalf.has(name) || child.tagName === 'NOSCRIPT' || child.matches('[data-done]');
203
+ child.dataset.step = first ? '1' : '2';
204
+ }
205
+ for (const el of form.querySelectorAll('[data-done], [data-booking-error]')) /** @type {HTMLElement} */ (el).dataset.step = 'both';
206
+
207
+ const stepper = document.createElement('ol');
208
+ stepper.className = 'booking__stepper';
209
+ stepper.innerHTML = [words.stepWhat ?? 'What and when', words.stepWho ?? 'Who is coming']
210
+ .map((name, i) => `<li class="booking__step" data-at="${i + 1}"><span class="booking__step-n">${i + 1}</span>${name}</li>`)
211
+ .join('');
212
+ form.prepend(stepper);
213
+
214
+ const next = document.createElement('button');
215
+ next.type = 'button';
216
+ next.className = 'booking__next';
217
+ next.dataset.step = '1';
218
+ next.textContent = words.next ?? 'Continue';
219
+ const need = document.createElement('p');
220
+ need.className = 'booking__need';
221
+ need.dataset.step = '1';
222
+ need.hidden = true;
223
+ need.textContent = words.needAll ?? 'Pick a service, a day and a time first.';
224
+ const times = form.querySelector('[data-times]');
225
+ times?.insertAdjacentElement('afterend', need);
226
+ need.insertAdjacentElement('afterend', next);
227
+
228
+ const summary = document.createElement('div');
229
+ summary.className = 'booking__summary';
230
+ summary.dataset.step = '2';
231
+ form.insertBefore(summary, form.querySelector('[data-step="2"]'));
232
+
233
+ const chosen = () => {
234
+ const names = /** @type {HTMLInputElement[]} */ ([...form.querySelectorAll('input[name=service]:checked')])
235
+ .map((box) => box.closest('.booking__service')?.querySelector('.booking__service-name')?.textContent?.trim() ?? box.value);
236
+ const single = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=service]'));
237
+ if (single) names.push(single.options[single.selectedIndex]?.textContent?.trim() ?? '');
238
+ const who = /** @type {HTMLSelectElement | null} */ (form.querySelector('select[name=resource]'));
239
+ const dayValue = /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=day]'))?.value ?? '';
240
+ const dayDate = parseDay(dayValue);
241
+ const slot = /** @type {HTMLElement | null} */ (form.querySelector('.booking__slot.is-chosen'));
242
+ const ends = /** @type {HTMLElement | null} */ (form.querySelector('[data-ends]'));
243
+ return {
244
+ services: names.filter(Boolean),
245
+ who: who ? who.options[who.selectedIndex]?.textContent?.trim() ?? '' : '',
246
+ day: dayDate ? new Intl.DateTimeFormat(lang, { weekday: 'short', day: 'numeric', month: 'long' }).format(dayDate) : dayValue,
247
+ time: slot ? to24((slot.textContent ?? '').trim()).split('·')[0].trim() : '',
248
+ until: ends && !ends.hidden ? ends.dataset.clock ?? '' : '',
249
+ takes: ends && !ends.hidden ? ends.dataset.length ?? '' : '',
250
+ };
251
+ };
252
+
253
+ const stage = (to) => {
254
+ // A move between the steps brings the form into view; the first stage,
255
+ // set as the page loads, must not — it scrolled every visitor to the
256
+ // form the moment the page opened (10 October 2026).
257
+ const moved = form.dataset.stage !== undefined && form.dataset.stage !== to;
258
+ form.dataset.stage = to;
259
+ for (const step of /** @type {NodeListOf<HTMLElement>} */ (stepper.querySelectorAll('.booking__step'))) {
260
+ step.classList.toggle('is-current', step.dataset.at === to);
261
+ step.classList.toggle('is-done', to === 'done' || Number(step.dataset.at) < Number(to));
262
+ }
263
+ if (to === '2') {
264
+ const c = chosen();
265
+ const row = (label, value) => (value ? `<div class="booking__summary-row"><dt>${label}</dt><dd>${value}</dd></div>` : '');
266
+ summary.innerHTML = `
267
+ <p class="booking__summary-label">${words.summary ?? 'Your booking'}</p>
268
+ <dl class="booking__summary-list">
269
+ ${row(words.what ?? 'What', c.services.join(', '))}
270
+ ${row(words.resource ?? 'With', c.who)}
271
+ ${row(words.when ?? 'When', `${c.day}${c.time ? `, ${c.time}${c.until ? `–${c.until}` : ''}` : ''}`)}
272
+ ${row(words.takes ?? 'Takes', c.takes)}
273
+ </dl>
274
+ <button type="button" class="booking__back">${words.change ?? 'Change'}</button>`;
275
+ summary.querySelector('.booking__back')?.addEventListener('click', () => stage('1'));
276
+ /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=name]'))?.focus({ preventScroll: true });
277
+ }
278
+ if (moved && to !== 'done') form.scrollIntoView({ block: 'start', behavior: 'smooth' });
279
+ };
280
+
281
+ next.addEventListener('click', () => {
282
+ const c = chosen();
283
+ const ready = c.services.length > 0 && /** @type {HTMLInputElement | null} */ (form.querySelector('input[name=day]'))?.value && c.time;
284
+ need.hidden = Boolean(ready);
285
+ if (ready) stage('2');
286
+ });
287
+ form.addEventListener('change', () => {
288
+ if (form.dataset.stage === '2') stage('2');
289
+ });
290
+ const done = /** @type {HTMLElement | null} */ (form.querySelector('[data-done]'));
291
+ if (done) {
292
+ const settle = () => {
293
+ if (done.hidden || form.dataset.stage === 'done') return;
294
+ stage('done');
295
+ form.scrollIntoView({ block: 'start', behavior: 'smooth' });
296
+ };
297
+ new MutationObserver(settle).observe(done, { attributes: true, attributeFilter: ['hidden'] });
298
+ settle();
299
+ }
300
+ stage('1');
301
+ }
302
+
303
+ /**
304
+ * A `<select>` as a drawn list: the native one stays, hidden, and is what
305
+ * the form reads; its options are mirrored as they change (the element
306
+ * rewrites whom from every availability answer), and a pick sets the native
307
+ * value and fires `change`.
308
+ *
309
+ * @param {HTMLSelectElement} select
310
+ */
311
+ export function drawnSelect(select) {
312
+ if (select.dataset.drawn === '1') return;
313
+ select.dataset.drawn = '1';
314
+ const root = document.createElement('div');
315
+ root.className = 'booking__dd';
316
+ const trigger = document.createElement('button');
317
+ trigger.type = 'button';
318
+ trigger.className = 'booking__dd-trigger';
319
+ trigger.setAttribute('aria-haspopup', 'listbox');
320
+ trigger.setAttribute('aria-expanded', 'false');
321
+ const list = document.createElement('div');
322
+ list.className = 'booking__dd-list';
323
+ list.setAttribute('role', 'listbox');
324
+ list.hidden = true;
325
+ root.append(trigger, list);
326
+ select.classList.add('is-replaced');
327
+ select.setAttribute('aria-hidden', 'true');
328
+ select.tabIndex = -1;
329
+ select.insertAdjacentElement('afterend', root);
330
+ const label = select.labels?.[0];
331
+ if (label) trigger.setAttribute('aria-label', label.textContent?.trim() ?? '');
332
+
333
+ const draw = () => {
334
+ const current = select.options[select.selectedIndex];
335
+ trigger.innerHTML = `<span class="booking__dd-value">${current?.textContent ?? ''}</span><span class="booking__dd-mark" aria-hidden="true">▾</span>`;
336
+ list.innerHTML = [...select.options]
337
+ .map((o) => `<button type="button" class="booking__dd-option${o.selected ? ' is-chosen' : ''}" role="option" aria-selected="${o.selected}" data-value="${o.value.replace(/"/g, '&quot;')}"${o.disabled ? ' disabled' : ''}>${o.textContent}</button>`)
338
+ .join('');
339
+ };
340
+ const open = (yes) => {
341
+ list.hidden = !yes;
342
+ trigger.setAttribute('aria-expanded', String(yes));
343
+ if (yes) /** @type {HTMLElement | null} */ (list.querySelector('.is-chosen:not(:disabled), .booking__dd-option:not(:disabled)'))?.focus();
344
+ };
345
+ trigger.addEventListener('click', () => open(list.hidden));
346
+ list.addEventListener('click', (event) => {
347
+ const option = event.target instanceof Element ? event.target.closest('[data-value]') : null;
348
+ if (!(option instanceof HTMLButtonElement) || option.disabled) return;
349
+ select.value = option.dataset.value ?? '';
350
+ select.dispatchEvent(new Event('change', { bubbles: true }));
351
+ draw();
352
+ open(false);
353
+ trigger.focus();
354
+ });
355
+ root.addEventListener('keydown', (event) => {
356
+ if (event.key === 'Escape') { open(false); trigger.focus(); }
357
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
358
+ event.preventDefault();
359
+ if (list.hidden) { open(true); return; }
360
+ const options = /** @type {HTMLButtonElement[]} */ ([...list.querySelectorAll('.booking__dd-option:not(:disabled)')]);
361
+ const at = options.indexOf(/** @type {HTMLButtonElement} */ (document.activeElement));
362
+ options[(at + (event.key === 'ArrowDown' ? 1 : options.length - 1)) % options.length]?.focus();
363
+ }
364
+ });
365
+ document.addEventListener('click', (event) => {
366
+ if (!list.hidden && event.target instanceof Node && !root.contains(event.target)) open(false);
367
+ });
368
+ new MutationObserver(draw).observe(select, { childList: true, subtree: true, attributes: true });
369
+ select.addEventListener('change', draw);
370
+ draw();
371
+ }