@echodial/deck 0.1.0

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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1327 -0
  3. package/bin/deck.mjs +219 -0
  4. package/build.mjs +338 -0
  5. package/dist/api-buckets.json +10486 -0
  6. package/dist/api.json +57896 -0
  7. package/dist/brand/deck-apple-touch-icon.png +0 -0
  8. package/dist/brand/deck-logo-dark.svg +1 -0
  9. package/dist/brand/deck-logo-light.svg +1 -0
  10. package/dist/brand/deck-logo-stacked.svg +1 -0
  11. package/dist/brand/deck-logo.svg +1 -0
  12. package/dist/brand/deck-mark.svg +7 -0
  13. package/dist/brand/deck-og.png +0 -0
  14. package/dist/collisions.json +223 -0
  15. package/dist/deck-adapters.js +482 -0
  16. package/dist/deck-adapters.min.js +23 -0
  17. package/dist/deck-extras.js +823 -0
  18. package/dist/deck-extras.min.js +5 -0
  19. package/dist/deck-icons.svg +184 -0
  20. package/dist/deck.bundle.js +2638 -0
  21. package/dist/deck.bundle.min.js +86 -0
  22. package/dist/deck.css +7904 -0
  23. package/dist/deck.esm.js +2644 -0
  24. package/dist/deck.js +1331 -0
  25. package/dist/deck.min.css +32 -0
  26. package/dist/deck.min.js +60 -0
  27. package/dist/layers/anchor.css +192 -0
  28. package/dist/layers/buttons.css +210 -0
  29. package/dist/layers/charts.css +337 -0
  30. package/dist/layers/combobox.css +248 -0
  31. package/dist/layers/components.css +868 -0
  32. package/dist/layers/container.css +199 -0
  33. package/dist/layers/datagrid.css +335 -0
  34. package/dist/layers/datepicker.css +305 -0
  35. package/dist/layers/forms.css +401 -0
  36. package/dist/layers/gradients.css +302 -0
  37. package/dist/layers/inputs.css +522 -0
  38. package/dist/layers/layers.css +87 -0
  39. package/dist/layers/layout.css +235 -0
  40. package/dist/layers/logical.css +226 -0
  41. package/dist/layers/media.css +400 -0
  42. package/dist/layers/mobile.css +245 -0
  43. package/dist/layers/motion.css +379 -0
  44. package/dist/layers/nav.css +771 -0
  45. package/dist/layers/perf.css +234 -0
  46. package/dist/layers/print.css +198 -0
  47. package/dist/layers/reset.css +103 -0
  48. package/dist/layers/space3d.css +280 -0
  49. package/dist/layers/toasts.css +201 -0
  50. package/dist/layers/tokens.css +229 -0
  51. package/dist/layers/type.css +114 -0
  52. package/dist/layers/utilities.css +241 -0
  53. package/dist/sizes.json +52 -0
  54. package/dist/usage.json +6340 -0
  55. package/package.json +94 -0
  56. package/src/00-layers.css +87 -0
  57. package/src/01-tokens.css +229 -0
  58. package/src/02-reset.css +103 -0
  59. package/src/03-type.css +114 -0
  60. package/src/04-layout.css +235 -0
  61. package/src/05-buttons.css +210 -0
  62. package/src/06-forms.css +401 -0
  63. package/src/07-components.css +868 -0
  64. package/src/08-mobile.css +245 -0
  65. package/src/09-utilities.css +241 -0
  66. package/src/10-datepicker.css +305 -0
  67. package/src/11-combobox.css +248 -0
  68. package/src/12-datagrid.css +335 -0
  69. package/src/13-toasts.css +201 -0
  70. package/src/14-charts.css +337 -0
  71. package/src/16-motion.css +379 -0
  72. package/src/18-container.css +199 -0
  73. package/src/19-logical.css +226 -0
  74. package/src/20-gradients.css +302 -0
  75. package/src/21-space3d.css +280 -0
  76. package/src/22-nav.css +771 -0
  77. package/src/23-inputs.css +522 -0
  78. package/src/24-media.css +400 -0
  79. package/src/25-anchor.css +192 -0
  80. package/src/26-perf.css +234 -0
  81. package/src/99-print.css +198 -0
  82. package/src/brand/deck-apple-touch-icon.png +0 -0
  83. package/src/brand/deck-logo-dark.svg +1 -0
  84. package/src/brand/deck-logo-light.svg +1 -0
  85. package/src/brand/deck-logo-stacked.svg +1 -0
  86. package/src/brand/deck-logo.svg +1 -0
  87. package/src/brand/deck-mark.svg +7 -0
  88. package/src/brand/deck-og.png +0 -0
  89. package/src/brand/sources.json +7 -0
  90. package/src/deck-icons.svg +184 -0
  91. package/src/js/deck-adapters.js +482 -0
  92. package/src/js/deck-extras.js +823 -0
  93. package/src/js/deck.js +1331 -0
