pgkiln 0.71.0 → 0.73.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 (77) hide show
  1. package/db/migrations/139_opentelemetry.sql +36 -0
  2. package/db/migrations/140_item_region_types.sql +27 -0
  3. package/docs/apex-feature-parity.md +86 -9
  4. package/docs/guide/01-installation.md +108 -0
  5. package/docs/guide/03-builder.md +9 -0
  6. package/docs/guide/04-pages-and-regions.md +41 -1
  7. package/docs/guide/05-items.md +43 -3
  8. package/docs/guide/09-reference.md +3 -0
  9. package/docs/guide/11-from-apex.md +3 -0
  10. package/docs/guide/12-development.md +23 -2
  11. package/examples/hr/hr_75_item_region_types.sql +81 -0
  12. package/package.json +1 -1
  13. package/public/app.css +48 -0
  14. package/public/app.js +427 -1
  15. package/public/builder.css +3 -0
  16. package/src/ai/service.ts +14 -0
  17. package/src/app.ts +10 -0
  18. package/src/builder/advisor.ts +5 -1
  19. package/src/builder/arrange.ts +7 -0
  20. package/src/builder/components.ts +5 -5
  21. package/src/builder/designer.ts +8 -2
  22. package/src/builder/home.ts +1 -0
  23. package/src/builder/i18n/de.ts +8 -0
  24. package/src/builder/i18n/en.ts +8 -0
  25. package/src/builder/i18n/es.ts +8 -0
  26. package/src/builder/i18n/fr.ts +8 -0
  27. package/src/builder/i18n/it.ts +8 -0
  28. package/src/builder/i18n/ja.ts +8 -0
  29. package/src/builder/i18n/ko.ts +8 -0
  30. package/src/builder/i18n/nl.ts +8 -0
  31. package/src/builder/i18n/pt.ts +8 -0
  32. package/src/builder/i18n/zh-cn.ts +8 -0
  33. package/src/builder/i18n/zh-tw.ts +8 -0
  34. package/src/builder/region-help.ts +8 -2
  35. package/src/builder/telemetry.ts +206 -0
  36. package/src/builder/workspaces.ts +2 -1
  37. package/src/i18n/ar.ts +13 -0
  38. package/src/i18n/cs.ts +13 -0
  39. package/src/i18n/da.ts +13 -0
  40. package/src/i18n/de.ts +13 -0
  41. package/src/i18n/el.ts +13 -0
  42. package/src/i18n/es.ts +13 -0
  43. package/src/i18n/fi.ts +13 -0
  44. package/src/i18n/fr.ts +13 -0
  45. package/src/i18n/he.ts +13 -0
  46. package/src/i18n/it.ts +13 -0
  47. package/src/i18n/ja.ts +13 -0
  48. package/src/i18n/ko.ts +13 -0
  49. package/src/i18n/nb.ts +13 -0
  50. package/src/i18n/pl.ts +13 -0
  51. package/src/i18n/pt.ts +13 -0
  52. package/src/i18n/ru.ts +13 -0
  53. package/src/i18n/sv.ts +13 -0
  54. package/src/i18n/tr.ts +13 -0
  55. package/src/i18n/uk.ts +13 -0
  56. package/src/i18n/zh.ts +13 -0
  57. package/src/i18n.ts +26 -0
  58. package/src/instance-checks.ts +11 -4
  59. package/src/layout.ts +2 -1
  60. package/src/mail/mail.ts +14 -0
  61. package/src/metadata.ts +5 -2
  62. package/src/runtime/ai-agents.ts +9 -0
  63. package/src/runtime/items.ts +32 -3
  64. package/src/runtime/page-regions.ts +50 -0
  65. package/src/runtime/region-cache.ts +1 -1
  66. package/src/runtime/regions.ts +11 -0
  67. package/src/runtime/render.ts +3 -17
  68. package/src/runtime/report.ts +55 -3
  69. package/src/runtime/routes.ts +3 -0
  70. package/src/session.ts +26 -0
  71. package/src/telemetry/browser.ts +91 -0
  72. package/src/telemetry/config.ts +342 -0
  73. package/src/telemetry/export.ts +484 -0
  74. package/src/telemetry/http.ts +126 -0
  75. package/src/telemetry/trace.ts +456 -0
  76. package/src/webclient.ts +25 -0
  77. package/src/workflow.ts +14 -7
package/public/app.js CHANGED
@@ -264,6 +264,8 @@ const pgkilnUnsaved = (() => {
264
264
  if (el.closest('[data-range]')) return els.some((e) => e.value) ? els.slice(0, 2).map((e) => e.value).join(':') : '';
265
265
  if (el.type === 'radio') return (els.find((e) => e.checked) || {}).value || '';
266
266
  if (el.type === 'checkbox') return el.checked ? 'true' : 'false';
267
+ // (140) a multi-value list (multiselect, shuttle, select many): its values colon-separated, in order
268
+ if (el.tagName === 'SELECT' && el.multiple) return [...el.selectedOptions].map((o) => o.value).join(':');
267
269
  return el.value;
268
270
  }
269
271
 
@@ -281,7 +283,10 @@ const pgkilnUnsaved = (() => {
281
283
  els.slice(0, 2).forEach((e, i) => (e.value = parts[i] || ''));
282
284
  } else if (el.type === 'radio') els.forEach((e) => (e.checked = e.value === value));
283
285
  else if (el.type === 'checkbox') el.checked = ['true', 't', 'on', '1', 'yes', 'y'].includes(String(value).toLowerCase());
284
- else el.value = value ?? '';
286
+ else if (el.tagName === 'SELECT' && el.multiple) {
287
+ const values = String(value ?? '').split(':').filter(Boolean);
288
+ for (const o of el.options) o.selected = values.includes(o.value);
289
+ } else el.value = value ?? '';
285
290
  if (depth < 5) {
286
291
  depth++;
287
292
  el.dispatchEvent(new Event('change', { bubbles: true }));
@@ -2162,6 +2167,377 @@ function popupLov(sel) {
2162
2167
  });
2163
2168
  }
2164
2169
 
2170
+ // (140; APEX: Shuttle) Two lists, available and chosen, with buttons to move one or all values across and to
2171
+ // order the chosen ones. The item's own <select multiple> (it works without JavaScript) stays in the form, hidden:
2172
+ // its selected options are the value, in the order of the chosen list (they come first in it).
2173
+ function enhanceShuttles(root) {
2174
+ const boxes = root.matches?.('[data-shuttle]') ? [root] : [...(root.querySelectorAll?.('[data-shuttle]') ?? [])];
2175
+ boxes.forEach(shuttle);
2176
+ }
2177
+ document.addEventListener('pgkiln:replaced', (e) => e.detail && enhanceShuttles(e.detail));
2178
+ enhanceShuttles(document);
2179
+ function shuttle(box) {
2180
+ const sel = box.querySelector('select[multiple]');
2181
+ if (!sel || box.dataset.enhanced) return;
2182
+ box.dataset.enhanced = '1';
2183
+ const d = box.dataset;
2184
+ const id = sel.id;
2185
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
2186
+ const name = (label?.textContent || sel.name).replace(/\*\s*$/, '').trim();
2187
+ const el = (tag, cls) => {
2188
+ const x = document.createElement(tag);
2189
+ if (cls) x.className = cls;
2190
+ return x;
2191
+ };
2192
+ const list = (suffix, text) => {
2193
+ const l = el('select', 'shuttle-list');
2194
+ l.multiple = true;
2195
+ l.id = `${id}_${suffix}`;
2196
+ l.size = Math.max(sel.size || 6, 6);
2197
+ l.setAttribute('aria-label', `${name}: ${text}`);
2198
+ for (const a of ['aria-describedby', 'aria-invalid']) if (sel.hasAttribute(a)) l.setAttribute(a, sel.getAttribute(a));
2199
+ return l;
2200
+ };
2201
+ const avail = list('available', d.availableLabel || 'Available');
2202
+ const chosen = list('chosen', d.chosenLabel || 'Selected');
2203
+ const filter = el('input', 'shuttle-filter');
2204
+ filter.type = 'search';
2205
+ filter.id = `${id}_filter`;
2206
+ filter.autocomplete = 'off';
2207
+ filter.placeholder = d.filterLabel || 'Filter';
2208
+ filter.setAttribute('aria-label', `${name}: ${d.filterLabel || 'Filter'}`);
2209
+ filter.setAttribute('aria-controls', avail.id);
2210
+ const button = (cmd, text, glyph) => {
2211
+ const b = el('button', 'btn btn-sm shuttle-btn');
2212
+ b.type = 'button';
2213
+ b.dataset.cmd = cmd;
2214
+ b.title = text;
2215
+ b.setAttribute('aria-label', text);
2216
+ const g = el('span');
2217
+ g.setAttribute('aria-hidden', 'true');
2218
+ g.textContent = glyph;
2219
+ b.append(g);
2220
+ return b;
2221
+ };
2222
+ const moves = el('div', 'shuttle-moves');
2223
+ moves.setAttribute('role', 'group');
2224
+ moves.append(button('all', d.moveAllLabel || 'Move all', '»'), button('move', d.moveLabel || 'Move', '›'), button('remove', d.removeLabel || 'Remove', '‹'), button('none', d.removeAllLabel || 'Remove all', '«'));
2225
+ const order = el('div', 'shuttle-order');
2226
+ order.setAttribute('role', 'group');
2227
+ order.append(button('top', d.topLabel || 'Move to top', '⤒'), button('up', d.upLabel || 'Move up', '↑'), button('down', d.downLabel || 'Move down', '↓'), button('bottom', d.bottomLabel || 'Move to bottom', '⤓'));
2228
+ const left = el('div', 'shuttle-col');
2229
+ left.append(filter, avail);
2230
+ const right = el('div', 'shuttle-col');
2231
+ right.append(chosen);
2232
+ const ui = el('div', 'shuttle-ui');
2233
+ ui.append(left, moves, right, order);
2234
+ sel.hidden = true;
2235
+ box.append(ui);
2236
+ if (label) label.htmlFor = avail.id;
2237
+
2238
+ const src = new Map(); // a shown option → the item's option
2239
+ const lovIndex = (o) => Number(o.dataset.i ?? 0);
2240
+ // the item's options: the chosen ones first (in their order), then the others in the list of values' order
2241
+ const arrange = (picked) => {
2242
+ const rest = [...sel.options].filter((o) => !picked.includes(o)).sort((a, b) => lovIndex(a) - lovIndex(b));
2243
+ for (const o of sel.options) o.selected = picked.includes(o);
2244
+ sel.append(...picked, ...rest);
2245
+ };
2246
+ const picked = () => [...sel.options].filter((o) => o.selected);
2247
+ const draw = (keep = new Set()) => {
2248
+ const q = filter.value.trim().toLowerCase();
2249
+ const fill = (l, opts) => {
2250
+ l.replaceChildren(
2251
+ ...opts.map((o) => {
2252
+ const x = new Option(o.text, o.value);
2253
+ x.selected = keep.has(o);
2254
+ src.set(x, o);
2255
+ return x;
2256
+ }),
2257
+ );
2258
+ };
2259
+ src.clear();
2260
+ fill(avail, [...sel.options].filter((o) => !o.selected && (!q || o.text.toLowerCase().includes(q))));
2261
+ fill(chosen, picked());
2262
+ };
2263
+ let own = false;
2264
+ const changed = (keep) => {
2265
+ draw(keep);
2266
+ own = true;
2267
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
2268
+ own = false;
2269
+ };
2270
+ const marked = (l) => [...l.selectedOptions].map((x) => src.get(x)).filter(Boolean);
2271
+ const run = (cmd) => {
2272
+ let now = picked();
2273
+ let keep = new Set();
2274
+ if (cmd === 'move' || cmd === 'all') {
2275
+ const add = cmd === 'all' ? [...avail.options].map((x) => src.get(x)) : marked(avail);
2276
+ if (!add.length) return;
2277
+ now = [...now, ...add];
2278
+ keep = new Set(add);
2279
+ } else if (cmd === 'remove' || cmd === 'none') {
2280
+ const drop = cmd === 'none' ? now : marked(chosen);
2281
+ if (!drop.length) return;
2282
+ now = now.filter((o) => !drop.includes(o));
2283
+ keep = new Set(drop);
2284
+ } else {
2285
+ const m = marked(chosen);
2286
+ if (!m.length) return;
2287
+ keep = new Set(m);
2288
+ const others = now.filter((o) => !keep.has(o));
2289
+ if (cmd === 'top') now = [...m, ...others];
2290
+ else if (cmd === 'bottom') now = [...others, ...m];
2291
+ else {
2292
+ const step = cmd === 'up' ? -1 : 1;
2293
+ const idx = (cmd === 'up' ? m : [...m].reverse()).map((o) => now.indexOf(o));
2294
+ for (const i of idx) {
2295
+ const j = i + step;
2296
+ if (j < 0 || j >= now.length || keep.has(now[j])) continue;
2297
+ [now[i], now[j]] = [now[j], now[i]];
2298
+ }
2299
+ }
2300
+ }
2301
+ arrange(now);
2302
+ changed(keep);
2303
+ };
2304
+ ui.addEventListener('click', (e) => {
2305
+ const b = e.target.closest('button[data-cmd]');
2306
+ if (b) run(b.dataset.cmd);
2307
+ });
2308
+ avail.addEventListener('dblclick', () => run('move'));
2309
+ chosen.addEventListener('dblclick', () => run('remove'));
2310
+ // the keyboard: Enter moves the marked values across; in the chosen list Alt+arrows order them
2311
+ avail.addEventListener('keydown', (e) => {
2312
+ if (e.key === 'Enter') {
2313
+ e.preventDefault();
2314
+ run('move');
2315
+ }
2316
+ });
2317
+ chosen.addEventListener('keydown', (e) => {
2318
+ if (e.key === 'Enter' || e.key === 'Delete') {
2319
+ e.preventDefault();
2320
+ run('remove');
2321
+ } else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
2322
+ e.preventDefault();
2323
+ run(e.key === 'ArrowUp' ? (e.shiftKey ? 'top' : 'up') : e.shiftKey ? 'bottom' : 'down');
2324
+ }
2325
+ });
2326
+ // the lists' own change events are not the item's
2327
+ for (const l of [avail, chosen]) l.addEventListener('change', (e) => e.stopPropagation());
2328
+ filter.addEventListener('input', () => draw(new Set(marked(avail))));
2329
+ filter.addEventListener('change', (e) => e.stopPropagation());
2330
+ filter.addEventListener('keydown', (e) => {
2331
+ if (e.key === 'Enter') e.preventDefault();
2332
+ });
2333
+ // a dynamic action (Set value, Clear) changed the item
2334
+ sel.addEventListener('change', () => {
2335
+ if (!own) draw();
2336
+ });
2337
+ arrange(picked());
2338
+ draw();
2339
+ }
2340
+
2341
+ // (140; APEX 24.2: Select One, Select Many) A search field over the item's list of values: typing filters a list
2342
+ // box below it (arrow keys, Enter, Escape); Select Many shows the chosen values as chips with a remove button.
2343
+ // The item's <select> (single or multiple; it works without JavaScript) stays in the form, hidden, as the value.
2344
+ function enhanceSelectPicks(root) {
2345
+ const boxes = root.matches?.('[data-select-pick]') ? [root] : [...(root.querySelectorAll?.('[data-select-pick]') ?? [])];
2346
+ boxes.forEach(selectPick);
2347
+ }
2348
+ let selectPickSeq = 0;
2349
+ document.addEventListener('pgkiln:replaced', (e) => e.detail && enhanceSelectPicks(e.detail));
2350
+ enhanceSelectPicks(document);
2351
+ function selectPick(box) {
2352
+ const sel = box.querySelector('select');
2353
+ if (!sel || box.dataset.enhanced) return;
2354
+ box.dataset.enhanced = '1';
2355
+ const d = box.dataset;
2356
+ const many = sel.multiple;
2357
+ const id = sel.id;
2358
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
2359
+ const input = document.createElement('input');
2360
+ input.type = 'text';
2361
+ input.id = `${id}_search`;
2362
+ input.className = 'select-pick-input';
2363
+ input.autocomplete = 'off';
2364
+ input.spellcheck = false;
2365
+ input.placeholder = d.searchLabel || 'Search…';
2366
+ input.setAttribute('role', 'combobox');
2367
+ input.setAttribute('aria-autocomplete', 'list');
2368
+ input.setAttribute('aria-expanded', 'false');
2369
+ for (const a of ['aria-describedby', 'aria-invalid', 'aria-required']) if (sel.hasAttribute(a)) input.setAttribute(a, sel.getAttribute(a));
2370
+ const listId = `${id}_listbox_${++selectPickSeq}`;
2371
+ input.setAttribute('aria-controls', listId);
2372
+ const list = document.createElement('ul');
2373
+ list.id = listId;
2374
+ list.className = 'select-pick-list';
2375
+ list.setAttribute('role', 'listbox');
2376
+ if (label) list.setAttribute('aria-label', label.textContent.replace(/\*\s*$/, '').trim());
2377
+ if (many) list.setAttribute('aria-multiselectable', 'true');
2378
+ list.hidden = true;
2379
+ const chips = document.createElement('ul');
2380
+ chips.className = 'tag-list select-pick-chips';
2381
+ const ui = document.createElement('div');
2382
+ ui.className = 'select-pick-ui';
2383
+ if (many) ui.append(chips);
2384
+ ui.append(input, list);
2385
+ sel.hidden = true;
2386
+ box.append(ui);
2387
+ if (label) label.htmlFor = input.id;
2388
+ const status = document.createElement('span');
2389
+ status.className = 'sr-only';
2390
+ status.setAttribute('aria-live', 'polite');
2391
+ ui.append(status);
2392
+
2393
+ let active = -1;
2394
+ let shown = [];
2395
+ const options = () => [...sel.options];
2396
+ const textOf = (o) => o.text;
2397
+ const syncInput = () => {
2398
+ if (!many) input.value = sel.value ? (sel.selectedOptions[0]?.text ?? '') : '';
2399
+ };
2400
+ const drawChips = () => {
2401
+ if (!many) return;
2402
+ chips.replaceChildren(
2403
+ ...[...sel.selectedOptions].map((o) => {
2404
+ const li = document.createElement('li');
2405
+ li.className = 'tag';
2406
+ li.append(o.text);
2407
+ const x = document.createElement('button');
2408
+ x.type = 'button';
2409
+ x.className = 'tag-remove';
2410
+ x.textContent = '×';
2411
+ x.setAttribute('aria-label', (d.removeLabel || 'Remove {value}').replace('{value}', o.text));
2412
+ x.addEventListener('click', () => {
2413
+ o.selected = false;
2414
+ changed();
2415
+ input.focus();
2416
+ });
2417
+ li.append(x);
2418
+ return li;
2419
+ }),
2420
+ );
2421
+ };
2422
+ let own = false;
2423
+ const changed = () => {
2424
+ drawChips();
2425
+ syncInput();
2426
+ if (!list.hidden) draw();
2427
+ own = true;
2428
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
2429
+ own = false;
2430
+ };
2431
+ const setActive = (i) => {
2432
+ active = i;
2433
+ [...list.children].forEach((li, n) => li.classList.toggle('is-active', n === i));
2434
+ const li = list.children[i];
2435
+ if (li && li.id) {
2436
+ input.setAttribute('aria-activedescendant', li.id);
2437
+ li.scrollIntoView?.({ block: 'nearest' });
2438
+ } else input.removeAttribute('aria-activedescendant');
2439
+ };
2440
+ const draw = () => {
2441
+ const q = (many || input.dataset.typed ? input.value : '').trim().toLowerCase();
2442
+ shown = options().filter((o) => (many ? o.value !== '' : true) && (!q || textOf(o).toLowerCase().includes(q)));
2443
+ list.replaceChildren(
2444
+ ...shown.map((o, n) => {
2445
+ const li = document.createElement('li');
2446
+ li.id = `${listId}_${n}`;
2447
+ li.setAttribute('role', 'option');
2448
+ li.setAttribute('aria-selected', String(o.selected && (many || o.value !== '')));
2449
+ li.textContent = textOf(o);
2450
+ li.addEventListener('mousedown', (e) => e.preventDefault()); // keep the focus in the field
2451
+ li.addEventListener('click', () => pick(n));
2452
+ return li;
2453
+ }),
2454
+ );
2455
+ if (!shown.length) {
2456
+ const li = document.createElement('li');
2457
+ li.className = 'select-pick-none';
2458
+ li.textContent = d.noneLabel || 'Nothing found.';
2459
+ list.append(li);
2460
+ }
2461
+ status.textContent = shown.length ? '' : d.noneLabel || 'Nothing found.';
2462
+ setActive(shown.length ? Math.min(Math.max(active, 0), shown.length - 1) : -1);
2463
+ };
2464
+ const open = () => {
2465
+ if (!list.hidden) return;
2466
+ list.hidden = false;
2467
+ input.setAttribute('aria-expanded', 'true');
2468
+ active = many ? 0 : Math.max(0, options().findIndex((o) => o.selected));
2469
+ draw();
2470
+ };
2471
+ const close = () => {
2472
+ list.hidden = true;
2473
+ input.setAttribute('aria-expanded', 'false');
2474
+ input.removeAttribute('aria-activedescendant');
2475
+ delete input.dataset.typed;
2476
+ if (many) input.value = '';
2477
+ else syncInput();
2478
+ };
2479
+ const pick = (n) => {
2480
+ const o = shown[n];
2481
+ if (!o) return;
2482
+ if (many) {
2483
+ o.selected = !o.selected;
2484
+ input.value = '';
2485
+ active = n;
2486
+ changed();
2487
+ } else {
2488
+ sel.value = o.value;
2489
+ close();
2490
+ changed();
2491
+ }
2492
+ };
2493
+ input.addEventListener('focus', () => {
2494
+ if (!many) input.select?.();
2495
+ });
2496
+ input.addEventListener('click', open);
2497
+ input.addEventListener('input', () => {
2498
+ input.dataset.typed = '1';
2499
+ if (list.hidden) open();
2500
+ active = 0;
2501
+ draw();
2502
+ });
2503
+ input.addEventListener('change', (e) => e.stopPropagation()); // the item's value is the select's
2504
+ input.addEventListener('keydown', (e) => {
2505
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
2506
+ e.preventDefault();
2507
+ if (list.hidden) return open();
2508
+ if (shown.length) setActive((active + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % shown.length);
2509
+ } else if (e.key === 'Enter') {
2510
+ if (list.hidden) return;
2511
+ e.preventDefault();
2512
+ if (active >= 0) pick(active);
2513
+ } else if (e.key === 'Escape') {
2514
+ if (list.hidden) return;
2515
+ e.preventDefault();
2516
+ close();
2517
+ } else if (e.key === 'Backspace' && many && !input.value && sel.selectedOptions.length) {
2518
+ sel.selectedOptions[sel.selectedOptions.length - 1].selected = false;
2519
+ changed();
2520
+ } else if (e.key === 'Tab') close();
2521
+ });
2522
+ input.addEventListener('blur', () => {
2523
+ if (list.hidden) return;
2524
+ // Select One: a field emptied by the user chooses no value
2525
+ if (!many && input.dataset.typed && !input.value.trim() && [...sel.options].some((o) => o.value === '')) {
2526
+ sel.value = '';
2527
+ close();
2528
+ changed();
2529
+ } else close();
2530
+ });
2531
+ // a dynamic action (Set value, Clear) changed the item
2532
+ sel.addEventListener('change', () => {
2533
+ if (own) return;
2534
+ drawChips();
2535
+ if (document.activeElement !== input) syncInput();
2536
+ });
2537
+ drawChips();
2538
+ syncInput();
2539
+ }
2540
+
2165
2541
  // Builder: role suggestions. Clicking a role adds it to the roles field of