@@ -0,0 +1,2644 @@
1
+ /* Deck v0.1.0 — ES module entry.
2
+ The scripts attach Deck to globalThis; this re-exports it so that
3
+ `import Deck from '@echodial/deck'` behaves the way you expect. */
4
+ /*! ==========================================================================
5
+ * deck.js v0.1 — behaviour for Deck
6
+ * No dependencies. No build step. Drop it in and it wires itself up.
7
+ *
8
+ * <script src="/assets/deck/deck.js" defer></script>
9
+ *
10
+ * deck-icons.svg is expected to sit beside this file.
11
+ *
12
+ * Anything with a data-deck-* attribute is initialised on DOMContentLoaded
13
+ * and again whenever you call Deck.init(container) after injecting HTML.
14
+ * ======================================================================== */
15
+
16
+ const Deck = (() => {
17
+ 'use strict';
18
+
19
+ /* deck-icons.svg ships next to deck.js, so resolve it against this script's
20
+ own URL and the sprite follows the bundle wherever it is installed. Read
21
+ it here, at script-execution time, while document.currentScript is still
22
+ set. Override per page with data-deck-icons on the script tag; setting
23
+ Deck.iconSprite later only affects icons rendered after that point. */
24
+ const spriteURL = (() => {
25
+ const tag = typeof document !== 'undefined' ? document.currentScript : null;
26
+ if (tag && tag.dataset.deckIcons) return tag.dataset.deckIcons;
27
+ if (tag && tag.src) {
28
+ try { return new URL('deck-icons.svg', tag.src).href; } catch (e) { }
29
+ }
30
+ return '/assets/deck-icons.svg';
31
+ })();
32
+
33
+ const $ = (sel, root = document) => root.querySelector(sel);
34
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
35
+ const el = (tag, cls, html) => {
36
+ const n = document.createElement(tag);
37
+ if (cls) n.className = cls;
38
+ if (html != null) n.innerHTML = html;
39
+ return n;
40
+ };
41
+ const icon = (name, cls = 'icon') =>
42
+ `<svg class="${cls}" aria-hidden="true"><use href="${Deck.iconSprite}#${name}"></use></svg>`;
43
+
44
+ /* ======================================================================
45
+ Date picker
46
+ <div class="datefield" data-deck-datepicker
47
+ data-mode="single|range" data-format="mdy|dmy|iso"
48
+ data-min="2024-01-01" data-max="2026-12-31" data-presets>
49
+ <input class="input" name="ship_date" readonly>
50
+ </div>
51
+ ====================================================================== */
52
+
53
+ const DAY = 86400000;
54
+ const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
55
+ const parseISO = s => {
56
+ if (!s) return null;
57
+ const [y, m, d] = s.split('-').map(Number);
58
+ return (y && m && d) ? new Date(y, m - 1, d) : null;
59
+ };
60
+ const sameDay = (a, b) => a && b && a.toDateString() === b.toDateString();
61
+ const startOfDay = d => new Date(d.getFullYear(), d.getMonth(), d.getDate());
62
+ const addMonths = (d, n) => new Date(d.getFullYear(), d.getMonth() + n, 1);
63
+
64
+ /* Month and weekday names come from Intl rather than a hardcoded English
65
+ array or a date library. It is built into every browser, it is correct in
66
+ every locale, and it costs nothing to ship. Set data-locale on the field
67
+ to override the document language. */
68
+ const nameCache = new Map();
69
+ function names(locale) {
70
+ const key = locale || 'auto';
71
+ if (nameCache.has(key)) return nameCache.get(key);
72
+ const loc = locale || document.documentElement.lang || navigator.language || 'en';
73
+ const month = new Intl.DateTimeFormat(loc, { month: 'long' });
74
+ const monthShort = new Intl.DateTimeFormat(loc, { month: 'short' });
75
+ const day = new Intl.DateTimeFormat(loc, { weekday: 'short' });
76
+ const set = {
77
+ locale: loc,
78
+ months: Array.from({ length: 12 }, (_, i) => month.format(new Date(2021, i, 1))),
79
+ monthsShort: Array.from({ length: 12 }, (_, i) => monthShort.format(new Date(2021, i, 1))),
80
+ // 2021-08-01 was a Sunday, so this walks Sunday through Saturday
81
+ days: Array.from({ length: 7 }, (_, i) => day.format(new Date(2021, 7, 1 + i))),
82
+ // First day of the week per locale: Sunday in the US, Monday in the EU
83
+ weekStart: (() => {
84
+ try {
85
+ const info = new Intl.Locale(loc).getWeekInfo?.() ||
86
+ new Intl.Locale(loc).weekInfo;
87
+ return info ? (info.firstDay === 7 ? 0 : info.firstDay) : 0;
88
+ } catch (e) { return 0; }
89
+ })(),
90
+ long: new Intl.DateTimeFormat(loc, { dateStyle: 'medium' }),
91
+ full: new Intl.DateTimeFormat(loc, { dateStyle: 'full' })
92
+ };
93
+ nameCache.set(key, set);
94
+ return set;
95
+ }
96
+
97
+ function formatDate(d, fmt, locale) {
98
+ if (!d) return '';
99
+ if (fmt === 'iso') return iso(d);
100
+ if (fmt === 'long') return names(locale).long.format(d);
101
+ const y = d.getFullYear(), m = d.getMonth() + 1, day = d.getDate();
102
+ const p = n => String(n).padStart(2, '0');
103
+ if (fmt === 'dmy') return `${p(day)}/${p(m)}/${y}`;
104
+ if (fmt === 'ymd') return `${y}-${p(m)}-${p(day)}`;
105
+ return `${p(m)}/${p(day)}/${y}`;
106
+ }
107
+
108
+ class DatePicker {
109
+ constructor(root) {
110
+ this.root = root;
111
+ this.input = $('input', root);
112
+ this.mode = root.dataset.mode || 'single';
113
+ this.format = root.dataset.format || 'mdy';
114
+ this.min = parseISO(root.dataset.min);
115
+ this.max = parseISO(root.dataset.max);
116
+ this.showPresets = root.hasAttribute('data-presets');
117
+ this.locale = root.dataset.locale || null;
118
+ this.i18n = names(this.locale);
119
+ this.weekStart = root.dataset.weekStart != null
120
+ ? Number(root.dataset.weekStart)
121
+ : this.i18n.weekStart;
122
+ this.months = Number(root.dataset.months || (this.mode === 'range' ? 2 : 1));
123
+
124
+ this.start = parseISO(this.input.value) || null;
125
+ this.end = null;
126
+ this.view = startOfDay(this.start || new Date());
127
+ this.view.setDate(1);
128
+ this.jump = null;
129
+
130
+ this.build();
131
+ this.bind();
132
+ this.render();
133
+ }
134
+
135
+ build() {
136
+ const r = this.root;
137
+ if (!$('.icon', r)) r.insertAdjacentHTML('afterbegin', icon('calendar'));
138
+ if (!$('.datefield-clear', r)) {
139
+ r.insertAdjacentHTML('beforeend',
140
+ `<button type="button" class="datefield-clear" aria-label="Clear date">${icon('x', 'icon icon-sm')}</button>`);
141
+ }
142
+ this.input.readOnly = true;
143
+ this.input.setAttribute('aria-haspopup', 'dialog');
144
+
145
+ this.id = 'dp-' + Math.random().toString(36).slice(2, 8);
146
+ this.panel = el('div', 'datepicker');
147
+ this.panel.id = this.id;
148
+ this.panel.setAttribute('popover', '');
149
+ document.body.append(this.panel);
150
+ this.input.setAttribute('popovertarget', this.id);
151
+ this.input.style.cursor = 'pointer';
152
+ }
153
+
154
+ bind() {
155
+ this.input.addEventListener('click', () => this.panel.showPopover());
156
+ this.input.addEventListener('keydown', e => {
157
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
158
+ e.preventDefault();
159
+ this.panel.showPopover();
160
+ }
161
+ });
162
+ $('.datefield-clear', this.root).addEventListener('click', e => {
163
+ e.stopPropagation();
164
+ this.start = this.end = null;
165
+ this.commit();
166
+ this.render();
167
+ });
168
+ this.panel.addEventListener('toggle', e => {
169
+ if (e.newState === 'open') this.position();
170
+ });
171
+ this.panel.addEventListener('click', e => this.onClick(e));
172
+ this.panel.addEventListener('mouseover', e => {
173
+ const day = e.target.closest('.datepicker-day');
174
+ if (this.mode !== 'range' || !this.start || this.end || !day) return;
175
+ this.hover = parseISO(day.dataset.date);
176
+ this.paintRange();
177
+ });
178
+ window.addEventListener('resize', () => {
179
+ if (this.panel.matches(':popover-open')) this.position();
180
+ });
181
+ }
182
+
183
+ position() {
184
+ /* Where the browser has anchor positioning, 25-anchor.css places the
185
+ panel and these inline insets would be measured from the anchored
186
+ region rather than from the viewport — offsetting it a second time by
187
+ its own distance down the page. Same shape as Grid.shadows(), which
188
+ only runs where scroll-state queries are missing. */
189
+ if (CSS.supports('anchor-name: --a')) return;
190
+ if (window.innerWidth <= 480) return; // becomes a sheet in CSS
191
+ const r = this.root.getBoundingClientRect();
192
+ const p = this.panel;
193
+ p.style.insetInlineStart = Math.max(8, Math.min(r.left, window.innerWidth - p.offsetWidth - 8)) + 'px';
194
+ const below = window.innerHeight - r.bottom;
195
+ p.style.insetBlockStart = (below > p.offsetHeight + 12 || below > r.top)
196
+ ? (r.bottom + 6) + 'px'
197
+ : Math.max(8, r.top - p.offsetHeight - 6) + 'px';
198
+ }
199
+
200
+ blocked(d) {
201
+ return (this.min && d < this.min) || (this.max && d > this.max);
202
+ }
203
+
204
+ onClick(e) {
205
+ const day = e.target.closest('.datepicker-day');
206
+ if (day) return this.pick(parseISO(day.dataset.date));
207
+
208
+ const nav = e.target.closest('[data-nav]');
209
+ if (nav) {
210
+ this.view = addMonths(this.view, Number(nav.dataset.nav));
211
+ return this.render();
212
+ }
213
+ const title = e.target.closest('.datepicker-title');
214
+ if (title) { this.jump = this.jump ? null : 'month'; return this.render(); }
215
+
216
+ const jm = e.target.closest('[data-month]');
217
+ if (jm) {
218
+ this.view = new Date(this.view.getFullYear(), Number(jm.dataset.month), 1);
219
+ this.jump = null;
220
+ return this.render();
221
+ }
222
+ const preset = e.target.closest('[data-preset]');
223
+ if (preset) {
224
+ const n = Number(preset.dataset.preset);
225
+ const today = startOfDay(new Date());
226
+ if (this.mode === 'range') {
227
+ this.end = today;
228
+ this.start = n === 0 ? today : new Date(today - (n - 1) * DAY);
229
+ } else {
230
+ this.start = new Date(today - n * DAY);
231
+ this.end = null;
232
+ }
233
+ this.commit();
234
+ this.render();
235
+ if (this.mode !== 'range') this.panel.hidePopover();
236
+ return;
237
+ }
238
+ if (e.target.closest('[data-apply]')) { this.commit(); this.panel.hidePopover(); }
239
+ if (e.target.closest('[data-cancel]')) this.panel.hidePopover();
240
+ }
241
+
242
+ pick(d) {
243
+ if (!d || this.blocked(d)) return;
244
+ if (this.mode === 'range') {
245
+ if (!this.start || this.end) { this.start = d; this.end = null; }
246
+ else if (d < this.start) { this.end = this.start; this.start = d; }
247
+ else this.end = d;
248
+ } else {
249
+ this.start = d;
250
+ this.end = null;
251
+ }
252
+ this.commit();
253
+ this.render();
254
+ if (this.mode !== 'range' || this.end) {
255
+ setTimeout(() => this.panel.hidePopover(), 120);
256
+ }
257
+ }
258
+
259
+ commit() {
260
+ const f = d => formatDate(d, this.format, this.locale);
261
+ this.input.value = this.mode === 'range'
262
+ ? (this.start ? f(this.start) + (this.end ? ' – ' + f(this.end) : '') : '')
263
+ : f(this.start);
264
+ this.root.classList.toggle('has-value', !!this.input.value);
265
+ this.input.dispatchEvent(new CustomEvent('deck:change', {
266
+ bubbles: true,
267
+ detail: { start: this.start ? iso(this.start) : null, end: this.end ? iso(this.end) : null }
268
+ }));
269
+ }
270
+
271
+ monthHTML(base) {
272
+ const first = new Date(base.getFullYear(), base.getMonth(), 1);
273
+ let lead = (first.getDay() - this.weekStart + 7) % 7;
274
+ const cells = [];
275
+ const cursor = new Date(first);
276
+ cursor.setDate(1 - lead);
277
+ for (let i = 0; i < 42; i++) {
278
+ cells.push(new Date(cursor));
279
+ cursor.setDate(cursor.getDate() + 1);
280
+ if (i >= 34 && cursor.getMonth() !== base.getMonth() && cursor.getDay() === this.weekStart) break;
281
+ }
282
+ const all = this.i18n.days;
283
+ const dow = all.slice(this.weekStart).concat(all.slice(0, this.weekStart));
284
+ const today = startOfDay(new Date());
285
+
286
+ return `<div class="datepicker-month">
287
+ <div class="datepicker-grid">${dow.map(d => `<div class="datepicker-dow">${d}</div>`).join('')}</div>
288
+ <div class="datepicker-grid">${cells.map(d => {
289
+ const cls = ['datepicker-day'];
290
+ if (d.getMonth() !== base.getMonth()) cls.push('is-outside');
291
+ if (sameDay(d, today)) cls.push('is-today');
292
+ if (this.blocked(d)) cls.push('is-blocked');
293
+ return `<button type="button" class="${cls.join(' ')}" data-date="${iso(d)}"
294
+ ${this.blocked(d) ? 'disabled' : ''}
295
+ aria-label="${this.i18n.full.format(d)}">${d.getDate()}</button>`;
296
+ }).join('')}</div>
297
+ </div>`;
298
+ }
299
+
300
+ render() {
301
+ const presets = this.showPresets ? `
302
+ <div class="datepicker-presets">
303
+ ${(this.mode === 'range'
304
+ ? [['Today', 0], ['Last 7 days', 7], ['Last 30 days', 30], ['Last 90 days', 90]]
305
+ : [['Today', 0], ['Yesterday', 1], ['A week ago', 7], ['A month ago', 30]]
306
+ ).map(([label, n]) =>
307
+ `<button type="button" class="datepicker-preset" data-preset="${n}">${label}</button>`).join('')}
308
+ </div>` : '';
309
+
310
+ const body = this.jump
311
+ ? `<div class="datepicker-jump">${this.i18n.monthsShort.map((m, i) =>
312
+ `<button type="button" data-month="${i}" aria-selected="${i === this.view.getMonth()}">${m}</button>`).join('')}</div>`
313
+ : `<div class="datepicker-months">${Array.from({ length: this.months },
314
+ (_, i) => this.monthHTML(addMonths(this.view, i))).join('')}</div>`;
315
+
316
+ const label = this.months > 1 && !this.jump
317
+ ? `${this.i18n.monthsShort[this.view.getMonth()]} – ${this.i18n.monthsShort[addMonths(this.view, this.months - 1).getMonth()]} ${this.view.getFullYear()}`
318
+ : `${this.i18n.months[this.view.getMonth()]} ${this.view.getFullYear()}`;
319
+
320
+ const f = d => formatDate(d, this.format, this.locale);
321
+ const readout = this.mode === 'range'
322
+ ? (this.start ? `${f(this.start)} – ${this.end ? f(this.end) : '…'}` : 'Pick a start date')
323
+ : (this.start ? f(this.start) : 'No date selected');
324
+
325
+ this.panel.innerHTML = `
326
+ <div class="datepicker-layout">
327
+ ${presets}
328
+ <div class="datepicker-main">
329
+ <div class="datepicker-head">
330
+ <button type="button" class="datepicker-nav" data-nav="-1" aria-label="Previous month">${icon('chevron-left', 'icon icon-sm')}</button>
331
+ <button type="button" class="datepicker-title">${label} ${icon('chevron-down', 'icon icon-sm')}</button>
332
+ <button type="button" class="datepicker-nav" data-nav="1" aria-label="Next month">${icon('chevron-right', 'icon icon-sm')}</button>
333
+ </div>
334
+ ${body}
335
+ </div>
336
+ </div>
337
+ <div class="datepicker-foot">
338
+ <span class="datepicker-readout">${readout}</span>
339
+ <button type="button" class="btn btn-sm" data-cancel>Cancel</button>
340
+ <button type="button" class="btn btn-sm btn-primary" data-apply>Apply</button>
341
+ </div>`;
342
+
343
+ this.paintRange();
344
+ this.root.classList.toggle('has-value', !!this.input.value);
345
+ }
346
+
347
+ paintRange() {
348
+ $$('.datepicker-day', this.panel).forEach(node => {
349
+ const d = parseISO(node.dataset.date);
350
+ const end = this.end || (this.mode === 'range' && this.start && this.hover > this.start ? this.hover : null);
351
+ node.classList.toggle('is-selected', sameDay(d, this.start) || sameDay(d, this.end));
352
+ node.classList.toggle('is-range-start', !!end && sameDay(d, this.start));
353
+ node.classList.toggle('is-range-end', !!end && sameDay(d, end));
354
+ node.classList.toggle('is-in-range', !!(end && this.start && d > this.start && d < end));
355
+ });
356
+ }
357
+ }
358
+
359
+ /* ======================================================================
360
+ Combobox / autocomplete
361
+ <div class="combo" data-deck-combo data-multi data-create
362
+ data-placeholder="Search repositories">
363
+ <select hidden multiple>… your real options …</select>
364
+ </div>
365
+ The <select> stays in the DOM and stays in sync, so normal form posts
366
+ work with no extra server-side handling.
367
+ ====================================================================== */
368
+
369
+ class Combo {
370
+ constructor(root) {
371
+ this.root = root;
372
+ this.select = $('select', root);
373
+ this.multi = root.hasAttribute('data-multi') || (this.select && this.select.multiple);
374
+ this.allowCreate = root.hasAttribute('data-create');
375
+ this.placeholder = root.dataset.placeholder || 'Search';
376
+ this.remote = root.dataset.url || null;
377
+ this.minChars = Number(root.dataset.minChars || 0);
378
+ this.open = false;
379
+ this.activeIndex = -1;
380
+ this.query = '';
381
+
382
+ this.options = this.select
383
+ ? $$('option', this.select).map(o => ({
384
+ value: o.value,
385
+ label: o.textContent.trim(),
386
+ sub: o.dataset.sub || '',
387
+ group: o.dataset.group || (o.parentElement.tagName === 'OPTGROUP' ? o.parentElement.label : ''),
388
+ disabled: o.disabled,
389
+ selected: o.selected
390
+ }))
391
+ : [];
392
+
393
+ if (this.select) this.select.hidden = true;
394
+ this.build();
395
+ this.bind();
396
+ this.renderTokens();
397
+ }
398
+
399
+ get selected() { return this.options.filter(o => o.selected); }
400
+
401
+ build() {
402
+ this.control = el('div', 'combo-control');
403
+ this.control.innerHTML = `
404
+ <span class="combo-tokens"></span>
405
+ <input class="combo-input" type="text" role="combobox" autocomplete="off"
406
+ aria-expanded="false" aria-autocomplete="list" placeholder="${this.placeholder}">
407
+ <button type="button" class="combo-toggle" tabindex="-1" aria-label="Toggle list">${icon('chevron-down', 'icon icon-sm')}</button>`;
408
+ this.list = el('div', 'combo-list');
409
+ this.list.setAttribute('role', 'listbox');
410
+ this.root.append(this.control, this.list);
411
+ this.input = $('.combo-input', this.control);
412
+ this.tokens = $('.combo-tokens', this.control);
413
+ }
414
+
415
+ bind() {
416
+ this.control.addEventListener('mousedown', e => {
417
+ if (e.target.closest('.combo-token > button')) return;
418
+ if (e.target !== this.input) { e.preventDefault(); this.input.focus(); }
419
+ if (e.target.closest('.combo-toggle')) this.toggle();
420
+ else if (!this.open) this.show();
421
+ });
422
+
423
+ this.input.addEventListener('input', () => {
424
+ this.query = this.input.value;
425
+ this.activeIndex = 0;
426
+ if (this.remote) this.fetch();
427
+ else this.render();
428
+ this.show();
429
+ });
430
+
431
+ this.input.addEventListener('keydown', e => this.onKey(e));
432
+ this.input.addEventListener('blur', () => setTimeout(() => this.hide(), 130));
433
+
434
+ this.list.addEventListener('mousedown', e => {
435
+ const opt = e.target.closest('.combo-option');
436
+ if (!opt) return;
437
+ e.preventDefault();
438
+ if (opt.dataset.create != null) this.create(this.query);
439
+ else this.choose(opt.dataset.value);
440
+ });
441
+ this.list.addEventListener('mousemove', e => {
442
+ const opt = e.target.closest('.combo-option');
443
+ if (!opt) return;
444
+ this.activeIndex = Number(opt.dataset.index);
445
+ this.paintActive();
446
+ });
447
+
448
+ this.tokens.addEventListener('click', e => {
449
+ const btn = e.target.closest('button');
450
+ if (!btn) return;
451
+ this.choose(btn.dataset.value, true);
452
+ });
453
+ }
454
+
455
+ matches() {
456
+ const q = this.query.trim().toLowerCase();
457
+ if (q.length < this.minChars) return [];
458
+ if (!q) return this.options;
459
+ return this.options.filter(o =>
460
+ o.label.toLowerCase().includes(q) || o.sub.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
461
+ }
462
+
463
+ render() {
464
+ const list = this.matches();
465
+ this.visible = list;
466
+
467
+ if (!list.length) {
468
+ this.list.innerHTML = this.allowCreate && this.query.trim()
469
+ ? `<button type="button" class="combo-option" data-create data-index="0">${icon('plus', 'icon icon-sm')}
470
+ <span class="combo-option-main"><span>Add “${escapeHTML(this.query.trim())}”</span></span></button>`
471
+ : `<div class="combo-empty">${icon('search', 'icon icon-sm')} No matches for “${escapeHTML(this.query)}”</div>`;
472
+ return;
473
+ }
474
+
475
+ let html = '';
476
+ let group = null;
477
+ list.forEach((o, i) => {
478
+ if (o.group && o.group !== group) {
479
+ group = o.group;
480
+ html += `<div class="combo-group">${escapeHTML(group)}</div>`;
481
+ }
482
+ html += `<button type="button" class="combo-option" role="option" data-index="${i}"
483
+ data-value="${escapeHTML(o.value)}"
484
+ aria-selected="${o.selected}" ${o.disabled ? 'aria-disabled="true"' : ''}>
485
+ <span class="combo-option-main">
486
+ <span>${highlight(o.label, this.query)}</span>
487
+ ${o.sub ? `<span class="combo-option-sub">${highlight(o.sub, this.query)}</span>` : ''}
488
+ </span>
489
+ ${this.multi ? `<span class="combo-check">${icon('check', 'icon icon-sm')}</span>` : ''}
490
+ </button>`;
491
+ });
492
+
493
+ if (this.allowCreate && this.query.trim() && !list.some(o => o.label.toLowerCase() === this.query.trim().toLowerCase())) {
494
+ html += `<div class="combo-create"><button type="button" class="combo-option" data-create data-index="${list.length}">
495
+ ${icon('plus', 'icon icon-sm')}<span class="combo-option-main"><span>Add “${escapeHTML(this.query.trim())}”</span></span></button></div>`;
496
+ }
497
+
498
+ html += `<div class="combo-hint"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> select</span><span><kbd>esc</kbd> close</span></div>`;
499
+ this.list.innerHTML = html;
500
+ this.paintActive();
501
+ }
502
+
503
+ paintActive() {
504
+ const nodes = $$('.combo-option', this.list);
505
+ nodes.forEach(n => n.classList.remove('is-active'));
506
+ const node = nodes[this.activeIndex];
507
+ if (node) {
508
+ node.classList.add('is-active');
509
+ node.scrollIntoView({ block: 'nearest' });
510
+ }
511
+ }
512
+
513
+ onKey(e) {
514
+ const nodes = $$('.combo-option', this.list);
515
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
516
+ e.preventDefault();
517
+ if (!this.open) this.show();
518
+ const dir = e.key === 'ArrowDown' ? 1 : -1;
519
+ this.activeIndex = (this.activeIndex + dir + nodes.length) % (nodes.length || 1);
520
+ this.paintActive();
521
+ } else if (e.key === 'Enter') {
522
+ if (!this.open) return;
523
+ e.preventDefault();
524
+ const node = nodes[this.activeIndex];
525
+ if (!node) return;
526
+ if (node.dataset.create != null) this.create(this.query);
527
+ else this.choose(node.dataset.value);
528
+ } else if (e.key === 'Escape') {
529
+ if (this.open) { e.stopPropagation(); this.hide(); }
530
+ } else if (e.key === 'Backspace' && !this.input.value && this.multi) {
531
+ const last = this.selected.at(-1);
532
+ if (last) this.choose(last.value, true);
533
+ }
534
+ }
535
+
536
+ choose(value, forceOff) {
537
+ const opt = this.options.find(o => o.value === value);
538
+ if (!opt || opt.disabled) return;
539
+ if (this.multi) {
540
+ opt.selected = forceOff ? false : !opt.selected;
541
+ } else {
542
+ this.options.forEach(o => { o.selected = false; });
543
+ opt.selected = true;
544
+ this.input.value = opt.label;
545
+ this.query = '';
546
+ this.hide();
547
+ }
548
+ if (this.multi) { this.input.value = ''; this.query = ''; }
549
+ this.sync();
550
+ this.renderTokens();
551
+ this.render();
552
+ }
553
+
554
+ create(text) {
555
+ const label = text.trim();
556
+ if (!label) return;
557
+ const o = { value: label, label, sub: '', group: '', disabled: false, selected: false };
558
+ this.options.push(o);
559
+ if (this.select) this.select.append(new Option(label, label));
560
+ this.choose(label);
561
+ this.root.dispatchEvent(new CustomEvent('deck:create', { bubbles: true, detail: { value: label } }));
562
+ }
563
+
564
+ renderTokens() {
565
+ if (!this.multi) {
566
+ const one = this.selected[0];
567
+ if (one && document.activeElement !== this.input) this.input.value = one.label;
568
+ this.tokens.innerHTML = '';
569
+ return;
570
+ }
571
+ const max = Number(this.root.dataset.maxTokens || 4);
572
+ const sel = this.selected;
573
+ this.tokens.innerHTML = sel.slice(0, max).map(o =>
574
+ `<span class="combo-token"><span>${escapeHTML(o.label)}</span>
575
+ <button type="button" data-value="${escapeHTML(o.value)}" aria-label="Remove ${escapeHTML(o.label)}">${icon('x', 'icon icon-sm')}</button>
576
+ </span>`).join('')
577
+ + (sel.length > max ? `<span class="combo-count">+${sel.length - max}</span>` : '');
578
+ this.input.placeholder = sel.length ? '' : this.placeholder;
579
+ }
580
+
581
+ sync() {
582
+ if (!this.select) return;
583
+ $$('option', this.select).forEach(o => {
584
+ const match = this.options.find(x => x.value === o.value);
585
+ o.selected = !!(match && match.selected);
586
+ });
587
+ this.select.dispatchEvent(new Event('change', { bubbles: true }));
588
+ this.root.dispatchEvent(new CustomEvent('deck:change', {
589
+ bubbles: true,
590
+ detail: { values: this.selected.map(o => o.value) }
591
+ }));
592
+ }
593
+
594
+ async fetch() {
595
+ const q = this.query.trim();
596
+ if (q.length < this.minChars) return;
597
+ this.list.innerHTML = `<div class="combo-loading"><span class="spinner"></span> Searching</div>`;
598
+ clearTimeout(this._t);
599
+ this._t = setTimeout(async () => {
600
+ try {
601
+ const res = await window.fetch(this.remote + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } });
602
+ const rows = await res.json();
603
+ const keep = this.selected;
604
+ this.options = rows.map(r => ({
605
+ value: String(r.value ?? r.id),
606
+ label: String(r.label ?? r.name),
607
+ sub: r.sub || '',
608
+ group: r.group || '',
609
+ disabled: !!r.disabled,
610
+ selected: keep.some(k => k.value === String(r.value ?? r.id))
611
+ }));
612
+ this.render();
613
+ } catch (err) {
614
+ this.list.innerHTML = `<div class="combo-empty">Could not load results. Try again.</div>`;
615
+ }
616
+ }, Number(this.root.dataset.debounce || 220));
617
+ }
618
+
619
+ show() {
620
+ if (this.open) return;
621
+ this.open = true;
622
+ this.root.classList.add('is-open');
623
+ this.input.setAttribute('aria-expanded', 'true');
624
+ this.render();
625
+ const r = this.control.getBoundingClientRect();
626
+ this.list.classList.toggle('is-above',
627
+ window.innerWidth > 480 && window.innerHeight - r.bottom < 240 && r.top > 240);
628
+ }
629
+
630
+ toggle() { this.open ? this.hide() : this.show(); }
631
+
632
+ hide() {
633
+ if (!this.open) return;
634
+ this.open = false;
635
+ this.root.classList.remove('is-open');
636
+ this.input.setAttribute('aria-expanded', 'false');
637
+ if (!this.multi) {
638
+ const one = this.selected[0];
639
+ this.input.value = one ? one.label : '';
640
+ } else {
641
+ this.input.value = '';
642
+ }
643
+ this.query = '';
644
+ }
645
+ }
646
+
647
+ function escapeHTML(s) {
648
+ return String(s).replace(/[&<>"']/g, c =>
649
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
650
+ }
651
+ function highlight(text, query) {
652
+ const q = query.trim();
653
+ const safe = escapeHTML(text);
654
+ if (!q) return safe;
655
+ const i = text.toLowerCase().indexOf(q.toLowerCase());
656
+ if (i < 0) return safe;
657
+ return escapeHTML(text.slice(0, i)) + '<mark>' + escapeHTML(text.slice(i, i + q.length)) + '</mark>' + escapeHTML(text.slice(i + q.length));
658
+ }
659
+
660
+ /* ======================================================================
661
+ Data grid: pin shadows, sort, select-all, column resize
662
+ <div class="dg-wrap" data-deck-grid> <table class="dg"> … </table> </div>
663
+ Sortable header: <th data-sort="num|text|date">
664
+ ====================================================================== */
665
+
666
+ class Grid {
667
+ constructor(wrap) {
668
+ this.wrap = wrap;
669
+ this.table = $('.dg', wrap);
670
+ if (!this.table) return;
671
+ this.measurePins();
672
+ this.wireSort();
673
+ this.wireSelect();
674
+ this.wireResize();
675
+ /* @container scroll-state() paints the pin shadows on its own; this
676
+ handler is only here for browsers that do not have it yet. */
677
+ if (!CSS.supports('container-type: scroll-state')) {
678
+ this.shadows();
679
+ wrap.addEventListener('scroll', () => this.shadows(), { passive: true });
680
+ }
681
+ new ResizeObserver(() => this.measurePins()).observe(this.table);
682
+ }
683
+
684
+ measurePins() {
685
+ const first = $('thead .dg-pin-start', this.table);
686
+ if (first) this.wrap.style.setProperty('--dg-pin-start', first.offsetWidth + 'px');
687
+ }
688
+
689
+ shadows() {
690
+ const w = this.wrap;
691
+ const x = Math.abs(w.scrollLeft); // an RTL scroller counts down from 0
692
+ w.classList.toggle('is-scrolled-x', x > 0);
693
+ w.classList.toggle('is-scrolled-end', x + w.clientWidth < w.scrollWidth - 1);
694
+ }
695
+
696
+ wireSort() {
697
+ $$('th[data-sort]', this.table).forEach((th, col) => {
698
+ if ($('.dg-sort', th)) return;
699
+ const label = th.textContent.trim();
700
+ th.innerHTML = `<button type="button" class="dg-sort">${escapeHTML(label)}
701
+ <svg class="icon dg-sort-icon" aria-hidden="true"><use href="${Deck.iconSprite}#chevron-up"></use></svg></button>`;
702
+ $('.dg-sort', th).addEventListener('click', () => this.sort(th));
703
+ });
704
+ }
705
+
706
+ sort(th) {
707
+ const head = $$('th', this.table.tHead.rows[0]);
708
+ const index = head.indexOf(th);
709
+ const kind = th.dataset.sort;
710
+ const dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
711
+ head.forEach(h => h.removeAttribute('aria-sort'));
712
+ th.setAttribute('aria-sort', dir);
713
+
714
+ const body = this.table.tBodies[0];
715
+ const rows = Array.from(body.rows);
716
+ const key = row => {
717
+ const cell = row.cells[index];
718
+ const raw = cell?.dataset.value ?? cell?.textContent.trim() ?? '';
719
+ if (kind === 'num') return parseFloat(raw.replace(/[^0-9.\-]/g, '')) || 0;
720
+ if (kind === 'date') return new Date(raw).getTime() || 0;
721
+ return raw.toLowerCase();
722
+ };
723
+ rows.sort((a, b) => {
724
+ const x = key(a), y = key(b);
725
+ return (x < y ? -1 : x > y ? 1 : 0) * (dir === 'ascending' ? 1 : -1);
726
+ });
727
+ rows.forEach(r => body.append(r));
728
+ this.wrap.dispatchEvent(new CustomEvent('deck:sort', { bubbles: true, detail: { index, dir, kind } }));
729
+ }
730
+
731
+ wireSelect() {
732
+ const all = $('thead .dg-check input', this.table);
733
+ const boxes = () => $$('tbody .dg-check input', this.table);
734
+ const update = () => {
735
+ const list = boxes();
736
+ const on = list.filter(b => b.checked);
737
+ list.forEach(b => b.closest('tr').setAttribute('aria-selected', b.checked));
738
+ if (all) {
739
+ all.checked = on.length > 0 && on.length === list.length;
740
+ all.indeterminate = on.length > 0 && on.length < list.length;
741
+ }
742
+ const bar = $('[data-dg-selection]', this.wrap.parentElement || document);
743
+ if (bar) {
744
+ bar.hidden = on.length === 0;
745
+ const count = $('[data-dg-count]', bar);
746
+ if (count) count.textContent = on.length;
747
+ }
748
+ this.wrap.dispatchEvent(new CustomEvent('deck:select', { bubbles: true, detail: { count: on.length } }));
749
+ };
750
+ if (all) all.addEventListener('change', () => {
751
+ boxes().forEach(b => { b.checked = all.checked; });
752
+ update();
753
+ });
754
+ this.table.addEventListener('change', e => {
755
+ if (e.target.closest('.dg-check')) update();
756
+ });
757
+ update();
758
+ }
759
+
760
+ wireResize() {
761
+ $$('th[data-resize]', this.table).forEach(th => {
762
+ if ($('.dg-resize', th)) return;
763
+ th.style.position = 'sticky';
764
+ const grip = el('div', 'dg-resize');
765
+ th.append(grip);
766
+ let x0 = 0, w0 = 0;
767
+ const move = e => {
768
+ const x = (e.touches ? e.touches[0].clientX : e.clientX);
769
+ th.style.width = Math.max(64, w0 + (x - x0)) + 'px';
770
+ this.measurePins();
771
+ };
772
+ const stop = () => {
773
+ this.wrap.classList.remove('is-resizing');
774
+ document.removeEventListener('pointermove', move);
775
+ document.removeEventListener('pointerup', stop);
776
+ };
777
+ grip.addEventListener('pointerdown', e => {
778
+ e.preventDefault();
779
+ x0 = e.clientX;
780
+ w0 = th.offsetWidth;
781
+ this.wrap.classList.add('is-resizing');
782
+ document.addEventListener('pointermove', move);
783
+ document.addEventListener('pointerup', stop);
784
+ });
785
+ });
786
+ }
787
+ }
788
+
789
+ /* ======================================================================
790
+ Toast queue
791
+ Deck.toast('Order #1042 shipped')
792
+ Deck.toast({ title: 'Project archived', text: 'You can undo this.',
793
+ kind: 'warn', duration: 8000,
794
+ actions: [{ label: 'Undo', onClick: fn }] })
795
+ const t = Deck.toast({ kind: 'loading', title: 'Submitting…', duration: 0 });
796
+ t.update({ kind: 'good', title: 'Submitted', duration: 4000 });
797
+ ====================================================================== */
798
+
799
+ const Toaster = {
800
+ region: null,
801
+ items: [],
802
+ max: 3,
803
+ stacked: true,
804
+
805
+ mount() {
806
+ if (this.region) return this.region;
807
+ this.region = $('.toast-region') || el('div', 'toast-region');
808
+ if (this.stacked) this.region.classList.add('toast-region-stacked');
809
+ this.region.setAttribute('role', 'region');
810
+ this.region.setAttribute('aria-label', 'Notifications');
811
+ if (!this.region.isConnected) document.body.append(this.region);
812
+ return this.region;
813
+ },
814
+
815
+ icons: { good: 'check-circle', bad: 'x-circle', warn: 'alert-triangle', info: 'info', loading: null },
816
+
817
+ push(opts) {
818
+ if (typeof opts === 'string') opts = { title: opts };
819
+ const o = Object.assign({ kind: '', duration: 5000, actions: [], dismissible: true }, opts);
820
+ const region = this.mount();
821
+
822
+ const node = el('div', 'toast' + (o.kind ? ' toast-' + o.kind : ''));
823
+ node.setAttribute('role', o.kind === 'bad' ? 'alert' : 'status');
824
+ node.style.setProperty('--toast-duration', o.duration + 'ms');
825
+
826
+ const item = {
827
+ node,
828
+ opts: o,
829
+ dismiss: () => this.dismiss(item),
830
+ update: patch => this.update(item, patch)
831
+ };
832
+
833
+ this.paintBody(item);
834
+ this.wireSwipe(item);
835
+
836
+ region.prepend(node);
837
+ this.items.unshift(item);
838
+ requestAnimationFrame(() => {
839
+ node.style.setProperty('--toast-h', node.offsetHeight + 'px');
840
+ this.reflow();
841
+ });
842
+
843
+ if (o.duration > 0) this.arm(item);
844
+ while (this.items.length > 8) this.dismiss(this.items.at(-1));
845
+ return item;
846
+ },
847
+
848
+ paintBody(item) {
849
+ const o = item.opts;
850
+ const glyph = o.kind === 'loading'
851
+ ? '<span class="spinner toast-icon"></span>'
852
+ : (this.icons[o.kind] ? icon(this.icons[o.kind], 'icon toast-icon') : '');
853
+ item.node.innerHTML = `
854
+ ${glyph}
855
+ <div class="toast-main">
856
+ ${o.title ? `<div class="toast-title">${escapeHTML(o.title)}</div>` : ''}
857
+ ${o.text ? `<div class="toast-text">${escapeHTML(o.text)}</div>` : ''}
858
+ ${o.actions.length ? `<div class="toast-actions">${o.actions.map((a, i) =>
859
+ `<button type="button" class="toast-action" data-action="${i}">${escapeHTML(a.label)}</button>`).join('')}</div>` : ''}
860
+ </div>
861
+ ${o.dismissible ? `<button type="button" class="toast-close" aria-label="Dismiss">${icon('x', 'icon icon-sm')}</button>` : ''}
862
+ ${o.duration > 0 ? '<span class="toast-timer"></span>' : ''}`;
863
+
864
+ $('.toast-close', item.node)?.addEventListener('click', () => this.dismiss(item));
865
+ $$('.toast-action', item.node).forEach(btn => {
866
+ btn.addEventListener('click', () => {
867
+ const a = o.actions[Number(btn.dataset.action)];
868
+ a?.onClick?.(item);
869
+ if (a?.close !== false) this.dismiss(item);
870
+ });
871
+ });
872
+ },
873
+
874
+ update(item, patch) {
875
+ Object.assign(item.opts, patch);
876
+ item.node.className = 'toast' + (item.opts.kind ? ' toast-' + item.opts.kind : '');
877
+ item.node.style.setProperty('--toast-duration', item.opts.duration + 'ms');
878
+ this.paintBody(item);
879
+ this.wireSwipe(item);
880
+ clearTimeout(item.timer);
881
+ if (item.opts.duration > 0) this.arm(item);
882
+ return item;
883
+ },
884
+
885
+ arm(item) {
886
+ clearTimeout(item.timer);
887
+ item.timer = setTimeout(() => this.dismiss(item), item.opts.duration);
888
+ const region = this.region;
889
+ const pause = () => clearTimeout(item.timer);
890
+ const resume = () => { if (item.opts.duration > 0) this.arm(item); };
891
+ region.addEventListener('mouseenter', pause, { once: true });
892
+ region.addEventListener('mouseleave', resume, { once: true });
893
+ },
894
+
895
+ dismiss(item) {
896
+ if (!item || item.leaving) return;
897
+ item.leaving = true;
898
+ clearTimeout(item.timer);
899
+ item.node.classList.add('is-leaving');
900
+ item.node.addEventListener('transitionend', () => item.node.remove(), { once: true });
901
+ setTimeout(() => item.node.remove(), 500);
902
+ this.items = this.items.filter(i => i !== item);
903
+ this.reflow();
904
+ item.opts.onDismiss?.(item);
905
+ },
906
+
907
+ clear() { [...this.items].forEach(i => this.dismiss(i)); },
908
+
909
+ reflow() {
910
+ this.items.forEach((item, i) =>
911
+ item.node.setAttribute('aria-hidden', i >= this.max ? 'true' : 'false'));
912
+ },
913
+
914
+ wireSwipe(item) {
915
+ const node = item.node;
916
+ let x0 = null, dx = 0;
917
+ node.addEventListener('pointerdown', e => {
918
+ if (e.target.closest('button')) return;
919
+ x0 = e.clientX;
920
+ node.classList.add('is-dragging');
921
+ node.setPointerCapture(e.pointerId);
922
+ });
923
+ node.addEventListener('pointermove', e => {
924
+ if (x0 === null) return;
925
+ dx = e.clientX - x0;
926
+ node.style.translate = `${dx}px 0`;
927
+ node.style.opacity = String(Math.max(0, 1 - Math.abs(dx) / 220));
928
+ });
929
+ const end = () => {
930
+ if (x0 === null) return;
931
+ node.classList.remove('is-dragging');
932
+ if (Math.abs(dx) > 90) {
933
+ node.style.setProperty('--toast-exit-x', (dx > 0 ? 400 : -400) + 'px');
934
+ this.dismiss(item);
935
+ } else {
936
+ node.style.translate = '';
937
+ node.style.opacity = '';
938
+ }
939
+ x0 = null; dx = 0;
940
+ };
941
+ node.addEventListener('pointerup', end);
942
+ node.addEventListener('pointercancel', end);
943
+ }
944
+ };
945
+
946
+ /* ======================================================================
947
+ Motion
948
+ Ripples, a reveal fallback for browsers without scroll-driven animation,
949
+ stagger indexes past the twelfth child, and view-transition helpers.
950
+ ====================================================================== */
951
+
952
+ const reduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
953
+
954
+ function wireRipple(root) {
955
+ $$('.ripple', root).forEach(node => {
956
+ if (node.dataset.deckWired) return;
957
+ node.dataset.deckWired = '1';
958
+ node.addEventListener('pointerdown', e => {
959
+ if (reduced()) return;
960
+ const r = node.getBoundingClientRect();
961
+ const ink = el('span', 'ripple-ink');
962
+ ink.style.setProperty('--rx', (e.clientX - r.left) + 'px');
963
+ ink.style.setProperty('--ry', (e.clientY - r.top) + 'px');
964
+ node.append(ink);
965
+ ink.addEventListener('animationend', () => ink.remove(), { once: true });
966
+ setTimeout(() => ink.remove(), 900);
967
+ });
968
+ });
969
+ }
970
+
971
+ function wireReveal(root) {
972
+ if (CSS.supports('animation-timeline: view()')) return; // CSS handles it
973
+ const targets = $$('.reveal, .reveal-fade, .reveal-pop', root)
974
+ .filter(n => !n.dataset.deckWired);
975
+ if (!targets.length) return;
976
+ if (reduced()) return targets.forEach(n => { n.style.opacity = '1'; });
977
+
978
+ const io = new IntersectionObserver(entries => {
979
+ entries.forEach(entry => {
980
+ if (!entry.isIntersecting) return;
981
+ entry.target.classList.add('is-revealed');
982
+ io.unobserve(entry.target);
983
+ });
984
+ }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
985
+
986
+ targets.forEach(n => { n.dataset.deckWired = '1'; io.observe(n); });
987
+ }
988
+
989
+ function wireValidationShake(root) {
990
+ $$('.field', root).forEach(field => {
991
+ if (field.dataset.deckWired) return;
992
+ field.dataset.deckWired = '1';
993
+ field.addEventListener('animationend', () => field.classList.add('was-shaken'));
994
+ $$('input, textarea, select', field).forEach(input => {
995
+ input.addEventListener('input', () => {
996
+ if (input.checkValidity()) {
997
+ field.classList.remove('is-invalid', 'was-shaken');
998
+ }
999
+ });
1000
+ });
1001
+ });
1002
+ }
1003
+
1004
+ function wireViewTransitions() {
1005
+ if (!('startViewTransition' in document)) return;
1006
+ // Give the browser a direction hint so back navigations slide the other way
1007
+ window.addEventListener('popstate', () => {
1008
+ document.documentElement.dataset.vt = 'back';
1009
+ });
1010
+ window.addEventListener('pageswap', () => {
1011
+ if (!performance.getEntriesByType('navigation')[0]) return;
1012
+ delete document.documentElement.dataset.vt;
1013
+ });
1014
+ }
1015
+
1016
+ function wireTick(root) {
1017
+ $$('[data-deck-tick]', root).forEach(node => {
1018
+ if (node.dataset.deckWired) return;
1019
+ node.dataset.deckWired = '1';
1020
+ node.classList.add('tick');
1021
+ let last = parseFloat(node.textContent.replace(/[^0-9.\-]/g, '')) || 0;
1022
+ new MutationObserver(() => {
1023
+ const now = parseFloat(node.textContent.replace(/[^0-9.\-]/g, '')) || 0;
1024
+ if (now === last) return;
1025
+ node.classList.remove('is-up', 'is-down');
1026
+ void node.offsetWidth; // restart the animation
1027
+ node.classList.add(now > last ? 'is-up' : 'is-down');
1028
+ last = now;
1029
+ }).observe(node, { childList: true, characterData: true, subtree: true });
1030
+ });
1031
+ }
1032
+
1033
+ /* ======================================================================
1034
+ Space: tilt, flip, coverflow
1035
+ ====================================================================== */
1036
+
1037
+ function wireTilt(root) {
1038
+ if (!window.matchMedia('(hover: hover)').matches) return;
1039
+ $$('.tilt', root).forEach(node => {
1040
+ if (node.dataset.deckWired) return;
1041
+ node.dataset.deckWired = '1';
1042
+ const max = Number(node.dataset.tilt || 9);
1043
+
1044
+ const move = e => {
1045
+ if (reduced()) return;
1046
+ const r = node.getBoundingClientRect();
1047
+ const px = (e.clientX - r.left) / r.width - 0.5;
1048
+ const py = (e.clientY - r.top) / r.height - 0.5;
1049
+ // one rotation about a computed axis, so it composes as a single value
1050
+ const amount = Math.hypot(px, py) * max * 2;
1051
+ node.style.setProperty('--tilt-axis', `${py.toFixed(3)} ${(-px).toFixed(3)} 0`);
1052
+ node.style.setProperty('--tilt-amount', amount.toFixed(2) + 'deg');
1053
+ node.style.setProperty('--gx', ((px + 0.5) * 100).toFixed(1) + '%');
1054
+ node.style.setProperty('--gy', ((py + 0.5) * 100).toFixed(1) + '%');
1055
+ };
1056
+ const rest = () => {
1057
+ node.classList.remove('is-tilting');
1058
+ node.style.setProperty('--tilt-amount', '0deg');
1059
+ };
1060
+ node.addEventListener('pointerenter', () => node.classList.add('is-tilting'));
1061
+ node.addEventListener('pointermove', move);
1062
+ node.addEventListener('pointerleave', rest);
1063
+ node.addEventListener('blur', rest);
1064
+ });
1065
+ }
1066
+
1067
+ function wireFlip(root) {
1068
+ $$('[data-deck-flip]', root).forEach(trigger => {
1069
+ if (trigger.dataset.deckWired) return;
1070
+ trigger.dataset.deckWired = '1';
1071
+ const target = trigger.dataset.deckFlip
1072
+ ? $(trigger.dataset.deckFlip)
1073
+ : trigger.closest('.flip, .flip-x');
1074
+ if (!target) return;
1075
+ trigger.addEventListener('click', () => {
1076
+ const on = target.classList.toggle('is-flipped');
1077
+ trigger.setAttribute('aria-pressed', String(on));
1078
+ // the hidden face should not be reachable by keyboard
1079
+ $$('.flip-front, .flip-back', target).forEach((face, i) => {
1080
+ face.inert = on ? i === 0 : i === 1;
1081
+ });
1082
+ });
1083
+ });
1084
+ }
1085
+
1086
+ function wireCoverflow(root) {
1087
+ $$('.coverflow', root).forEach(strip => {
1088
+ if (strip.dataset.deckWired) return;
1089
+ strip.dataset.deckWired = '1';
1090
+ const items = Array.from(strip.children);
1091
+ const paint = () => {
1092
+ const mid = strip.scrollLeft + strip.clientWidth / 2;
1093
+ const rtl = getComputedStyle(strip).direction === 'rtl';
1094
+ items.forEach(item => {
1095
+ const center = item.offsetLeft + item.offsetWidth / 2;
1096
+ const d = center - mid;
1097
+ item.classList.toggle('is-front', Math.abs(d) < item.offsetWidth / 2);
1098
+ item.classList.toggle('is-behind-start', d < -item.offsetWidth / 2 !== rtl && Math.abs(d) >= item.offsetWidth / 2);
1099
+ item.classList.toggle('is-behind-end', d > item.offsetWidth / 2 !== rtl && Math.abs(d) >= item.offsetWidth / 2);
1100
+ });
1101
+ };
1102
+ strip.addEventListener('scroll', () => requestAnimationFrame(paint), { passive: true });
1103
+ new ResizeObserver(paint).observe(strip);
1104
+ paint();
1105
+ });
1106
+ }
1107
+
1108
+ function wireStack(root) {
1109
+ $$('.pile', root).forEach(stack => {
1110
+ if (stack.dataset.deckWired) return;
1111
+ stack.dataset.deckWired = '1';
1112
+ stack.addEventListener('deck:advance', () => {
1113
+ const top = stack.querySelector(':scope > *:not(.is-dismissed)');
1114
+ if (!top) return;
1115
+ top.classList.add('is-dismissed');
1116
+ /* Once it has flown off, move it to the end of the pile. It stays in
1117
+ the DOM, but out of the way of everyone else's sibling-index(). */
1118
+ setTimeout(() => stack.append(top), 400);
1119
+ });
1120
+ });
1121
+ }
1122
+
1123
+ /* ======================================================================
1124
+ Small conveniences that pair with the CSS
1125
+ ====================================================================== */
1126
+
1127
+ function wireGroups(root) {
1128
+ $$('.segmented, .tabs', root).forEach(group => {
1129
+ if (group.dataset.deckWired) return;
1130
+ group.dataset.deckWired = '1';
1131
+ group.addEventListener('click', e => {
1132
+ const item = e.target.closest('[aria-selected]');
1133
+ if (!item) return;
1134
+ $$('[aria-selected]', group).forEach(b => b.setAttribute('aria-selected', 'false'));
1135
+ item.setAttribute('aria-selected', 'true');
1136
+ group.dispatchEvent(new CustomEvent('deck:change', { bubbles: true, detail: { value: item.dataset.value ?? item.textContent.trim() } }));
1137
+ });
1138
+ });
1139
+ }
1140
+
1141
+ /* Anchor positioning needs a unique anchor-name per pair. Rather than make
1142
+ the author invent one, pair up every popovertarget with its panel and
1143
+ generate it. Where the browser lacks anchor positioning this is inert and
1144
+ the Floating UI adapter or Deck's own placement takes over. */
1145
+ let anchorSeq = 0;
1146
+ function wireAnchors(root) {
1147
+ if (!CSS.supports('anchor-name: --a')) return;
1148
+ $$('[popovertarget]', root).forEach(trigger => {
1149
+ const panel = document.getElementById(trigger.getAttribute('popovertarget'));
1150
+ if (!panel || trigger.dataset.deckAnchored) return;
1151
+ trigger.dataset.deckAnchored = '1';
1152
+ const name = panel.style.getPropertyValue('--anchor') || `--dk-a${++anchorSeq}`;
1153
+ trigger.classList.add('anchor');
1154
+ trigger.style.setProperty('--anchor', name);
1155
+ panel.style.setProperty('--anchor', name);
1156
+ trigger.setAttribute('data-deck-anchor', '#' + panel.id);
1157
+ });
1158
+ }
1159
+
1160
+ function wireTheme(root) {
1161
+ $$('[data-deck-theme]', root).forEach(btn => {
1162
+ if (btn.dataset.deckWired) return;
1163
+ btn.dataset.deckWired = '1';
1164
+ btn.addEventListener('click', () => {
1165
+ /* The theme in effect, not just the attribute. With no saved choice there
1166
+ is no attribute and the OS decides, so reading the attribute alone made
1167
+ the first press on a dark machine set "dark" on a page already dark. */
1168
+ const set = document.documentElement.getAttribute('data-theme');
1169
+ const dark = set ? set === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
1170
+ Deck.theme(dark ? 'light' : 'dark');
1171
+ });
1172
+ });
1173
+ }
1174
+
1175
+ /* Fallback for browsers with no scroll-driven animations. The CSS above
1176
+ handles show and hide everywhere else, so this only runs where
1177
+ animation-timeline is missing -- and never as a scroll handler. A sentinel
1178
+ the height of the reveal threshold sits at the top of the document; when it
1179
+ leaves the viewport, we are past it. */
1180
+ let bttObserver = null;
1181
+
1182
+ function wireBackToTop(root) {
1183
+ if (CSS.supports('animation-timeline: scroll()')) return; // CSS handles it
1184
+ if (reduced()) return; // CSS leaves the link visible; nothing to toggle
1185
+ const links = $$('.back-to-top', root).filter(n => !n.dataset.deckBtt);
1186
+ if (!links.length) return;
1187
+ /* data-deck-btt is both the once-guard and the CSS hook: without it the
1188
+ link stays visible, so a browser that runs no JS still gets a working
1189
+ one rather than an invisible one. */
1190
+ links.forEach(n => { n.dataset.deckBtt = '1'; });
1191
+
1192
+ let sentinel = document.getElementById('dk-btt-sentinel');
1193
+ if (!sentinel) {
1194
+ sentinel = document.createElement('div');
1195
+ sentinel.id = 'dk-btt-sentinel';
1196
+ sentinel.setAttribute('aria-hidden', 'true');
1197
+ sentinel.style.cssText =
1198
+ 'position:absolute;inset-block-start:0;inset-inline-start:0;' +
1199
+ 'inline-size:1px;block-size:400px;visibility:hidden;pointer-events:none;';
1200
+ document.body.prepend(sentinel);
1201
+ }
1202
+
1203
+ if (bttObserver) bttObserver.disconnect();
1204
+ bttObserver = new IntersectionObserver(entries => {
1205
+ const past = !entries[entries.length - 1].isIntersecting;
1206
+ $$('.back-to-top[data-deck-btt]').forEach(n => n.classList.toggle('is-visible', past));
1207
+ });
1208
+ bttObserver.observe(sentinel);
1209
+ }
1210
+
1211
+ /* ====================================================================== */
1212
+
1213
+ const Deck = {
1214
+ iconSprite: spriteURL,
1215
+ version: '0.1',
1216
+
1217
+ init(root = document) {
1218
+ $$('[data-deck-datepicker]', root).forEach(n => { if (!n._deck) n._deck = new DatePicker(n); });
1219
+ $$('[data-deck-combo]', root).forEach(n => { if (!n._deck) n._deck = new Combo(n); });
1220
+ $$('[data-deck-grid]', root).forEach(n => { if (!n._deck) n._deck = new Grid(n); });
1221
+ wireGroups(root);
1222
+ wireAnchors(root);
1223
+ wireTheme(root);
1224
+ wireBackToTop(root);
1225
+ wireRipple(root);
1226
+ wireReveal(root);
1227
+ wireValidationShake(root);
1228
+ wireTick(root);
1229
+ wireTilt(root);
1230
+ wireFlip(root);
1231
+ wireCoverflow(root);
1232
+ wireStack(root);
1233
+ return Deck;
1234
+ },
1235
+
1236
+ toast(opts) { return Toaster.push(opts); },
1237
+ toasts: Toaster,
1238
+
1239
+ theme(mode) {
1240
+ if (!mode) return document.documentElement.getAttribute('data-theme') || 'auto';
1241
+ document.documentElement.setAttribute('data-theme', mode);
1242
+ try { localStorage.setItem('deck-theme', mode); } catch (e) {}
1243
+ return mode;
1244
+ },
1245
+
1246
+ hue(n) { document.documentElement.style.setProperty('--hue-brand', n); },
1247
+
1248
+ /* Locale-aware month, weekday, and first-day-of-week data from Intl. */
1249
+ locale(tag) { return names(tag); },
1250
+
1251
+ /* Wrap a DOM change so the browser tweens between before and after.
1252
+ Falls back to running the change immediately where unsupported or where
1253
+ the person asked for reduced motion.
1254
+ Deck.transition(() => row.remove());
1255
+ Deck.transition(() => list.prepend(newRow), { name: 'row-42' }); */
1256
+ transition(update, opts = {}) {
1257
+ if (reduced() || !document.startViewTransition) {
1258
+ const out = update();
1259
+ return { finished: Promise.resolve(out), ready: Promise.resolve() };
1260
+ }
1261
+ if (opts.direction) document.documentElement.dataset.vt = opts.direction;
1262
+ const vt = document.startViewTransition(update);
1263
+ vt.finished.finally(() => { delete document.documentElement.dataset.vt; });
1264
+ return vt;
1265
+ },
1266
+
1267
+ /* Play a one-shot animation class and clean up after itself.
1268
+ Deck.play(field, 'shake');
1269
+ await Deck.play(card, 'flash-good'); */
1270
+ play(node, className) {
1271
+ if (!node) return Promise.resolve();
1272
+ node.classList.remove(className);
1273
+ void node.offsetWidth;
1274
+ node.classList.add(className, 'is-animating');
1275
+ return new Promise(resolve => {
1276
+ const done = () => {
1277
+ node.classList.remove(className, 'is-animating');
1278
+ resolve(node);
1279
+ };
1280
+ node.addEventListener('animationend', done, { once: true });
1281
+ setTimeout(done, 2000);
1282
+ });
1283
+ },
1284
+
1285
+ /* Height:auto expand/collapse with no measuring.
1286
+ Deck.toggle(panel); */
1287
+ toggle(node, force) {
1288
+ const open = force ?? !node.classList.contains('is-open');
1289
+ node.classList.add('expand');
1290
+ node.classList.toggle('is-open', open);
1291
+ return open;
1292
+ },
1293
+
1294
+ /* Read or set text direction. Deck is written in logical properties, so
1295
+ this is all that a full RTL flip requires.
1296
+ Deck.dir('rtl'); */
1297
+ dir(value) {
1298
+ if (!value) return document.documentElement.getAttribute('dir') || 'ltr';
1299
+ document.documentElement.setAttribute('dir', value);
1300
+ try { localStorage.setItem('deck-dir', value); } catch (e) {}
1301
+ return value;
1302
+ },
1303
+
1304
+ /* Flip a card, advance a depth stack, rotate a cube. */
1305
+ flip(node, force) {
1306
+ const on = node.classList.toggle('is-flipped', force);
1307
+ return on;
1308
+ },
1309
+ advance(stack) { stack.dispatchEvent(new CustomEvent('deck:advance')); },
1310
+ face(cube, name) { cube.dataset.face = name; return name; },
1311
+
1312
+ reduced
1313
+ };
1314
+
1315
+ wireViewTransitions();
1316
+
1317
+ document.addEventListener('DOMContentLoaded', () => {
1318
+ try {
1319
+ const saved = localStorage.getItem('deck-theme');
1320
+ if (saved) document.documentElement.setAttribute('data-theme', saved);
1321
+ const dir = localStorage.getItem('deck-dir');
1322
+ if (dir) document.documentElement.setAttribute('dir', dir);
1323
+ } catch (e) {}
1324
+ Deck.init();
1325
+ });
1326
+
1327
+ return Deck;
1328
+ })();
1329
+
1330
+ /* Expose on the global so the optional scripts and page code can find Deck
1331
+ whether it was loaded as a classic script, a module, or from a bundler. */
1332
+ if (typeof window !== 'undefined') window.Deck = Deck;
1333
+ if (typeof globalThis !== 'undefined') globalThis.Deck = Deck;
1334
+ if (typeof module !== 'undefined' && module.exports) module.exports = Deck;
1335
+
1336
+ /*! ==========================================================================
1337
+ * deck-extras.js v0.1 — behaviour for the extended component set
1338
+ * Loads after deck.js and registers itself with Deck.init.
1339
+ * No dependencies, including the QR encoder.
1340
+ * ======================================================================== */
1341
+
1342
+ (function (Deck) {
1343
+ 'use strict';
1344
+ if (!Deck) { console.warn('deck-extras.js: load deck.js first'); return; }
1345
+
1346
+ const $ = (s, r = document) => r.querySelector(s);
1347
+ const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
1348
+ const once = node => { if (node.dataset.deckX) return false; node.dataset.deckX = '1'; return true; };
1349
+ const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
1350
+
1351
+ /* ======================================================================
1352
+ Carousel
1353
+ <div class="carousel" data-deck-carousel data-autoplay="6000">
1354
+ ====================================================================== */
1355
+
1356
+ function carousels(root) {
1357
+ $$('[data-deck-carousel]', root).forEach(car => {
1358
+ if (!once(car)) return;
1359
+ const track = $('.carousel-track', car);
1360
+ if (!track) return;
1361
+ const slides = $$('.carousel-slide', track);
1362
+ /* ::scroll-marker and ::scroll-button give us the dots, the arrows, the
1363
+ active and disabled states and the keyboard order for free. Where they
1364
+ exist the CSS hides the DIY ones, so drop the references here and the
1365
+ building and wiring below skips itself. */
1366
+ const diy = !CSS.supports('selector(::scroll-marker)');
1367
+ const dots = diy ? $('.carousel-dots', car) : null;
1368
+ const prev = diy ? $('.carousel-prev', car) : null;
1369
+ const next = diy ? $('.carousel-next', car) : null;
1370
+ const rtl = () => getComputedStyle(track).direction === 'rtl';
1371
+
1372
+ if (dots && !dots.children.length) {
1373
+ dots.innerHTML = slides.map((s, i) =>
1374
+ `<button type="button" class="carousel-dot" data-go="${i}" aria-label="Slide ${i + 1}"></button>`).join('');
1375
+ }
1376
+
1377
+ const current = () => {
1378
+ const mid = track.scrollLeft + track.clientWidth / 2;
1379
+ let best = 0, bestD = Infinity;
1380
+ slides.forEach((s, i) => {
1381
+ const d = Math.abs(s.offsetLeft + s.offsetWidth / 2 - mid);
1382
+ if (d < bestD) { bestD = d; best = i; }
1383
+ });
1384
+ return best;
1385
+ };
1386
+
1387
+ const go = i => {
1388
+ const s = slides[Math.max(0, Math.min(slides.length - 1, i))];
1389
+ if (!s) return;
1390
+ track.scrollTo({
1391
+ left: s.offsetLeft - (track.clientWidth - s.offsetWidth) / 2,
1392
+ behavior: reduced() ? 'auto' : 'smooth'
1393
+ });
1394
+ };
1395
+
1396
+ const paint = () => {
1397
+ const i = current();
1398
+ if (dots) $$('.carousel-dot', dots).forEach((d, n) =>
1399
+ d.setAttribute('aria-current', String(n === i)));
1400
+ if (prev) prev.disabled = i === 0;
1401
+ if (next) next.disabled = i === slides.length - 1;
1402
+ car.dispatchEvent(new CustomEvent('deck:slide', { bubbles: true, detail: { index: i } }));
1403
+ };
1404
+
1405
+ track.addEventListener('scroll', () => requestAnimationFrame(paint), { passive: true });
1406
+ prev?.addEventListener('click', () => go(current() - 1));
1407
+ next?.addEventListener('click', () => go(current() + 1));
1408
+ dots?.addEventListener('click', e => {
1409
+ const b = e.target.closest('[data-go]');
1410
+ if (b) go(Number(b.dataset.go));
1411
+ });
1412
+
1413
+ car.addEventListener('keydown', e => {
1414
+ const back = rtl() ? 'ArrowRight' : 'ArrowLeft';
1415
+ const fwd = rtl() ? 'ArrowLeft' : 'ArrowRight';
1416
+ if (e.key === back) { e.preventDefault(); go(current() - 1); }
1417
+ if (e.key === fwd) { e.preventDefault(); go(current() + 1); }
1418
+ });
1419
+
1420
+ const ms = Number(car.dataset.autoplay || 0);
1421
+ if (ms > 0 && !reduced()) {
1422
+ let timer = setInterval(tick, ms);
1423
+ function tick() {
1424
+ const i = current();
1425
+ go(i >= slides.length - 1 ? 0 : i + 1);
1426
+ }
1427
+ const stop = () => clearInterval(timer);
1428
+ const start = () => { stop(); timer = setInterval(tick, ms); };
1429
+ car.addEventListener('pointerenter', stop);
1430
+ car.addEventListener('pointerleave', start);
1431
+ car.addEventListener('focusin', stop);
1432
+ document.addEventListener('visibilitychange', () => document.hidden ? stop() : start());
1433
+ }
1434
+
1435
+ paint();
1436
+ car.carousel = { go, current, paint };
1437
+ });
1438
+ }
1439
+
1440
+ /* ======================================================================
1441
+ Drawer, mega menu, speed dial, banner
1442
+ ====================================================================== */
1443
+
1444
+ function drawers(root) {
1445
+ $$('[data-deck-drawer]', root).forEach(trigger => {
1446
+ if (!once(trigger)) return;
1447
+ const target = $(trigger.dataset.deckDrawer);
1448
+ if (!target) return;
1449
+ trigger.addEventListener('click', () => target.showModal());
1450
+ // Click on the backdrop closes it; the dialog element itself does not
1451
+ target.addEventListener('click', e => {
1452
+ if (e.target === target) target.close();
1453
+ });
1454
+ $$('[data-drawer-close]', target).forEach(b =>
1455
+ b.addEventListener('click', () => target.close()));
1456
+ });
1457
+ }
1458
+
1459
+ function megaMenus(root) {
1460
+ $$('[data-deck-mega]', root).forEach(trigger => {
1461
+ if (!once(trigger)) return;
1462
+ const panel = $(trigger.dataset.deckMega);
1463
+ if (!panel) return;
1464
+ const place = () => {
1465
+ /* 25-anchor.css anchors .mega where the browser supports it, and these
1466
+ inline insets would then be resolved against the anchored region and
1467
+ double-offset the panel. */
1468
+ if (CSS.supports('anchor-name: --a')) return;
1469
+ const r = trigger.getBoundingClientRect();
1470
+ panel.style.insetBlockStart = (r.bottom + 8) + 'px';
1471
+ const w = panel.offsetWidth;
1472
+ const start = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), innerWidth - w - 8);
1473
+ panel.style.insetInlineStart = start + 'px';
1474
+ };
1475
+ panel.addEventListener('toggle', e => { if (e.newState === 'open') place(); });
1476
+ addEventListener('resize', () => { if (panel.matches(':popover-open')) place(); });
1477
+ // Hover intent on pointer devices, click everywhere
1478
+ if (matchMedia('(hover: hover)').matches) {
1479
+ let t;
1480
+ const open = () => { clearTimeout(t); t = setTimeout(() => panel.showPopover(), 90); };
1481
+ const close = () => { clearTimeout(t); t = setTimeout(() => panel.hidePopover(), 180); };
1482
+ [trigger, panel].forEach(n => {
1483
+ n.addEventListener('pointerenter', open);
1484
+ n.addEventListener('pointerleave', close);
1485
+ });
1486
+ }
1487
+ });
1488
+ }
1489
+
1490
+ function speedDials(root) {
1491
+ $$('.speed-dial', root).forEach(dial => {
1492
+ if (!once(dial)) return;
1493
+ const fab = $('.fab', dial);
1494
+ const set = on => {
1495
+ dial.classList.toggle('is-open', on);
1496
+ fab?.setAttribute('aria-expanded', String(on));
1497
+ };
1498
+ fab?.addEventListener('click', () => set(!dial.classList.contains('is-open')));
1499
+ document.addEventListener('click', e => {
1500
+ if (!dial.contains(e.target)) set(false);
1501
+ });
1502
+ dial.addEventListener('keydown', e => { if (e.key === 'Escape') { set(false); fab?.focus(); } });
1503
+ });
1504
+ }
1505
+
1506
+ function banners(root) {
1507
+ $$('.banner', root).forEach(banner => {
1508
+ if (!once(banner)) return;
1509
+ const key = banner.dataset.dismissKey;
1510
+ if (key) {
1511
+ try { if (localStorage.getItem('deck-banner-' + key)) { banner.hidden = true; return; } } catch (e) {}
1512
+ }
1513
+ $('.banner-close', banner)?.addEventListener('click', () => {
1514
+ banner.hidden = true;
1515
+ if (key) { try { localStorage.setItem('deck-banner-' + key, '1'); } catch (e) {} }
1516
+ });
1517
+ });
1518
+ }
1519
+
1520
+ /* ======================================================================
1521
+ Number, phone, range pair
1522
+ ====================================================================== */
1523
+
1524
+ function numbers(root) {
1525
+ $$('.number', root).forEach(box => {
1526
+ if (!once(box)) return;
1527
+ const input = $('input', box);
1528
+ const step = Number(input.step) || 1;
1529
+ const min = input.min === '' ? -Infinity : Number(input.min);
1530
+ const max = input.max === '' ? Infinity : Number(input.max);
1531
+ const decimals = (String(step).split('.')[1] || '').length;
1532
+
1533
+ const nudge = dir => {
1534
+ const now = Number(input.value) || 0;
1535
+ const next = Math.min(max, Math.max(min, now + dir * step));
1536
+ input.value = next.toFixed(decimals);
1537
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1538
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1539
+ paint();
1540
+ };
1541
+ const paint = () => {
1542
+ const now = Number(input.value);
1543
+ $$('button', box).forEach(b => {
1544
+ const d = Number(b.dataset.step || (b === box.firstElementChild ? -1 : 1));
1545
+ b.disabled = d < 0 ? now <= min : now >= max;
1546
+ });
1547
+ };
1548
+
1549
+ $$('button', box).forEach(b => {
1550
+ const dir = Number(b.dataset.step || (b === box.firstElementChild ? -1 : 1));
1551
+ let hold, repeat;
1552
+ const start = e => {
1553
+ e.preventDefault();
1554
+ nudge(dir);
1555
+ hold = setTimeout(() => { repeat = setInterval(() => nudge(dir), 70); }, 420);
1556
+ };
1557
+ const stop = () => { clearTimeout(hold); clearInterval(repeat); };
1558
+ b.addEventListener('pointerdown', start);
1559
+ ['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => b.addEventListener(ev, stop));
1560
+ });
1561
+ input.addEventListener('input', paint);
1562
+ paint();
1563
+ });
1564
+ }
1565
+
1566
+ function phones(root) {
1567
+ $$('.phone', root).forEach(box => {
1568
+ if (!once(box)) return;
1569
+ const input = $('input', box);
1570
+ const select = $('select', box);
1571
+ const codeEl = $('.phone-code', box);
1572
+ const flagEl = $('.phone-flag', box);
1573
+
1574
+ const sync = () => {
1575
+ const opt = select?.selectedOptions[0];
1576
+ if (!opt) return;
1577
+ if (codeEl) codeEl.textContent = opt.dataset.code || '';
1578
+ if (flagEl) flagEl.textContent = opt.dataset.flag || '';
1579
+ input.placeholder = opt.dataset.mask
1580
+ ? opt.dataset.mask.replace(/#/g, '0')
1581
+ : input.placeholder;
1582
+ };
1583
+ select?.addEventListener('change', () => { sync(); format(); });
1584
+
1585
+ /* FINDINGS 62: this destroys a digit when someone edits the middle of a
1586
+ number. The loop stops at the end of the MASK, not the end of the
1587
+ digits, so inserting a character shifts everything along and the last
1588
+ digit falls off with no warning -- measured: caret at index 8 of
1589
+ "(202) 555-0147", type 9, get "(202) 559-5014". The row still reports
1590
+ itself .is-valid because it still holds ten digits.
1591
+
1592
+ Assigning input.value also collapses the caret to the end.
1593
+
1594
+ Fix: count digits before the caret, rebuild, restore the caret to the
1595
+ same digit offset; and refuse the keystroke when the mask is full
1596
+ rather than dropping data silently. */
1597
+ const format = () => {
1598
+ const mask = select?.selectedOptions[0]?.dataset.mask;
1599
+ const digits = input.value.replace(/\D/g, '');
1600
+ if (!mask) { input.value = digits; return; }
1601
+ let out = '', d = 0;
1602
+ for (const ch of mask) {
1603
+ if (d >= digits.length) break;
1604
+ out += ch === '#' ? digits[d++] : ch;
1605
+ }
1606
+ input.value = out;
1607
+ const full = (mask.match(/#/g) || []).length;
1608
+ box.classList.toggle('is-valid', digits.length === full);
1609
+ };
1610
+ input.addEventListener('input', format);
1611
+ input.addEventListener('blur', () => {
1612
+ box.dispatchEvent(new CustomEvent('deck:change', {
1613
+ bubbles: true,
1614
+ detail: {
1615
+ code: select?.selectedOptions[0]?.dataset.code || '',
1616
+ number: input.value.replace(/\D/g, ''),
1617
+ e164: (select?.selectedOptions[0]?.dataset.code || '') + input.value.replace(/\D/g, '')
1618
+ }
1619
+ }));
1620
+ });
1621
+ sync();
1622
+ });
1623
+ }
1624
+
1625
+ function rangePairs(root) {
1626
+ $$('.range-pair', root).forEach(pair => {
1627
+ if (!once(pair)) return;
1628
+ const [lo, hi] = $$('input[type="range"]', pair);
1629
+ if (!lo || !hi) return;
1630
+ const min = Number(lo.min || 0), max = Number(lo.max || 100);
1631
+ const pct = v => ((v - min) / (max - min)) * 100;
1632
+ const gap = Number(pair.dataset.gap || 0);
1633
+
1634
+ const paint = () => {
1635
+ if (Number(lo.value) > Number(hi.value) - gap) {
1636
+ // whichever handle moved last gets pushed back
1637
+ if (document.activeElement === lo) lo.value = String(Number(hi.value) - gap);
1638
+ else hi.value = String(Number(lo.value) + gap);
1639
+ }
1640
+ pair.style.setProperty('--lo', pct(Number(lo.value)).toFixed(2));
1641
+ pair.style.setProperty('--hi', pct(Number(hi.value)).toFixed(2));
1642
+ const readout = pair.parentElement?.querySelector('.range-readout');
1643
+ if (readout) {
1644
+ const [a, b] = readout.children;
1645
+ if (a) a.textContent = lo.dataset.prefix ? lo.dataset.prefix + lo.value : lo.value;
1646
+ if (b) b.textContent = hi.dataset.prefix ? hi.dataset.prefix + hi.value : hi.value;
1647
+ }
1648
+ pair.dispatchEvent(new CustomEvent('deck:change', {
1649
+ bubbles: true, detail: { min: Number(lo.value), max: Number(hi.value) }
1650
+ }));
1651
+ };
1652
+ [lo, hi].forEach(i => i.addEventListener('input', paint));
1653
+ paint();
1654
+ });
1655
+ }
1656
+
1657
+ /* ======================================================================
1658
+ Copy to clipboard
1659
+ ====================================================================== */
1660
+
1661
+ async function writeClipboard(text) {
1662
+ try {
1663
+ await navigator.clipboard.writeText(text);
1664
+ return true;
1665
+ } catch (e) {
1666
+ // http origins and older engines
1667
+ const ta = document.createElement('textarea');
1668
+ ta.value = text;
1669
+ ta.setAttribute('readonly', '');
1670
+ ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
1671
+ document.body.append(ta);
1672
+ ta.select();
1673
+ let ok = false;
1674
+ try { ok = document.execCommand('copy'); } catch (err) {}
1675
+ ta.remove();
1676
+ return ok;
1677
+ }
1678
+ }
1679
+
1680
+ function copiers(root) {
1681
+ $$('[data-deck-copy]', root).forEach(btn => {
1682
+ if (!once(btn)) return;
1683
+ btn.addEventListener('click', async () => {
1684
+ const sel = btn.dataset.deckCopy;
1685
+ const text = sel && sel !== 'true'
1686
+ ? ($(sel)?.value ?? $(sel)?.textContent ?? sel)
1687
+ : (btn.closest('.copy')?.querySelector('code, .copy-value')?.textContent
1688
+ ?? btn.previousElementSibling?.textContent ?? '');
1689
+ const ok = await writeClipboard(String(text).trim());
1690
+ btn.classList.toggle('is-copied', ok);
1691
+ /* FINDINGS 63: this is set AFTER the subtree has already changed, so
1692
+ the first press announces nothing -- a live region has to exist
1693
+ before its content changes. Every press after that announces,
1694
+ because the attribute is by then already there. Declare it in the
1695
+ markup, or write into a separate role="status" element. */
1696
+ btn.setAttribute('aria-live', 'polite');
1697
+ if (ok && !btn.classList.contains('copy-inline') && !$('.copy-done', btn)) {
1698
+ Deck.toast({ kind: 'good', title: 'Copied', duration: 2000 });
1699
+ }
1700
+ setTimeout(() => btn.classList.remove('is-copied'), 1800);
1701
+ });
1702
+ });
1703
+ }
1704
+
1705
+ /* ======================================================================
1706
+ WYSIWYG editor
1707
+ ====================================================================== */
1708
+
1709
+ function editors(root) {
1710
+ $$('.editor', root).forEach(ed => {
1711
+ if (!once(ed)) return;
1712
+ const content = $('.editor-content', ed);
1713
+ const target = ed.dataset.target ? $(ed.dataset.target) : null;
1714
+ const counter = $('.editor-count', ed);
1715
+ const limit = Number(ed.dataset.limit || 0);
1716
+ if (!content) return;
1717
+ content.contentEditable = 'true';
1718
+ content.setAttribute('role', 'textbox');
1719
+ content.setAttribute('aria-multiline', 'true');
1720
+
1721
+ const run = (cmd, value = null) => {
1722
+ content.focus();
1723
+ document.execCommand(cmd, false, value);
1724
+ paint();
1725
+ sync();
1726
+ };
1727
+
1728
+ const paint = () => {
1729
+ $$('[data-cmd]', ed).forEach(b => {
1730
+ let on = false;
1731
+ try { on = document.queryCommandState(b.dataset.cmd); } catch (e) {}
1732
+ b.setAttribute('aria-pressed', String(on));
1733
+ });
1734
+ if (counter) {
1735
+ const n = content.textContent.trim().length;
1736
+ counter.textContent = limit ? `${n} / ${limit}` : `${n} characters`;
1737
+ counter.classList.toggle('is-over', limit > 0 && n > limit);
1738
+ }
1739
+ };
1740
+
1741
+ const sync = () => {
1742
+ if (target) target.value = content.innerHTML;
1743
+ ed.dispatchEvent(new CustomEvent('deck:change', {
1744
+ bubbles: true, detail: { html: content.innerHTML, text: content.textContent }
1745
+ }));
1746
+ };
1747
+
1748
+ ed.addEventListener('click', e => {
1749
+ const b = e.target.closest('[data-cmd]');
1750
+ if (!b) return;
1751
+ e.preventDefault();
1752
+ const cmd = b.dataset.cmd;
1753
+ if (cmd === 'createLink') {
1754
+ const url = prompt('Link address');
1755
+ if (url) run('createLink', url);
1756
+ return;
1757
+ }
1758
+ run(cmd, b.dataset.value || null);
1759
+ });
1760
+
1761
+ $$('.editor-select', ed).forEach(sel => {
1762
+ sel.addEventListener('change', () => run('formatBlock', sel.value));
1763
+ });
1764
+
1765
+ // Paste as plain text, so a paste from Word does not import its styling
1766
+ content.addEventListener('paste', e => {
1767
+ e.preventDefault();
1768
+ const text = (e.clipboardData || window.clipboardData).getData('text/plain');
1769
+ document.execCommand('insertText', false, text);
1770
+ });
1771
+
1772
+ content.addEventListener('input', () => { paint(); sync(); });
1773
+ content.addEventListener('keyup', paint);
1774
+ content.addEventListener('mouseup', paint);
1775
+ document.addEventListener('selectionchange', () => {
1776
+ if (content.contains(document.getSelection()?.anchorNode)) paint();
1777
+ });
1778
+
1779
+ if (target && target.value) content.innerHTML = target.value;
1780
+ paint();
1781
+ });
1782
+ }
1783
+
1784
+ /* ======================================================================
1785
+ Lazy images
1786
+ ====================================================================== */
1787
+
1788
+ function lazies(root) {
1789
+ const imgs = $$('.lazy img, img[data-src]', root).filter(i => !i.dataset.deckX);
1790
+ if (!imgs.length) return;
1791
+ const load = img => {
1792
+ img.dataset.deckX = '1';
1793
+ if (img.dataset.src) img.src = img.dataset.src;
1794
+ if (img.dataset.srcset) img.srcset = img.dataset.srcset;
1795
+ const done = () => img.classList.add('is-loaded');
1796
+ if (img.complete && img.naturalWidth) done();
1797
+ else img.addEventListener('load', done, { once: true });
1798
+ img.addEventListener('error', () => img.closest('.lazy')?.classList.add('is-error'), { once: true });
1799
+ };
1800
+ if (!('IntersectionObserver' in window)) return imgs.forEach(load);
1801
+ const io = new IntersectionObserver(entries => {
1802
+ entries.forEach(e => {
1803
+ if (!e.isIntersecting) return;
1804
+ load(e.target);
1805
+ io.unobserve(e.target);
1806
+ });
1807
+ }, { rootMargin: '200px' });
1808
+ imgs.forEach(i => io.observe(i));
1809
+ }
1810
+
1811
+ /* ======================================================================
1812
+ QR code
1813
+ A complete encoder: byte mode, versions 1 to 10, error correction
1814
+ L / M / Q / H, all eight masks scored by the standard penalty rules.
1815
+ No dependency, no network call, no canvas — it emits SVG.
1816
+ ====================================================================== */
1817
+
1818
+ const QR = (() => {
1819
+ // Galois field GF(256) with the QR primitive polynomial 0x11D
1820
+ const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
1821
+ for (let i = 0, x = 1; i < 255; i++) {
1822
+ EXP[i] = x; LOG[x] = i;
1823
+ x <<= 1; if (x & 0x100) x ^= 0x11d;
1824
+ }
1825
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
1826
+ const mul = (a, b) => (a && b) ? EXP[LOG[a] + LOG[b]] : 0;
1827
+
1828
+ // [ecPerBlock, group1Blocks, group1Data, group2Blocks, group2Data] by version, then L M Q H
1829
+ const RS = {
1830
+ 1: [[7,1,19,0,0],[10,1,16,0,0],[13,1,13,0,0],[17,1,9,0,0]],
1831
+ 2: [[10,1,34,0,0],[16,1,28,0,0],[22,1,22,0,0],[28,1,16,0,0]],
1832
+ 3: [[15,1,55,0,0],[26,1,44,0,0],[18,2,17,0,0],[22,2,13,0,0]],
1833
+ 4: [[20,1,80,0,0],[18,2,32,0,0],[26,2,24,0,0],[16,4,9,0,0]],
1834
+ 5: [[26,1,108,0,0],[24,2,43,0,0],[18,2,15,2,16],[22,2,11,2,12]],
1835
+ 6: [[18,2,68,0,0],[16,4,27,0,0],[24,4,19,0,0],[28,4,15,0,0]],
1836
+ 7: [[20,2,78,0,0],[18,4,31,0,0],[18,2,14,4,15],[26,4,13,1,14]],
1837
+ 8: [[24,2,97,0,0],[22,2,38,2,39],[22,4,18,2,19],[26,4,14,2,15]],
1838
+ 9: [[30,2,116,0,0],[22,3,36,2,37],[20,4,16,4,17],[24,4,12,4,13]],
1839
+ 10: [[18,2,68,2,69],[26,4,43,1,44],[24,6,19,2,20],[28,6,15,2,16]]
1840
+ };
1841
+ const ALIGN = {
1842
+ 1: [], 2: [6,18], 3: [6,22], 4: [6,26], 5: [6,30],
1843
+ 6: [6,34], 7: [6,22,38], 8: [6,24,42], 9: [6,26,46], 10: [6,28,50]
1844
+ };
1845
+ const ECL = { L: 0, M: 1, Q: 2, H: 3 };
1846
+ const ECL_BITS = { L: 1, M: 0, Q: 3, H: 2 };
1847
+
1848
+ function generator(n) {
1849
+ let poly = [1];
1850
+ for (let i = 0; i < n; i++) {
1851
+ const next = new Array(poly.length + 1).fill(0);
1852
+ for (let j = 0; j < poly.length; j++) {
1853
+ next[j] ^= mul(poly[j], EXP[i]);
1854
+ next[j + 1] ^= poly[j];
1855
+ }
1856
+ poly = next;
1857
+ }
1858
+ return poly;
1859
+ }
1860
+
1861
+ function ecBytes(data, n) {
1862
+ const gen = generator(n);
1863
+ const rem = new Array(n).fill(0);
1864
+ for (const byte of data) {
1865
+ const factor = byte ^ rem[0];
1866
+ rem.shift();
1867
+ rem.push(0);
1868
+ for (let i = 0; i < n; i++) rem[i] ^= mul(gen[i + 1], factor);
1869
+ }
1870
+ return rem;
1871
+ }
1872
+
1873
+ function formatBits(ecl, mask) {
1874
+ const data = (ECL_BITS[ecl] << 3) | mask;
1875
+ let rem = data;
1876
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537);
1877
+ return ((data << 10) | rem) ^ 0x5412;
1878
+ }
1879
+
1880
+ function versionBits(v) {
1881
+ let rem = v;
1882
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25);
1883
+ return (v << 12) | rem;
1884
+ }
1885
+
1886
+ function pickVersion(len, ecl) {
1887
+ for (let v = 1; v <= 10; v++) {
1888
+ const [ec, b1, d1, b2, d2] = RS[v][ECL[ecl]];
1889
+ const total = b1 * d1 + b2 * d2;
1890
+ const countBits = v < 10 ? 8 : 16;
1891
+ if (len <= Math.floor((total * 8 - 4 - countBits) / 8)) return v;
1892
+ }
1893
+ throw new Error('QR: content too long for versions 1 to 10. Shorten it or link to it.');
1894
+ }
1895
+
1896
+ function encode(text, ecl = 'M') {
1897
+ const bytes = Array.from(new TextEncoder().encode(text));
1898
+ const version = pickVersion(bytes.length, ecl);
1899
+ const [ecPer, b1, d1, b2, d2] = RS[version][ECL[ecl]];
1900
+ const totalData = b1 * d1 + b2 * d2;
1901
+ const countBits = version < 10 ? 8 : 16;
1902
+
1903
+ // Bit stream: mode 0100, character count, payload, terminator, padding
1904
+ const bits = [];
1905
+ const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); };
1906
+ push(4, 4);
1907
+ push(bytes.length, countBits);
1908
+ bytes.forEach(b => push(b, 8));
1909
+ for (let i = 0; i < 4 && bits.length < totalData * 8; i++) bits.push(0);
1910
+ while (bits.length % 8) bits.push(0);
1911
+ const data = [];
1912
+ for (let i = 0; i < bits.length; i += 8) {
1913
+ data.push(parseInt(bits.slice(i, i + 8).join(''), 2));
1914
+ }
1915
+ const PAD = [0xec, 0x11];
1916
+ for (let i = 0; data.length < totalData; i++) data.push(PAD[i % 2]);
1917
+
1918
+ // Split into blocks, compute error correction, then interleave
1919
+ const blocks = [], ecs = [];
1920
+ let at = 0;
1921
+ for (let i = 0; i < b1 + b2; i++) {
1922
+ const size = i < b1 ? d1 : d2;
1923
+ const block = data.slice(at, at + size);
1924
+ at += size;
1925
+ blocks.push(block);
1926
+ ecs.push(ecBytes(block, ecPer));
1927
+ }
1928
+ const out = [];
1929
+ const maxData = Math.max(d1, d2);
1930
+ for (let i = 0; i < maxData; i++)
1931
+ blocks.forEach(b => { if (i < b.length) out.push(b[i]); });
1932
+ for (let i = 0; i < ecPer; i++)
1933
+ ecs.forEach(b => out.push(b[i]));
1934
+
1935
+ return { version, ecl, codewords: out };
1936
+ }
1937
+
1938
+ function build(text, ecl = 'M') {
1939
+ const { version, codewords } = encode(text, ecl);
1940
+ const size = version * 4 + 17;
1941
+ const grid = Array.from({ length: size }, () => new Array(size).fill(null));
1942
+ const reserved = Array.from({ length: size }, () => new Array(size).fill(false));
1943
+
1944
+ const put = (x, y, v, res = true) => {
1945
+ if (x < 0 || y < 0 || x >= size || y >= size) return;
1946
+ grid[y][x] = v;
1947
+ if (res) reserved[y][x] = true;
1948
+ };
1949
+
1950
+ // Finder patterns and separators
1951
+ [[0, 0], [size - 7, 0], [0, size - 7]].forEach(([ox, oy]) => {
1952
+ for (let y = -1; y <= 7; y++) for (let x = -1; x <= 7; x++) {
1953
+ const on = (x >= 0 && x <= 6 && (y === 0 || y === 6)) ||
1954
+ (y >= 0 && y <= 6 && (x === 0 || x === 6)) ||
1955
+ (x >= 2 && x <= 4 && y >= 2 && y <= 4);
1956
+ put(ox + x, oy + y, on ? 1 : 0);
1957
+ }
1958
+ });
1959
+
1960
+ // Timing patterns
1961
+ for (let i = 8; i < size - 8; i++) {
1962
+ put(i, 6, i % 2 === 0 ? 1 : 0);
1963
+ put(6, i, i % 2 === 0 ? 1 : 0);
1964
+ }
1965
+
1966
+ // Alignment patterns, skipping the ones that collide with finders
1967
+ const centers = ALIGN[version];
1968
+ centers.forEach(cy => centers.forEach(cx => {
1969
+ if ((cx === 6 && cy === 6) ||
1970
+ (cx === 6 && cy === size - 7) ||
1971
+ (cx === size - 7 && cy === 6)) return;
1972
+ for (let y = -2; y <= 2; y++) for (let x = -2; x <= 2; x++) {
1973
+ const on = Math.max(Math.abs(x), Math.abs(y)) !== 1;
1974
+ put(cx + x, cy + y, on ? 1 : 0);
1975
+ }
1976
+ }));
1977
+
1978
+ // Dark module, then reserve the two format strips so data skips them
1979
+ put(8, size - 8, 1);
1980
+ for (let i = 0; i < 9; i++) { reserved[8][i] = true; reserved[i][8] = true; }
1981
+ for (let i = 0; i < 8; i++) {
1982
+ reserved[8][size - 1 - i] = true;
1983
+ reserved[size - 1 - i][8] = true;
1984
+ }
1985
+
1986
+ // Version information blocks, versions 7 and up
1987
+ if (version >= 7) {
1988
+ const vb = versionBits(version);
1989
+ for (let i = 0; i < 18; i++) {
1990
+ const b = (vb >> i) & 1;
1991
+ const a = Math.floor(i / 3), c = i % 3;
1992
+ grid[a][size - 11 + c] = b; reserved[a][size - 11 + c] = true;
1993
+ grid[size - 11 + c][a] = b; reserved[size - 11 + c][a] = true;
1994
+ }
1995
+ }
1996
+
1997
+ // Place data in the zig-zag, skipping the timing column
1998
+ let bit = 0;
1999
+ const stream = [];
2000
+ codewords.forEach(cw => { for (let i = 7; i >= 0; i--) stream.push((cw >> i) & 1); });
2001
+
2002
+ let up = true;
2003
+ for (let right = size - 1; right > 0; right -= 2) {
2004
+ if (right === 6) right = 5;
2005
+ for (let step = 0; step < size; step++) {
2006
+ const y = up ? size - 1 - step : step;
2007
+ for (let c = 0; c < 2; c++) {
2008
+ const x = right - c;
2009
+ if (reserved[y][x]) continue;
2010
+ grid[y][x] = bit < stream.length ? stream[bit++] : 0;
2011
+ }
2012
+ }
2013
+ up = !up;
2014
+ }
2015
+
2016
+ // Try every mask, keep the one with the lowest penalty
2017
+ const maskFn = [
2018
+ (x, y) => (x + y) % 2 === 0,
2019
+ (x, y) => y % 2 === 0,
2020
+ (x, y) => x % 3 === 0,
2021
+ (x, y) => (x + y) % 3 === 0,
2022
+ (x, y) => (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0,
2023
+ (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
2024
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
2025
+ (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0
2026
+ ];
2027
+
2028
+ let best = null, bestScore = Infinity;
2029
+ for (let m = 0; m < 8; m++) {
2030
+ const test = grid.map(r => r.slice());
2031
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
2032
+ if (!reserved[y][x] && maskFn[m](x, y)) test[y][x] ^= 1;
2033
+ }
2034
+ const fb = formatBits(ecl, m);
2035
+ const fbit = i => (fb >> i) & 1;
2036
+ // grid is indexed [row][column]
2037
+ // First copy: down column 8, then left along row 8
2038
+ for (let i = 0; i <= 5; i++) test[i][8] = fbit(i);
2039
+ test[7][8] = fbit(6);
2040
+ test[8][8] = fbit(7);
2041
+ test[8][7] = fbit(8);
2042
+ for (let i = 9; i <= 14; i++) test[8][14 - i] = fbit(i);
2043
+ // Second copy: along row 8 beside the top-right finder,
2044
+ // then down column 8 beside the bottom-left finder
2045
+ for (let i = 0; i <= 7; i++) test[8][size - 1 - i] = fbit(i);
2046
+ for (let i = 8; i <= 14; i++) test[size - 15 + i][8] = fbit(i);
2047
+ test[size - 8][8] = 1;
2048
+ const score = penalty(test, size);
2049
+ if (score < bestScore) { bestScore = score; best = test; }
2050
+ }
2051
+ return { modules: best, size, version };
2052
+ }
2053
+
2054
+ function penalty(m, size) {
2055
+ let score = 0;
2056
+ // Rule 1: runs of five or more of the same colour
2057
+ const run = line => {
2058
+ let total = 0, count = 1;
2059
+ for (let i = 1; i < size; i++) {
2060
+ if (line[i] === line[i - 1]) count++;
2061
+ else { if (count >= 5) total += count - 2; count = 1; }
2062
+ }
2063
+ if (count >= 5) total += count - 2;
2064
+ return total;
2065
+ };
2066
+ for (let y = 0; y < size; y++) score += run(m[y]);
2067
+ for (let x = 0; x < size; x++) score += run(m.map(r => r[x]));
2068
+ // Rule 2: two by two blocks
2069
+ for (let y = 0; y < size - 1; y++) for (let x = 0; x < size - 1; x++) {
2070
+ const v = m[y][x];
2071
+ if (v === m[y][x + 1] && v === m[y + 1][x] && v === m[y + 1][x + 1]) score += 3;
2072
+ }
2073
+ // Rule 3: the finder-like 1011101 pattern with four light modules beside it
2074
+ const pat1 = [1,0,1,1,1,0,1,0,0,0,0];
2075
+ const pat2 = [0,0,0,0,1,0,1,1,1,0,1];
2076
+ const scan = line => {
2077
+ let s = 0;
2078
+ for (let i = 0; i <= size - 11; i++) {
2079
+ const slice = line.slice(i, i + 11).join('');
2080
+ if (slice === pat1.join('') || slice === pat2.join('')) s += 40;
2081
+ }
2082
+ return s;
2083
+ };
2084
+ for (let y = 0; y < size; y++) score += scan(m[y]);
2085
+ for (let x = 0; x < size; x++) score += scan(m.map(r => r[x]));
2086
+ // Rule 4: deviation from an even split of dark and light
2087
+ let dark = 0;
2088
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) dark += m[y][x];
2089
+ const ratio = Math.abs((dark * 100) / (size * size) - 50);
2090
+ score += Math.floor(ratio / 5) * 10;
2091
+ return score;
2092
+ }
2093
+
2094
+ function svg(text, ecl = 'M') {
2095
+ const { modules, size } = build(text, ecl);
2096
+ // One path-free approach: merge horizontal runs into rects so the SVG
2097
+ // stays small even at version 10
2098
+ let rects = '';
2099
+ for (let y = 0; y < size; y++) {
2100
+ let x = 0;
2101
+ while (x < size) {
2102
+ if (!modules[y][x]) { x++; continue; }
2103
+ let w = 1;
2104
+ while (x + w < size && modules[y][x + w]) w++;
2105
+ rects += `<rect x="${x}" y="${y}" width="${w}" height="1"/>`;
2106
+ x += w;
2107
+ }
2108
+ }
2109
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" role="img"><g>${rects}</g></svg>`;
2110
+ }
2111
+
2112
+ return { svg, build, encode };
2113
+ })();
2114
+
2115
+ function qrCodes(root) {
2116
+ $$('[data-deck-qr]', root).forEach(node => {
2117
+ if (!once(node)) return;
2118
+ const value = node.dataset.deckQr || node.textContent.trim();
2119
+ const level = (node.dataset.ecl || 'M').toUpperCase();
2120
+ try {
2121
+ node.innerHTML = QR.svg(value, level) + (node.dataset.caption
2122
+ ? `<span class="qr-caption">${node.dataset.caption}</span>` : '');
2123
+ } catch (err) {
2124
+ node.innerHTML = `<span class="text-sm text-muted">${err.message}</span>`;
2125
+ }
2126
+ });
2127
+ }
2128
+
2129
+ /* ======================================================================
2130
+ Register
2131
+ ====================================================================== */
2132
+
2133
+ const baseInit = Deck.init.bind(Deck);
2134
+ Deck.init = function (root = document) {
2135
+ baseInit(root);
2136
+ carousels(root);
2137
+ drawers(root);
2138
+ megaMenus(root);
2139
+ speedDials(root);
2140
+ banners(root);
2141
+ numbers(root);
2142
+ phones(root);
2143
+ rangePairs(root);
2144
+ copiers(root);
2145
+ editors(root);
2146
+ lazies(root);
2147
+ qrCodes(root);
2148
+ return Deck;
2149
+ };
2150
+
2151
+ Deck.qr = QR;
2152
+ Deck.copy = writeClipboard;
2153
+
2154
+ if (document.readyState !== 'loading') Deck.init();
2155
+ else document.addEventListener('DOMContentLoaded', () => Deck.init());
2156
+
2157
+ })(typeof window !== 'undefined' && window.Deck ? window.Deck
2158
+ : typeof Deck !== 'undefined' ? Deck : null);
2159
+
2160
+ /*! ==========================================================================
2161
+ * deck-adapters.js v0.1
2162
+ *
2163
+ * Deck's core is zero-dependency on purpose. This file is the opposite: it
2164
+ * is a set of adapters that light up ONLY if the corresponding library is
2165
+ * already on the page. Load none of them and everything still works; load
2166
+ * one and Deck hands that job over to a better engine and keeps providing
2167
+ * the styling.
2168
+ *
2169
+ * Detected, in order of how much they actually add:
2170
+ *
2171
+ * Floating UI window.FloatingUIDOM collision-aware placement, for
2172
+ * browsers without CSS anchor
2173
+ * positioning
2174
+ * Quill window.Quill replaces the deprecated
2175
+ * Tiptap window.Tiptap document.execCommand editor
2176
+ * Chart.js window.Chart real axes, tooltips, time scales
2177
+ * SortableJS window.Sortable drag reordering and kanban
2178
+ * Lucide window.lucide 1500 icons instead of Deck's 56
2179
+ *
2180
+ * Nothing here is required and nothing here is bundled. Add a script tag for
2181
+ * the ones you want.
2182
+ * ======================================================================== */
2183
+
2184
+ (function (Deck) {
2185
+ 'use strict';
2186
+ if (!Deck) { console.warn('deck-adapters.js: load deck.js first'); return; }
2187
+
2188
+ const $ = (s, r = document) => r.querySelector(s);
2189
+ const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
2190
+ const once = n => { if (n.dataset.deckA) return false; n.dataset.deckA = '1'; return true; };
2191
+ const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
2192
+ const hasAnchor = CSS.supports('anchor-name: --a');
2193
+
2194
+ const have = {
2195
+ get floating() { return !!window.FloatingUIDOM; },
2196
+ get quill() { return !!window.Quill; },
2197
+ get tiptap() { return !!(window.Tiptap && window.Tiptap.Editor); },
2198
+ get chart() { return !!window.Chart; },
2199
+ get sortable() { return !!window.Sortable; },
2200
+ get lucide() { return !!window.lucide; }
2201
+ };
2202
+
2203
+ /* ======================================================================
2204
+ 1. Floating UI — placement
2205
+ Only used where CSS anchor positioning is missing. Where the browser has
2206
+ anchor positioning, the CSS in 25-anchor.css already does this natively,
2207
+ on the compositor, with no listeners, so the adapter stays out of the way.
2208
+ ====================================================================== */
2209
+
2210
+ function floating(root) {
2211
+ if (hasAnchor || !have.floating) return;
2212
+ const { computePosition, autoUpdate, offset, flip, shift, arrow, size } = window.FloatingUIDOM;
2213
+
2214
+ $$('[data-deck-anchor]', root).forEach(trigger => {
2215
+ if (!once(trigger)) return;
2216
+ const panel = $(trigger.dataset.deckAnchor);
2217
+ if (!panel) return;
2218
+ const arrowEl = $('.tip-arrow', panel);
2219
+ const placement = panel.dataset.placement || 'bottom';
2220
+ const matchWidth = panel.classList.contains('menu-match') ||
2221
+ panel.classList.contains('combo-list');
2222
+
2223
+ const middleware = [
2224
+ offset(8),
2225
+ flip({ fallbackAxisSideDirection: 'start' }),
2226
+ shift({ padding: 8 }),
2227
+ size({
2228
+ padding: 8,
2229
+ apply({ availableHeight, rects, elements }) {
2230
+ Object.assign(elements.floating.style, {
2231
+ maxHeight: `${Math.max(120, availableHeight)}px`,
2232
+ ...(matchWidth ? { width: `${rects.reference.width}px` } : {})
2233
+ });
2234
+ }
2235
+ })
2236
+ ];
2237
+ if (arrowEl) middleware.push(arrow({ element: arrowEl, padding: 6 }));
2238
+
2239
+ const place = async () => {
2240
+ const r = await computePosition(trigger, panel, { placement, strategy: 'fixed', middleware });
2241
+ Object.assign(panel.style, { left: `${r.x}px`, top: `${r.y}px`, position: 'fixed' });
2242
+ if (arrowEl && r.middlewareData.arrow) {
2243
+ const { x, y } = r.middlewareData.arrow;
2244
+ const side = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[r.placement.split('-')[0]];
2245
+ Object.assign(arrowEl.style, {
2246
+ left: x != null ? `${x}px` : '',
2247
+ top: y != null ? `${y}px` : '',
2248
+ [side]: '-4px'
2249
+ });
2250
+ }
2251
+ };
2252
+
2253
+ let stop = null;
2254
+ panel.addEventListener('toggle', e => {
2255
+ if (e.newState === 'open') { place(); stop = autoUpdate(trigger, panel, place); }
2256
+ else { stop?.(); stop = null; }
2257
+ });
2258
+ });
2259
+ }
2260
+
2261
+ /* ======================================================================
2262
+ 2. Editor — Quill or Tiptap
2263
+ Deck's own editor uses document.execCommand, which is deprecated and
2264
+ inconsistent. If either library is on the page, Deck hands the content
2265
+ region over and keeps its own toolbar chrome, so the markup and the CSS
2266
+ do not change.
2267
+ ====================================================================== */
2268
+
2269
+ function editorAdapter(root) {
2270
+ if (!have.quill && !have.tiptap) return;
2271
+
2272
+ $$('.editor', root).forEach(ed => {
2273
+ if (!once(ed)) return;
2274
+ const content = $('.editor-content', ed);
2275
+ const target = ed.dataset.target ? $(ed.dataset.target) : null;
2276
+ const counter = $('.editor-count', ed);
2277
+ const limit = Number(ed.dataset.limit || 0);
2278
+ if (!content) return;
2279
+
2280
+ // Deck's own handler already owns this node; take it back cleanly
2281
+ content.contentEditable = 'false';
2282
+ content.removeAttribute('role');
2283
+
2284
+ const count = text => {
2285
+ if (!counter) return;
2286
+ const n = text.trim().length;
2287
+ counter.textContent = limit ? `${n} / ${limit}` : `${n} characters`;
2288
+ counter.classList.toggle('is-over', limit > 0 && n > limit);
2289
+ };
2290
+
2291
+ if (have.tiptap) {
2292
+ const { Editor } = window.Tiptap;
2293
+ const extensions = window.Tiptap.StarterKit ? [window.Tiptap.StarterKit] : [];
2294
+ const editor = new Editor({
2295
+ element: content,
2296
+ extensions,
2297
+ content: target?.value || content.innerHTML || '',
2298
+ onUpdate: ({ editor }) => {
2299
+ if (target) target.value = editor.getHTML();
2300
+ count(editor.getText());
2301
+ paint(editor);
2302
+ ed.dispatchEvent(new CustomEvent('deck:change', {
2303
+ bubbles: true, detail: { html: editor.getHTML(), text: editor.getText() }
2304
+ }));
2305
+ }
2306
+ });
2307
+
2308
+ const CMD = {
2309
+ bold: e => e.chain().focus().toggleBold().run(),
2310
+ italic: e => e.chain().focus().toggleItalic().run(),
2311
+ underline: e => e.chain().focus().toggleUnderline?.().run(),
2312
+ strikeThrough: e => e.chain().focus().toggleStrike().run(),
2313
+ insertUnorderedList: e => e.chain().focus().toggleBulletList().run(),
2314
+ insertOrderedList: e => e.chain().focus().toggleOrderedList().run(),
2315
+ formatBlock: (e, v) => v === 'p'
2316
+ ? e.chain().focus().setParagraph().run()
2317
+ : e.chain().focus().toggleHeading({ level: Number(v.replace('h', '')) }).run(),
2318
+ undo: e => e.chain().focus().undo().run(),
2319
+ redo: e => e.chain().focus().redo().run(),
2320
+ createLink: e => {
2321
+ const url = prompt('Link address');
2322
+ if (url) e.chain().focus().setLink({ href: url }).run();
2323
+ }
2324
+ };
2325
+ const STATE = { bold: 'bold', italic: 'italic', underline: 'underline',
2326
+ strikeThrough: 'strike', insertUnorderedList: 'bulletList',
2327
+ insertOrderedList: 'orderedList' };
2328
+ const paint = e => $$('[data-cmd]', ed).forEach(b => {
2329
+ const name = STATE[b.dataset.cmd];
2330
+ if (name) b.setAttribute('aria-pressed', String(e.isActive(name)));
2331
+ });
2332
+
2333
+ ed.addEventListener('click', e => {
2334
+ const b = e.target.closest('[data-cmd]');
2335
+ if (!b) return;
2336
+ e.preventDefault();
2337
+ CMD[b.dataset.cmd]?.(editor, b.dataset.value);
2338
+ paint(editor);
2339
+ });
2340
+ $$('.editor-select', ed).forEach(sel =>
2341
+ sel.addEventListener('change', () => CMD.formatBlock(editor, sel.value)));
2342
+ ed.deckEditor = editor;
2343
+ count(editor.getText());
2344
+
2345
+ } else {
2346
+ const q = new window.Quill(content, {
2347
+ theme: null, // Deck supplies the chrome
2348
+ placeholder: content.dataset.placeholder || '',
2349
+ modules: { toolbar: false }
2350
+ });
2351
+ if (target?.value) q.clipboard.dangerouslyPasteHTML(target.value);
2352
+
2353
+ const paint = () => {
2354
+ const f = q.getFormat();
2355
+ $$('[data-cmd]', ed).forEach(b => {
2356
+ const map = { bold: 'bold', italic: 'italic', underline: 'underline',
2357
+ strikeThrough: 'strike' };
2358
+ const key = map[b.dataset.cmd];
2359
+ if (key) b.setAttribute('aria-pressed', String(!!f[key]));
2360
+ if (b.dataset.cmd === 'insertUnorderedList')
2361
+ b.setAttribute('aria-pressed', String(f.list === 'bullet'));
2362
+ if (b.dataset.cmd === 'insertOrderedList')
2363
+ b.setAttribute('aria-pressed', String(f.list === 'ordered'));
2364
+ });
2365
+ };
2366
+
2367
+ const CMD = {
2368
+ bold: () => q.format('bold', !q.getFormat().bold),
2369
+ italic: () => q.format('italic', !q.getFormat().italic),
2370
+ underline: () => q.format('underline', !q.getFormat().underline),
2371
+ strikeThrough: () => q.format('strike', !q.getFormat().strike),
2372
+ insertUnorderedList: () => q.format('list', q.getFormat().list === 'bullet' ? false : 'bullet'),
2373
+ insertOrderedList: () => q.format('list', q.getFormat().list === 'ordered' ? false : 'ordered'),
2374
+ formatBlock: v => q.format('header', v === 'p' ? false : Number(v.replace('h', ''))),
2375
+ undo: () => q.history.undo(),
2376
+ redo: () => q.history.redo(),
2377
+ createLink: () => { const u = prompt('Link address'); if (u) q.format('link', u); }
2378
+ };
2379
+
2380
+ ed.addEventListener('click', e => {
2381
+ const b = e.target.closest('[data-cmd]');
2382
+ if (!b) return;
2383
+ e.preventDefault();
2384
+ CMD[b.dataset.cmd]?.(b.dataset.value);
2385
+ paint();
2386
+ });
2387
+ $$('.editor-select', ed).forEach(sel =>
2388
+ sel.addEventListener('change', () => CMD.formatBlock(sel.value)));
2389
+
2390
+ q.on('text-change', () => {
2391
+ if (target) target.value = q.root.innerHTML;
2392
+ count(q.getText());
2393
+ ed.dispatchEvent(new CustomEvent('deck:change', {
2394
+ bubbles: true, detail: { html: q.root.innerHTML, text: q.getText() }
2395
+ }));
2396
+ });
2397
+ q.on('selection-change', paint);
2398
+ ed.deckEditor = q;
2399
+ count(q.getText());
2400
+ }
2401
+ });
2402
+ }
2403
+
2404
+ /* ======================================================================
2405
+ 3. Chart.js — themed from Deck's tokens
2406
+ Deck's CSS charts are good for dashboard tiles and they retheme with the
2407
+ hue slider. What they cannot do is a time axis, a crosshair, a zoom, or
2408
+ twenty thousand points. Where Chart.js is loaded, this makes it match
2409
+ Deck exactly and follow the theme and the hue at runtime.
2410
+ ====================================================================== */
2411
+
2412
+ function chartTheme() {
2413
+ if (!have.chart) return null;
2414
+ const Chart = window.Chart;
2415
+
2416
+ const series = i => css(`--c${(i % 6) + 1}`) || css('--brand-500');
2417
+
2418
+ const apply = () => {
2419
+ Chart.defaults.font.family = css('--font-sans');
2420
+ Chart.defaults.font.size = 12;
2421
+ Chart.defaults.color = css('--text-muted');
2422
+ Chart.defaults.borderColor = css('--line');
2423
+ Chart.defaults.elements.line.tension = 0.3;
2424
+ Chart.defaults.elements.line.borderWidth = 2;
2425
+ Chart.defaults.elements.point.radius = 0;
2426
+ Chart.defaults.elements.point.hoverRadius = 5;
2427
+ Chart.defaults.elements.bar.borderRadius = 4;
2428
+ Chart.defaults.elements.bar.borderSkipped = 'bottom';
2429
+
2430
+ Chart.defaults.plugins.legend.labels.usePointStyle = true;
2431
+ Chart.defaults.plugins.legend.labels.boxWidth = 8;
2432
+ Chart.defaults.plugins.legend.labels.boxHeight = 8;
2433
+ Chart.defaults.plugins.legend.labels.padding = 14;
2434
+
2435
+ Object.assign(Chart.defaults.plugins.tooltip, {
2436
+ backgroundColor: css('--ink-900'),
2437
+ titleColor: css('--ink-50'),
2438
+ bodyColor: css('--ink-100'),
2439
+ borderColor: 'transparent',
2440
+ cornerRadius: 6,
2441
+ padding: 10,
2442
+ displayColors: true,
2443
+ usePointStyle: true,
2444
+ boxPadding: 4,
2445
+ titleFont: { weight: '620' }
2446
+ });
2447
+
2448
+ Chart.defaults.scale.grid.color = css('--line');
2449
+ Chart.defaults.scale.grid.drawTicks = false;
2450
+ Chart.defaults.scale.border = { display: false };
2451
+ Chart.defaults.scale.ticks.padding = 8;
2452
+ };
2453
+
2454
+ apply();
2455
+
2456
+ // Follow theme and hue changes without rebuilding the chart
2457
+ const repaint = () => {
2458
+ apply();
2459
+ Object.values(Chart.instances || {}).forEach(c => {
2460
+ c.data.datasets.forEach((d, i) => {
2461
+ if (d.deckSeries !== false) {
2462
+ d.borderColor = series(i);
2463
+ d.backgroundColor = d.fill ? `color-mix(in oklab, ${series(i)} 18%, transparent)` : series(i);
2464
+ }
2465
+ });
2466
+ c.update('none');
2467
+ });
2468
+ };
2469
+ new MutationObserver(repaint).observe(document.documentElement, {
2470
+ attributes: true, attributeFilter: ['data-theme', 'style']
2471
+ });
2472
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', repaint);
2473
+
2474
+ // Convenience: Deck.chart(canvas, config) applies the series palette
2475
+ Deck.chart = (canvas, config) => {
2476
+ (config.data?.datasets || []).forEach((d, i) => {
2477
+ if (d.borderColor == null) d.borderColor = series(i);
2478
+ if (d.backgroundColor == null) {
2479
+ d.backgroundColor = d.fill
2480
+ ? `color-mix(in oklab, ${series(i)} 18%, transparent)`
2481
+ : series(i);
2482
+ }
2483
+ });
2484
+ return new Chart(canvas, config);
2485
+ };
2486
+
2487
+ return { apply, repaint, series };
2488
+ }
2489
+
2490
+ /* ======================================================================
2491
+ 4. SortableJS — drag reordering and kanban
2492
+ Deck's CSS already uses SortableJS's default class names, so no
2493
+ configuration is needed. Without the library, the fallback below uses the
2494
+ native HTML drag and drop API, which is worse on touch but costs nothing.
2495
+ ====================================================================== */
2496
+
2497
+ function sortables(root) {
2498
+ $$('[data-deck-sortable]', root).forEach(list => {
2499
+ if (!once(list)) return;
2500
+ const group = list.dataset.deckSortable || undefined;
2501
+ const handle = list.dataset.handle || null;
2502
+
2503
+ const emit = detail => list.dispatchEvent(
2504
+ new CustomEvent('deck:reorder', { bubbles: true, detail }));
2505
+
2506
+ if (have.sortable) {
2507
+ window.Sortable.create(list, {
2508
+ group: group && group !== 'true' ? { name: group, pull: true, put: true } : undefined,
2509
+ handle,
2510
+ animation: Deck.reduced?.() ? 0 : 180,
2511
+ easing: 'cubic-bezier(.22,1,.36,1)',
2512
+ ghostClass: 'sortable-ghost',
2513
+ chosenClass: 'sortable-chosen',
2514
+ dragClass: 'sortable-drag',
2515
+ forceFallback: false,
2516
+ fallbackOnBody: true,
2517
+ swapThreshold: 0.66,
2518
+ delay: matchMedia('(pointer: coarse)').matches ? 140 : 0,
2519
+ delayOnTouchOnly: true,
2520
+ onEnd: e => {
2521
+ counts();
2522
+ emit({
2523
+ item: e.item, from: e.from, to: e.to,
2524
+ oldIndex: e.oldIndex, newIndex: e.newIndex,
2525
+ order: Array.from(e.to.children).map(c => c.dataset.id ?? null)
2526
+ });
2527
+ }
2528
+ });
2529
+ return;
2530
+ }
2531
+
2532
+ // Native fallback
2533
+ Array.from(list.children).forEach(child => { child.draggable = true; });
2534
+ let dragged = null;
2535
+ list.addEventListener('dragstart', e => {
2536
+ dragged = e.target.closest('[draggable="true"]');
2537
+ if (!dragged) return;
2538
+ dragged.classList.add('sortable-chosen');
2539
+ e.dataTransfer.effectAllowed = 'move';
2540
+ e.dataTransfer.setData('text/plain', dragged.dataset.id || '');
2541
+ });
2542
+ list.addEventListener('dragover', e => {
2543
+ e.preventDefault();
2544
+ const over = e.target.closest('[draggable="true"]');
2545
+ if (!over || over === dragged || !dragged) return;
2546
+ const r = over.getBoundingClientRect();
2547
+ const after = (e.clientY - r.top) / r.height > 0.5;
2548
+ over.parentElement.insertBefore(dragged, after ? over.nextSibling : over);
2549
+ });
2550
+ list.addEventListener('dragend', () => {
2551
+ dragged?.classList.remove('sortable-chosen');
2552
+ counts();
2553
+ emit({ item: dragged, from: list, to: list,
2554
+ order: Array.from(list.children).map(c => c.dataset.id ?? null) });
2555
+ dragged = null;
2556
+ });
2557
+ });
2558
+
2559
+ function counts() {
2560
+ $$('.kanban-col').forEach(col => {
2561
+ const n = $$('.kanban-card', col).length;
2562
+ const badge = $('.kanban-count', col);
2563
+ if (badge) badge.textContent = n;
2564
+ });
2565
+ }
2566
+ counts();
2567
+ }
2568
+
2569
+ /* ======================================================================
2570
+ 5. Lucide — 1500 icons instead of Deck's 56
2571
+ Deck's sprite covers the framework's own needs and the automotive set it
2572
+ was built for. For anything beyond that, write the Lucide name and this
2573
+ swaps in the path data, keeping Deck's .icon sizing and stroke rules.
2574
+ ====================================================================== */
2575
+
2576
+ function lucideIcons(root) {
2577
+ if (!have.lucide) return;
2578
+ const toPascal = s => s.replace(/(^\w|-\w)/g, m => m.replace('-', '').toUpperCase());
2579
+
2580
+ $$('[data-icon]', root).forEach(node => {
2581
+ if (!once(node)) return;
2582
+ const name = node.dataset.icon;
2583
+ const node_ = window.lucide.icons?.[toPascal(name)] || window.lucide[toPascal(name)];
2584
+ if (!node_) { console.warn(`deck: no Lucide icon "${name}"`); return; }
2585
+ const children = Array.isArray(node_) ? node_[2] : node_.children || [];
2586
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2587
+ svg.setAttribute('viewBox', '0 0 24 24');
2588
+ svg.setAttribute('aria-hidden', 'true');
2589
+ svg.setAttribute('class', node.className || 'icon');
2590
+ children.forEach(([tag, attrs]) => {
2591
+ const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
2592
+ Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
2593
+ svg.append(el);
2594
+ });
2595
+ node.replaceWith(svg);
2596
+ });
2597
+ }
2598
+
2599
+ /* ======================================================================
2600
+ Register
2601
+ ====================================================================== */
2602
+
2603
+ const baseInit = Deck.init.bind(Deck);
2604
+ Deck.init = function (root = document) {
2605
+ baseInit(root);
2606
+ floating(root);
2607
+ editorAdapter(root);
2608
+ sortables(root);
2609
+ lucideIcons(root);
2610
+ return Deck;
2611
+ };
2612
+
2613
+ Deck.adapters = {
2614
+ get available() {
2615
+ return Object.fromEntries(Object.keys(have).map(k => [k, have[k]]));
2616
+ },
2617
+ /* What is actually doing the work right now. Useful in a console when a
2618
+ component behaves differently between two pages. */
2619
+ report() {
2620
+ const a = this.available;
2621
+ return {
2622
+ placement: hasAnchor ? 'CSS anchor positioning'
2623
+ : a.floating ? 'Floating UI'
2624
+ : 'Deck built-in',
2625
+ editor: a.tiptap ? 'Tiptap' : a.quill ? 'Quill' : 'Deck built-in (execCommand)',
2626
+ charts: a.chart ? 'Chart.js, themed by Deck' : 'Deck CSS charts',
2627
+ dragDrop: a.sortable ? 'SortableJS' : 'native drag and drop',
2628
+ icons: a.lucide ? 'Lucide + Deck sprite' : 'Deck sprite (74 icons)',
2629
+ virtualization: CSS.supports('content-visibility: auto')
2630
+ ? 'content-visibility (engine)' : 'none'
2631
+ };
2632
+ }
2633
+ };
2634
+
2635
+ if (have.chart) Deck.chartTheme = chartTheme();
2636
+
2637
+ if (document.readyState !== 'loading') Deck.init();
2638
+ else document.addEventListener('DOMContentLoaded', () => Deck.init());
2639
+
2640
+ })(typeof window !== 'undefined' && window.Deck ? window.Deck
2641
+ : typeof Deck !== 'undefined' ? Deck : null);
2642
+
2643
+ export default globalThis.Deck;
2644
+ export { globalThis as __deckGlobal };