2166
2542
  // the same form; in a form with an application select, only that app's
2167
2543
  // suggestions are shown.
@@ -4362,3 +4738,53 @@ document.addEventListener('change', (e) => {
4362
4738
  });
4363
4739
  }
4364
4740
  })();
4741
+
4742
+ // (sprint 139, AP-6) Browser spans: a page whose trace goes to an OpenTelemetry collector carries a
4743
+ // signed <meta name="pgkiln-trace">. Its load timings and its Ajax calls to the same application are
4744
+ // reported to pgkiln (POST /a/<alias>/telemetry), which adds them to the page's trace; the browser
4745
+ // never sees the collector. Nothing happens on pages without the meta.
4746
+ (() => {
4747
+ const meta = document.querySelector('meta[name="pgkiln-trace"]');
4748
+ const m = /^\/a\/[^/]+\//.exec(location.pathname);
4749
+ if (!meta || !m || !window.fetch || !window.performance) return;
4750
+ const token = meta.content;
4751
+ const url = `${m[0]}telemetry`;
4752
+ const origin = performance.timeOrigin || Date.now() - performance.now();
4753
+ let queue = [];
4754
+ const send = () => {
4755
+ if (!queue.length) return;
4756
+ const body = JSON.stringify({ t: token, spans: queue.splice(0, 50) });
4757
+ const blob = new Blob([body], { type: 'application/json' });
4758
+ if (!(navigator.sendBeacon && navigator.sendBeacon(url, blob)))
4759
+ realFetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json' }, keepalive: true, credentials: 'same-origin' }).catch(() => {});
4760
+ };
4761
+ const realFetch = window.fetch.bind(window);
4762
+ window.fetch = function (input, init) {
4763
+ const t0 = performance.now();
4764
+ let path = '';
4765
+ try {
4766
+ const u = new URL(typeof input === 'string' ? input : input instanceof Request ? input.url : String(input), location.href);
4767
+ if (u.origin === location.origin && u.pathname.startsWith(m[0]) && u.pathname !== url) path = u.pathname + u.search;
4768
+ } catch {
4769
+ // not a URL we report
4770
+ }
4771
+ const p = realFetch(input, init);
4772
+ if (path) {
4773
+ const method = String((init && init.method) || (input instanceof Request ? input.method : 'GET')).toUpperCase();
4774
+ const done = (status) => {
4775
+ queue.push({ k: 'ajax', path, method, status, start: origin + t0, dur: performance.now() - t0 });
4776
+ if (queue.length >= 20) send();
4777
+ };
4778
+ p.then((r) => done(r.status), () => done(0));
4779
+ }
4780
+ return p;
4781
+ };
4782
+ window.addEventListener('load', () => setTimeout(() => {
4783
+ const n = performance.getEntriesByType('navigation')[0];
4784
+ if (n) queue.push({ k: 'load', start: origin + n.startTime, dur: (n.loadEventEnd || performance.now()) - n.startTime,
4785
+ t: { ttfb: n.responseStart - n.startTime, dom_interactive: n.domInteractive - n.startTime, dom_complete: n.domComplete - n.startTime, load: n.loadEventEnd - n.startTime } });
4786
+ send();
4787
+ }, 0));
4788
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') send(); });
4789
+ window.addEventListener('pagehide', send);
4790
+ })();
@@ -1645,3 +1645,6 @@ a.cp-type:hover, a.cp-type:focus-visible { border-color: var(--accent); }
1645
1645
  .util-icons code { font-size: 0.75rem; overflow-wrap: anywhere; }
1646
1646
  .ui-defaults :is(input, select, textarea) { width: 100%; min-width: 7rem; }
1647
1647
  .util-upgrade + .util-upgrade { margin-top: 1rem; }
1648
+
1649
+ /* (sprint 139, AP-6) OpenTelemetry */
1650
+ .otel-problem { color: var(--danger); overflow-wrap: anywhere; }
package/src/ai/service.ts CHANGED
@@ -3,6 +3,7 @@ import { decryptSecret } from '../secrets.ts';
3
3
  import { ClaudeProvider } from './anthropic.ts';
4
4
  import { OpenAiProvider } from './openai.ts';
5
5
  import { AiError, type AiProvider, type GenerateRequest, type GenerateResult, type ProviderName } from './types.ts';
6
+ import { count, recordSpan } from '../telemetry/trace.ts';
6
7
 
7
8
  // AI services (migration 060; Gemini and OpenAI-compatible endpoints: 121): an administrator configures them in the
8
9
  // builder (Workspace utilities → AI services) and allows each application
@@ -115,6 +116,19 @@ export interface Usage {
115
116
  }
116
117
 
117
118
  export async function logUsage(info: Pick<CallInfo, 'appId' | 'pageNo' | 'user' | 'source'>, u: Usage) {
119
+ // (sprint 139, AP-6) the call as a span of the current trace (timed by the caller), tokens as metrics
120
+ if (u.status !== 'limited')
121
+ recordSpan(`AI ${u.service.provider} ${u.model}`.slice(0, 120), {
122
+ ms: u.ms,
123
+ attrs: {
124
+ 'gen_ai.system': u.service.provider, 'gen_ai.request.model': u.model, 'gen_ai.operation.name': info.source === 'embedding' ? 'embeddings' : 'chat',
125
+ 'gen_ai.usage.input_tokens': u.inputTokens, 'gen_ai.usage.output_tokens': u.outputTokens, 'pgkiln.ai.service': u.service.name, 'pgkiln.ai.source': info.source,
126
+ },
127
+ error: u.status === 'ok' ? null : `${u.status}${u.message ? `: ${u.message}` : ''}`,
128
+ });
129
+ count('pgkiln.ai.requests', { provider: u.service.provider, status: u.status });
130
+ count('pgkiln.ai.tokens', { provider: u.service.provider, model: u.model.slice(0, 100), type: 'input' }, u.inputTokens);
131
+ count('pgkiln.ai.tokens', { provider: u.service.provider, model: u.model.slice(0, 100), type: 'output' }, u.outputTokens);
118
132
  try {
119
133
  await owner.query(
120
134
  `insert into meta.ai_usage (app_id, page_no, username, service_id, service, provider, model, source, input_tokens, output_tokens, duration_ms, status, message)
package/src/app.ts CHANGED
@@ -97,6 +97,10 @@ import { mailQueueRoutes } from './builder/mail-queue.ts';
97
97
  import { networkAccessRoutes } from './builder/network-access.ts';
98
98
  import { workspaceMailRoutes } from './builder/workspace-mail.ts';
99
99
  import { runtimeScope } from './db.ts';
100
+ // (sprint 139, AP-6) OpenTelemetry: a trace per request, /metrics, the browser relay
101
+ import { startTelemetry } from './telemetry/export.ts';
102
+ import { telemetryHooks, telemetryRoutes } from './telemetry/http.ts';
103
+ import { telemetryBuilderRoutes } from './builder/telemetry.ts';
100
104
 
101
105
  export async function buildApp(opts: { logger?: boolean } = {}) {
102
106
  // a database older than the code: apply the migrations (MIGRATE_ON_START=true)
@@ -117,6 +121,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
117
121
  trustProxy: trustProxySetting(process.env.TRUST_PROXY),
118
122
  });
119
123
  securityHeaders(app);
124
+ // (sprint 139) before the workspace scope below, so the request's trace encloses everything
125
+ if (!pending.length) await startTelemetry();
126
+ telemetryHooks(app);
120
127
  if (pending.length) {
121
128
  // checked again (at most every 5 s) until someone migrates; then requests go through
122
129
  let checkedAt = Date.now();
@@ -279,6 +286,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
279
286
  await app.register(dependencyRoutes);
280
287
  await app.register(upgradeRoutes);
281
288
  await app.register(uiDefaultRoutes);
289
+ // (sprint 139, AP-6) OpenTelemetry: Workspace utilities → OpenTelemetry, a workspace's own, /metrics, the browser relay
290
+ await app.register(telemetryRoutes);
291
+ await app.register(telemetryBuilderRoutes);
282
292
  app.get('/', async (_req, reply) => reply.redirect('/builder/home'));
283
293
  return app;
284
294
  }
@@ -15,6 +15,7 @@ import { configColumns, searchConfigProblems, textSearchConfig, tsvectorSql } fr
15
15
  import { COMPONENTS } from './components.ts';
16
16
  import { DISTANCES, PGVECTOR_MISSING, vectorColumnDims, vectorSchema, type Distance } from '../vector.ts';
17
17
  import { appEntries, type Entry } from './search.ts';
18
+ import { LOV_CHECKED } from '../runtime/items.ts';
18
19
  import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
19
20
 
20
21
  // App Builder → Advisor (APEX: Advisor): checks an application without
@@ -48,7 +49,7 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
48
49
  switch (kind) {
49
50
  case 'region':
50
51
  return [
51
- ...(['report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
52
+ ...(['report', 'classic_report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
52
53
  { name: 'condition', shape: 'boolean' },
53
54
  ];
54
55
  case 'item':
@@ -428,6 +429,9 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
428
429
  if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
429
430
  missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
430
431
  if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
432
+ // (140) a list item (select list, radio group, shuttle, select one or many …) shows no choices without a list of values
433
+ if (kind === 'item' && LOV_CHECKED.has(row.type) && !String(row.lov ?? '').trim())
434
+ missing(e, 'List of values', `A ${row.type} item needs a list of values: it has no choices, and no value passes the check on submit.`, 'warning');
431
435
  // (078) a URL region: a literal origin, valid attributes
432
436
  if (kind === 'region' && row.type === 'url') {
433
437
  const target = urlTarget(row.source);
@@ -45,6 +45,7 @@ export const REGION_LABELS: Record<string, [string, string]> = {
45
45
  workflows: ['Workflow Console', 'activity'], map: ['Map', 'map'], tree: ['Tree', 'org'], static: ['Static Content', 'region'], dynamic: ['Dynamic Content', 'code'],
46
46
  template_component: ['Template Component', 'layers'], list: ['List', 'list'], data_reporter: ['Data Reporter', 'table'], form_designer: ['Form Designer', 'file'],
47
47
  ai_assistant: ['AI Assistant', 'bolt'], url: ['URL', 'link'], search: ['Search', 'search'], login: ['Login', 'lock'], plugin: ['Plug-in', 'layers'],
48
+ classic_report: ['Classic Report', 'table'], breadcrumb: ['Breadcrumb', 'chevron'], help_text: ['Help Text', 'help'],
48
49
  };
49
50
  export const ITEM_LABELS: Record<string, [string, string]> = {
50
51
  text: ['Text Field', 'item'], textarea: ['Text Area', 'item'], number: ['Number Field', 'item'], date: ['Date Picker', 'calendar'],
@@ -53,6 +54,7 @@ export const ITEM_LABELS: Record<string, [string, string]> = {
53
54
  email: ['E-mail', 'inbox'], tel: ['Phone Number', 'item'], url: ['URL', 'item'], color: ['Color Picker', 'item'], file: ['File Upload', 'upload'],
54
55
  location: ['Location', 'map'], richtext: ['Rich Text Editor', 'edit'], markdown: ['Markdown Editor', 'code'], rating: ['Star Rating', 'check'],
55
56
  combobox: ['Combobox (Tags)', 'list'], daterange: ['Date Range', 'calendar'], qrcode: ['QR Code', 'scan'], image: ['Display Image', 'file'], address: ['Geocoded Address', 'map'], hidden: ['Hidden', 'item'], display: ['Display Only', 'file'], password: ['Password', 'key'],
57
+ shuttle: ['Shuttle', 'arrow-right'], select_one: ['Select One', 'search'], select_many: ['Select Many', 'check'],
56
58
  };
57
59
  export const BUTTON_LABELS: Record<string, [string, string]> = {
58
60
  submit: ['Submit Page', 'button'], redirect: ['Redirect to Page', 'button'], da: ['Dynamic Action', 'bolt'], document: ['Download Document', 'download'],
@@ -212,6 +214,11 @@ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type:
212
214
  if (!REGION_TYPES().includes(type)) throw new LayoutError(400, 'Unknown region type.');
213
215
  id = (await c.query(`insert into meta.region (page_id, seq, title, type, source) values ($1, 999999, $2, $3, $4) returning id`,
214
216
  [pid, `New ${REGION_LABELS[type]?.[0].toLowerCase() ?? type}`, type, NEW_REGION_SOURCE[type] ?? null])).rows[0].id;
217
+ // (140) a breadcrumb goes in the breadcrumb bar, without a frame of its own (unless it is dropped elsewhere)
218
+ if (type === 'breadcrumb') {
219
+ await c.query(`update meta.region set template = 'plain', title = 'Breadcrumb' where id = $1`, [id]);
220
+ if (!o.position && !o.before) o = { ...o, position: 'breadcrumb' };
221
+ }
215
222
  } else {
216
223
  const region = await targetRegion(c, pid, o.region ?? '');
217
224
  if (kind === 'item') {
@@ -116,9 +116,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
116
116
  { name: 'title', common: true, label: 'Title', kind: 'text', group: 'Identification' },
117
117
  { name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
118
118
  help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
119
- { name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
119
+ { name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search', 'classic_report', 'breadcrumb', 'help_text'], group: 'Identification' },
120
120
  { name: 'source', common: true, label: 'Source', kind: 'code', wide: true, group: 'Source',
121
- help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
121
+ help: 'report/grid/classic_report: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
122
122
  { name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
123
123
  help: 'Read the rows of a REST data source (Shared Components) instead of a table: the source above is then optional SQL over them, e.g. select * from rest where price > 10. Parameters: {"rest_params": {"city": "&P1_CITY."}} in the attributes.' },
124
124
  { name: 'table_name', common: true, label: 'Table (form, grid)', kind: 'text', help: 'e.g. sales.orders', group: 'Source' },
@@ -149,13 +149,13 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
149
149
  validate: (v) => formatSettingsProblem(v.config),
150
150
  fields: [
151
151
  { name: 'name', common: true, label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
152
- { name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin'], group: 'Identification' },
152
+ { name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin', 'shuttle', 'select_one', 'select_many'], group: 'Identification' },
153
153
  { name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
154
154
  { name: 'region_id', common: true, label: 'Region', kind: 'region', group: 'Layout' },
155
155
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
156
156
  { name: 'source_column', common: true, label: 'Source column (form)', kind: 'text', group: 'Source' },
157
157
  { name: 'default_value', common: true, label: 'Default value', kind: 'text', group: 'Source' },
158
- { name: 'lov', common: true, label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect store values colon-separated: string_to_array(:P1_X, \':\')' },
158
+ { name: 'lov', common: true, label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect/shuttle/select_many store values colon-separated: string_to_array(:P1_X, \':\')' },
159
159
  { name: 'template_options', label: 'Template options', kind: 'options', choices: ITEM_OPTIONS, group: 'Appearance',
160
160
  help: 'CSS classes from a fixed list, added to the item (APEX: Template Options).' },
161
161
  { name: 'required', common: true, label: 'Value required', kind: 'bool', group: 'Validation' },
@@ -163,7 +163,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
163
163
  { name: 'readonly_condition', label: 'Read-only condition (SQL)', kind: 'code', group: 'Security', help: 'When true the item is shown read-only and ignored on submit.' },
164
164
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
165
165
  buildOption('Security'),
166
- { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
166
+ { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Shuttle: two lists (available, chosen) with move and order buttons; the chosen values colon-separated in their order. Select One / Select Many: a search field over the list of values (Select Many: chosen values as chips, colon-separated); {"null_label":"- None -"} or false for Select One. Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
167
167
  ],
168
168
  },
169
169
  button: {