pgkiln 0.45.0 → 0.46.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 (71) hide show
  1. package/db/migrations/106_form_designer.sql +102 -0
  2. package/db/migrations/107_app_version.sql +9 -0
  3. package/docs/apex-feature-parity.md +6 -3
  4. package/docs/guide/03-builder.md +36 -12
  5. package/docs/guide/04-pages-and-regions.md +74 -2
  6. package/docs/guide/05-items.md +14 -2
  7. package/docs/guide/09-reference.md +1 -1
  8. package/docs/guide/11-from-apex.md +2 -1
  9. package/docs/guide/12-development.md +6 -1
  10. package/examples/hr/hr_61_form_designer.sql +80 -0
  11. package/package.json +1 -1
  12. package/public/app.css +62 -2
  13. package/public/app.js +244 -35
  14. package/public/builder.css +21 -1
  15. package/public/builder.js +67 -0
  16. package/public/sw.js +2 -1
  17. package/src/app.ts +9 -1
  18. package/src/builder/appwizard.ts +22 -6
  19. package/src/builder/arrange.ts +33 -12
  20. package/src/builder/code-editor.ts +1 -1
  21. package/src/builder/components.ts +1 -1
  22. package/src/builder/designer.ts +44 -17
  23. package/src/builder/errors.ts +65 -0
  24. package/src/builder/form-designer.ts +229 -0
  25. package/src/builder/locks.ts +6 -0
  26. package/src/builder/newapp.ts +2 -1
  27. package/src/builder/object-browser.ts +81 -18
  28. package/src/builder/region-help.ts +2 -0
  29. package/src/builder/region-settings.ts +2 -0
  30. package/src/builder/reporter.ts +1 -1
  31. package/src/builder/routes.ts +7 -5
  32. package/src/builder/search.ts +3 -3
  33. package/src/builder/shared.ts +2 -2
  34. package/src/builder/ui.ts +2 -0
  35. package/src/cli/replace.ts +10 -0
  36. package/src/i18n/ar.ts +62 -0
  37. package/src/i18n/cs.ts +62 -0
  38. package/src/i18n/da.ts +62 -0
  39. package/src/i18n/de.ts +62 -0
  40. package/src/i18n/el.ts +62 -0
  41. package/src/i18n/es.ts +62 -0
  42. package/src/i18n/fi.ts +62 -0
  43. package/src/i18n/fr.ts +62 -0
  44. package/src/i18n/he.ts +62 -0
  45. package/src/i18n/it.ts +62 -0
  46. package/src/i18n/ja.ts +62 -0
  47. package/src/i18n/ko.ts +62 -0
  48. package/src/i18n/nb.ts +62 -0
  49. package/src/i18n/pl.ts +62 -0
  50. package/src/i18n/pt.ts +62 -0
  51. package/src/i18n/ru.ts +62 -0
  52. package/src/i18n/sv.ts +62 -0
  53. package/src/i18n/tr.ts +62 -0
  54. package/src/i18n/uk.ts +62 -0
  55. package/src/i18n/zh.ts +62 -0
  56. package/src/i18n.ts +124 -0
  57. package/src/metadata.ts +4 -2
  58. package/src/runtime/devbar.ts +1 -1
  59. package/src/runtime/facet-state.ts +10 -5
  60. package/src/runtime/facets.ts +29 -5
  61. package/src/runtime/form-designer.ts +676 -0
  62. package/src/runtime/grid.ts +15 -8
  63. package/src/runtime/items.ts +33 -21
  64. package/src/runtime/lists.ts +1 -1
  65. package/src/runtime/pdf.ts +2 -1
  66. package/src/runtime/regions.ts +4 -0
  67. package/src/runtime/render.ts +7 -3
  68. package/src/runtime/report.ts +2 -2
  69. package/src/runtime/routes.ts +3 -1
  70. package/src/runtime/smart-filters.ts +2 -1
  71. package/src/runtime/tasks.ts +6 -3
package/public/app.css CHANGED
@@ -1152,6 +1152,13 @@ td[data-add]:hover > .cal-add, .cal-add:focus-visible { opacity: 1; }
1152
1152
  .facet { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.3rem; }
1153
1153
  .facet legend { font-weight: 650; font-size: 0.85rem; margin-bottom: 0.3rem; padding: 0; display: flex; gap: 0.5rem; }
1154
1154
  .facet-clear { font-weight: 400; font-size: 0.8rem; }
1155
+ .facet:has(> .facet-menu) { position: relative; }
1156
+ .facet:has(> .facet-menu) > legend { padding-right: 1.6rem; }
1157
+ .facet-menu { position: absolute; top: 0; right: 0; }
1158
+ .facet-menu > summary { cursor: pointer; padding: 0 0.35rem; border-radius: 4px; color: var(--muted); }
1159
+ .facet-menu > summary:hover, .facet-menu[open] > summary { background: var(--surface-3); color: inherit; }
1160
+ .facet-menu .menu-panel { left: auto; right: 0; min-width: 11rem; }
1161
+ .facet-menu .menu-links a[aria-current] { font-weight: 650; }
1155
1162
  .facet-value { font-weight: 400; font-size: 0.9rem; }
1156
1163
  /* (sprint 65) a facet's chart of its counts, collapsed under the legend */
1157
1164
  .facet-chart { margin: 0.1rem 0 0.4rem; }
@@ -1965,9 +1972,25 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
1965
1972
  .th-menu-values li.muted { padding: 0.35rem 0.9rem; }
1966
1973
  .js th[data-col] > a { cursor: pointer; }
1967
1974
 
1968
- /* (sprint 76) a date item in the application's date format: the browser's picker on a hidden date field */
1975
+ /* (sprint 76) a date item in the application's date format */
1969
1976
  .date-field { position: relative; }
1970
- .date-native { position: absolute; right: 0; bottom: 0; width: 1px; height: 1px; opacity: 0; pointer-events: none; border: 0; padding: 0; }
1977
+ /* (sprint 84; APEX: the date picker) a month of days under a date field (app.js) */
1978
+ .date-picker { position: absolute; top: calc(100% + 4px); left: 0; z-index: 60; background: var(--surface); color: var(--text);
1979
+ border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgb(16 24 40 / 18%); padding: 0.5rem; min-width: 17rem; }
1980
+ .date-picker-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.35rem; }
1981
+ .date-picker-title { font-weight: 650; text-transform: capitalize; }
1982
+ .date-picker-grid { border-collapse: collapse; width: 100%; }
1983
+ .date-picker-grid th { font-size: 0.75rem; font-weight: 600; color: var(--muted); padding: 0.2rem 0; text-align: center; }
1984
+ .date-picker-grid td { padding: 1px; text-align: center; }
1985
+ .date-picker-grid button { width: 2.2rem; height: 2.1rem; border: 0; border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; }
1986
+ .date-picker-grid button:hover { background: var(--surface-3); }
1987
+ .date-picker-grid button.date-other { color: var(--muted); }
1988
+ .date-picker-grid button.date-today { box-shadow: inset 0 0 0 1px var(--accent); }
1989
+ .date-picker-grid button.date-selected { background: var(--accent); color: var(--accent-text); }
1990
+ .date-picker-grid button:disabled { opacity: 0.35; cursor: not-allowed; }
1991
+ .date-picker-grid button:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; }
1992
+ .date-picker-foot { display: flex; justify-content: space-between; margin-top: 0.4rem; }
1993
+ .js .grid-table tbody td[data-col]:not(:focus-within) .date-field > .btn { visibility: hidden; }
1971
1994
 
1972
1995
  /* (sprint 77) display positions: banner, breadcrumb bar, a side column, footer; button positions in a region */
1973
1996
  .t-pos-banner, .t-pos-breadcrumb { margin-bottom: 1rem; }
@@ -1982,3 +2005,40 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
1982
2005
  @media (max-width: 900px) { .t-columns-left, .t-columns-right { grid-template-columns: 1fr; } .t-columns-right > .t-side { order: 0; } }
1983
2006
  .region-footer-split { display: flex; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap; }
1984
2007
  .region-footer-split > div { display: flex; gap: 0.5rem; }
2008
+
2009
+ /* (sprint 85) the Form Designer region (form-designer.ts): forms as cards with a Design link, the designer's field table, a row's form */
2010
+ .fd-cards > li { display: flex; flex-direction: column; gap: 0.25rem; }
2011
+ .fd-design-link { font-size: 0.85rem; display: inline-flex; align-items: center; gap: 0.3rem; }
2012
+ .fd-fields input.fd-seq { width: 4.5rem; }
2013
+ .fd-fields td select { min-width: 8rem; }
2014
+ .fd-fields td input:not([type=checkbox]):not(.fd-seq) { min-width: 6rem; width: 100%; }
2015
+ .fd-form { margin: 0.75rem 0; }
2016
+ .fd-radios { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.25rem; }
2017
+ .fd-new { margin-top: 1rem; }
2018
+
2019
+ /* (sprint 88, GEN-4; APEX: the Cards list template and the hero region) a list's cards each get a colour band
2020
+ and their icon in a round badge, in the chart palette's order; the hero sits on the page background */
2021
+ .list-cards > li { --card-color: var(--series-1); }
2022
+ .list-cards > li:nth-child(8n+2) { --card-color: var(--series-2); }
2023
+ .list-cards > li:nth-child(8n+3) { --card-color: var(--series-3); }
2024
+ .list-cards > li:nth-child(8n+4) { --card-color: var(--series-4); }
2025
+ .list-cards > li:nth-child(8n+5) { --card-color: var(--series-5); }
2026
+ .list-cards > li:nth-child(8n+6) { --card-color: var(--series-6); }
2027
+ .list-cards > li:nth-child(8n+7) { --card-color: var(--series-7); }
2028
+ .list-cards > li:nth-child(8n+8) { --card-color: var(--series-8); }
2029
+ .list-card { border-top: 4px solid var(--card-color); }
2030
+ .list-card-icon { flex: none; display: grid; place-items: center; width: 2.5rem; height: 2.5rem; border-radius: 50%;
2031
+ background: color-mix(in srgb, var(--card-color) 16%, transparent); color: var(--card-color); }
2032
+ .list-card-icon .icon { width: 1.3rem; height: 1.3rem; }
2033
+ .list-card-head { gap: 0.75rem; }
2034
+ .region-hero { background: transparent; padding-left: 0; padding-right: 0; }
2035
+
2036
+ /* (sprint 88, GEN-4; APEX: the page footer) the application's version and Back to top, under the content */
2037
+ .t-footer { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 0.5rem 1rem;
2038
+ margin: 0 1.5rem 1rem; max-width: calc(1500px - 3rem); padding: 0.75rem 0 0.25rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; }
2039
+ @media (max-width: 640px) { .t-footer { margin: 0 0.75rem 1rem; } }
2040
+ .t-footer .t-top { margin-left: auto; display: inline-flex; align-items: center; gap: 0.3rem; color: inherit; }
2041
+ .t-footer .t-top:hover, .t-footer .t-top:focus-visible { color: var(--accent); }
2042
+ .t-footer .icon { width: 1rem; height: 1rem; }
2043
+ @media print { .t-footer { display: none; } }
2044
+ .list-card:hover { border-top-color: var(--card-color); }
package/public/app.js CHANGED
@@ -4,6 +4,23 @@
4
4
  // inline scripts.
5
5
  document.documentElement.classList.add('js');
6
6
 
7
+ // (sprint 86) Values a browser kept under pgkiln's old name (pgapex.*, pgapex-push:*) move to
8
+ // pgkiln.* / pgkiln-push:* once: the navigation drawer, the builder's tabs and panes, the region
9
+ // display selector's tab, a device's push notifications. A value already kept under the new name wins.
10
+ for (const store of ['localStorage', 'sessionStorage']) {
11
+ try {
12
+ const s = window[store];
13
+ for (const k of Object.keys(s)) {
14
+ if (!/^pgapex[.-]/.test(k)) continue;
15
+ const neu = `pgkiln${k.slice(6)}`;
16
+ if (s.getItem(neu) === null) s.setItem(neu, s.getItem(k));
17
+ s.removeItem(k);
18
+ }
19
+ } catch {
20
+ /* storage off (private mode, a blocked cookie setting): nothing kept, nothing to move */
21
+ }
22
+ }
23
+
7
24
  // Push notifications (migration 074): the page of an app with notifications on carries
8
25
  // the app's public key (data-push) and the user (data-push-user). Subscribing needs a
9
26
  // click (My account → Notifications, or the dynamic action push_subscribe); the
@@ -3492,23 +3509,10 @@ function areaFilter(map, f) {
3492
3509
  draw();
3493
3510
  }
3494
3511
 
3495
- // Date range: the end date can't be before the start date
3496
- function range(box) {
3497
- const [from, to] = box.querySelectorAll('input[type=date]');
3498
- if (!from || !to) return;
3499
- const limit = () => {
3500
- to.min = from.value;
3501
- from.max = to.value;
3502
- };
3503
- from.addEventListener('change', limit);
3504
- to.addEventListener('change', limit);
3505
- }
3506
-
3507
3512
  function enhance(root) {
3508
3513
  root.querySelectorAll('[data-richtext]').forEach(richText);
3509
3514
  root.querySelectorAll('[data-markdown]').forEach(markdown);
3510
3515
  root.querySelectorAll('[data-tags]').forEach(tags);
3511
- root.querySelectorAll('[data-range]').forEach(range);
3512
3516
  root.querySelectorAll('[data-reveal]').forEach((b) => (b.hidden = false));
3513
3517
  }
3514
3518
  enhance(document);
@@ -3931,11 +3935,42 @@ document.addEventListener('change', (e) => {
3931
3935
  if (e.target.matches?.('select[data-go]')) e.target.form?.submit();
3932
3936
  });
3933
3937
 
3934
- // (sprint 76) A date item in the application's date format: the calendar button opens the browser's date
3935
- // picker on a hidden date field; the chosen date is written into the text field in the mask.
3938
+ // (sprint 76, 84; APEX: the date picker) A date field in a date mask (items.ts dateField): its calendar button
3939
+ // opens pgkiln's date picker, a month of days under the field; the chosen date is written into the text field
3940
+ // in the mask. Keys: arrows (a day, a week), Page Up/Down (a month; with Shift a year), Home/End (the week),
3941
+ // Enter or Space (choose), Escape (close).
3936
3942
  (() => {
3943
+ const meta = (() => { try { return JSON.parse(document.getElementById('pgkiln-meta')?.textContent || '{}'); } catch { return {}; } })();
3944
+ const t = (k, d) => (meta.texts && meta.texts[k]) || d;
3945
+ const lang = document.documentElement.lang || 'en';
3937
3946
  const pad = (n) => String(n).padStart(2, '0');
3938
- const TOKEN = /"[^"]*"|YYYY|YY|MONTH|MON|MM|DD/gi;
3947
+ const isoOf = (d) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
3948
+ const dateOf = (iso) => { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)); };
3949
+ const addDays = (iso, n) => { const d = dateOf(iso); d.setUTCDate(d.getUTCDate() + n); return isoOf(d); };
3950
+ const addMonths = (iso, n) => {
3951
+ const d = dateOf(iso);
3952
+ const day = d.getUTCDate();
3953
+ d.setUTCDate(1);
3954
+ d.setUTCMonth(d.getUTCMonth() + n);
3955
+ const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
3956
+ d.setUTCDate(Math.min(day, last));
3957
+ return isoOf(d);
3958
+ };
3959
+ const today = () => { const n = new Date(); return `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())}`; };
3960
+ // the week starts on the language's first day (Monday where the browser doesn't say)
3961
+ const weekStart = (() => {
3962
+ try {
3963
+ const l = new Intl.Locale(lang);
3964
+ const w = (l.getWeekInfo && l.getWeekInfo()) || l.weekInfo;
3965
+ if (w && w.firstDay) return w.firstDay % 7;
3966
+ } catch {}
3967
+ return /^en-US$|^(ja|ko|zh|he|ar|pt-BR)/i.test(lang) ? 0 : 1;
3968
+ })();
3969
+ const fmt = (o) => { try { return new Intl.DateTimeFormat(lang, { ...o, timeZone: 'UTC' }); } catch { return new Intl.DateTimeFormat('en', { ...o, timeZone: 'UTC' }); } };
3970
+ const dayShort = fmt({ weekday: 'short' }), dayLong = fmt({ weekday: 'long' }), full = fmt({ dateStyle: 'full' }), title = fmt({ month: 'long', year: 'numeric' });
3971
+
3972
+ const TOKEN = /"[^"]*"|YYYY|YY|MONTH|MON|MM|DD|DAY|DY/gi;
3973
+ const monthsOf = (field) => { try { return JSON.parse(field.dataset.months || '{}'); } catch { return {}; } };
3939
3974
  const format = (iso, mask, months) => {
3940
3975
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
3941
3976
  if (!m) return '';
@@ -3948,31 +3983,205 @@ document.addEventListener('change', (e) => {
3948
3983
  if (up === 'YY') return pad(y % 100);
3949
3984
  if (up === 'MM') return pad(mo);
3950
3985
  if (up === 'DD') return pad(d);
3951
- if (up === 'MONTH') return cased(months.long[mo - 1]);
3952
- if (up === 'MON') return cased(months.short[mo - 1]);
3986
+ if (up === 'MONTH') return cased((months.long || [])[mo - 1] || '');
3987
+ if (up === 'MON') return cased((months.short || [])[mo - 1] || '');
3988
+ if (up === 'DAY') return cased(dayLong.format(dateOf(iso)));
3989
+ if (up === 'DY') return cased(dayShort.format(dateOf(iso)));
3953
3990
  return tok;
3954
3991
  });
3955
3992
  };
3993
+ // the text read back in the mask (as format.ts parseDateMask does), or as YYYY-MM-DD; null when it isn't a date
3994
+ const parse = (text, mask, months) => {
3995
+ const v = (text || '').trim();
3996
+ let y, mo, d;
3997
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
3998
+ if (iso) [y, mo, d] = iso.slice(1).map(Number);
3999
+ else {
4000
+ const esc = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4001
+ const names = (list) => `(${(list || []).map(esc).join('|') || '\\p{L}+'})`;
4002
+ const groups = [];
4003
+ let last = 0, re = '';
4004
+ for (const m of mask.matchAll(TOKEN)) {
4005
+ re += esc(mask.slice(last, m.index));
4006
+ last = m.index + m[0].length;
4007
+ if (m[0].startsWith('"')) { re += esc(m[0].slice(1, -1)); continue; }
4008
+ const up = m[0].toUpperCase();
4009
+ re += { YYYY: '(\\d{4})', YY: '(\\d{2})', MM: '(\\d{1,2})', DD: '(\\d{1,2})', MONTH: names(months.long), MON: names(months.short), DAY: '(\\p{L}+)', DY: '(\\p{L}+)' }[up];
4010
+ groups.push(up);
4011
+ }
4012
+ re += esc(mask.slice(last));
4013
+ const m = new RegExp(`^${re}$`, 'iu').exec(v);
4014
+ if (!m) return null;
4015
+ groups.forEach((g, i) => {
4016
+ const x = m[i + 1];
4017
+ if (g === 'YYYY') y = Number(x);
4018
+ else if (g === 'YY') y = 2000 + Number(x);
4019
+ else if (g === 'MM') mo = Number(x);
4020
+ else if (g === 'DD') d = Number(x);
4021
+ else if (g === 'MONTH' || g === 'MON') mo = ((g === 'MONTH' ? months.long : months.short) || []).findIndex((n) => n.toLowerCase() === x.toLowerCase()) + 1;
4022
+ });
4023
+ }
4024
+ if (!y || !mo || !d) return null;
4025
+ const out = `${String(y).padStart(4, '0')}-${pad(mo)}-${pad(d)}`;
4026
+ return isoOf(dateOf(out)) === out ? out : null;
4027
+ };
4028
+
4029
+ let open = null; // { box, field, btn, focus, view }
4030
+ function close(back) {
4031
+ if (!open) return;
4032
+ const { box, btn } = open;
4033
+ open = null;
4034
+ box.remove();
4035
+ btn.setAttribute('aria-expanded', 'false');
4036
+ if (back) btn.focus();
4037
+ }
4038
+ function choose(iso) {
4039
+ const { field } = open;
4040
+ field.value = iso ? format(iso, field.dataset.dateMask || 'YYYY-MM-DD', monthsOf(field)) : '';
4041
+ close(false);
4042
+ field.dispatchEvent(new Event('input', { bubbles: true }));
4043
+ field.dispatchEvent(new Event('change', { bubbles: true }));
4044
+ field.focus();
4045
+ }
4046
+ // a date range's to is on or after its from (and the from on or before the to)
4047
+ const allowed = (iso) => {
4048
+ const { field } = open;
4049
+ if (!field.closest('.date-range')) return true;
4050
+ const isTo = field.id.endsWith('_TO');
4051
+ const other = document.getElementById(isTo ? field.id.slice(0, -3) : `${field.id}_TO`);
4052
+ const bound = other ? parse(other.value, other.dataset.dateMask || 'YYYY-MM-DD', monthsOf(other)) : null;
4053
+ return !bound || (isTo ? iso >= bound : iso <= bound);
4054
+ };
4055
+ function render() {
4056
+ const { box, field } = open;
4057
+ const sel = parse(field.value, field.dataset.dateMask || 'YYYY-MM-DD', monthsOf(field));
4058
+ const now = today();
4059
+ const view = open.view;
4060
+ const first = dateOf(`${view.slice(0, 7)}-01`);
4061
+ const start = addDays(isoOf(first), -((first.getUTCDay() - weekStart + 7) % 7));
4062
+ box.textContent = '';
4063
+ const head = document.createElement('div');
4064
+ head.className = 'date-picker-head';
4065
+ const nav = (label, n, glyph) => {
4066
+ const b = document.createElement('button');
4067
+ b.type = 'button';
4068
+ b.className = 'btn btn-sm';
4069
+ b.setAttribute('aria-label', label);
4070
+ b.textContent = glyph;
4071
+ b.addEventListener('click', () => { open.focus = addMonths(open.focus, n); open.view = open.focus; render(); });
4072
+ return b;
4073
+ };
4074
+ const caption = document.createElement('div');
4075
+ caption.className = 'date-picker-title';
4076
+ caption.setAttribute('aria-live', 'polite');
4077
+ caption.id = `${field.id}_dp_title`;
4078
+ caption.textContent = title.format(first);
4079
+ head.append(nav(t('calendar.previous', 'Previous month'), -1, '‹'), caption, nav(t('calendar.next', 'Next month'), 1, '›'));
4080
+ const table = document.createElement('table');
4081
+ table.className = 'date-picker-grid';
4082
+ table.setAttribute('role', 'grid');
4083
+ table.setAttribute('aria-labelledby', caption.id);
4084
+ const hr = table.createTHead().insertRow();
4085
+ for (let i = 0; i < 7; i++) {
4086
+ const th = document.createElement('th');
4087
+ const day = addDays(start, i);
4088
+ th.scope = 'col';
4089
+ th.abbr = dayLong.format(dateOf(day));
4090
+ th.textContent = dayShort.format(dateOf(day));
4091
+ hr.append(th);
4092
+ }
4093
+ const body = table.createTBody();
4094
+ for (let w = 0; w < 6; w++) {
4095
+ const tr = body.insertRow();
4096
+ for (let i = 0; i < 7; i++) {
4097
+ const iso = addDays(start, w * 7 + i);
4098
+ const td = tr.insertCell();
4099
+ const b = document.createElement('button');
4100
+ b.type = 'button';
4101
+ b.dataset.iso = iso;
4102
+ b.textContent = String(Number(iso.slice(8)));
4103
+ b.setAttribute('aria-label', full.format(dateOf(iso)));
4104
+ b.tabIndex = iso === open.focus ? 0 : -1;
4105
+ if (iso.slice(0, 7) !== view.slice(0, 7)) b.classList.add('date-other');
4106
+ if (iso === now) { b.classList.add('date-today'); b.setAttribute('aria-current', 'date'); }
4107
+ if (iso === sel) { b.classList.add('date-selected'); b.setAttribute('aria-pressed', 'true'); }
4108
+ if (!allowed(iso)) b.disabled = true;
4109
+ td.append(b);
4110
+ }
4111
+ }
4112
+ const foot = document.createElement('div');
4113
+ foot.className = 'date-picker-foot';
4114
+ const act = (label, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-sm'; b.textContent = label; b.addEventListener('click', fn); return b; };
4115
+ foot.append(act(t('calendar.today', 'Today'), () => (allowed(now) ? choose(now) : null)), act(t('facets.clear_short', 'Clear'), () => choose('')));
4116
+ box.append(head, table, foot);
4117
+ box.querySelector('button[tabindex="0"]')?.focus({ preventScroll: true });
4118
+ }
4119
+ // fixed under the field (above it when there is no room), so a scrolling table or grid cell doesn't clip it
4120
+ function place() {
4121
+ if (!open) return;
4122
+ const { box, field } = open;
4123
+ const r = field.getBoundingClientRect();
4124
+ const h = box.offsetHeight, w = box.offsetWidth;
4125
+ box.style.position = 'fixed';
4126
+ box.style.top = `${Math.round(r.bottom + 4 + h > window.innerHeight - 8 && r.top - h - 4 > 8 ? r.top - h - 4 : r.bottom + 4)}px`;
4127
+ box.style.left = `${Math.round(Math.max(8, Math.min(r.left, window.innerWidth - w - 8)))}px`;
4128
+ }
4129
+ window.addEventListener('scroll', place, { passive: true, capture: true });
4130
+ window.addEventListener('resize', place);
4131
+ function show(field, btn) {
4132
+ close(false);
4133
+ const box = document.createElement('div');
4134
+ box.className = 'date-picker';
4135
+ box.setAttribute('role', 'dialog');
4136
+ box.setAttribute('aria-label', t('item.choose_date', 'Choose a date'));
4137
+ const sel = parse(field.value, field.dataset.dateMask || 'YYYY-MM-DD', monthsOf(field));
4138
+ open = { box, field, btn, focus: sel || today(), view: sel || today() };
4139
+ btn.setAttribute('aria-expanded', 'true');
4140
+ (field.closest('.date-field') || field.parentElement).append(box);
4141
+ render();
4142
+ place();
4143
+ box.addEventListener('keydown', (e) => {
4144
+ const day = e.target.dataset?.iso;
4145
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); return; }
4146
+ if (!day) return;
4147
+ const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
4148
+ let next = null;
4149
+ if (step) next = addDays(day, step);
4150
+ else if (e.key === 'PageUp' || e.key === 'PageDown') next = addMonths(day, (e.key === 'PageUp' ? -1 : 1) * (e.shiftKey ? 12 : 1));
4151
+ else if (e.key === 'Home') next = addDays(day, -((dateOf(day).getUTCDay() - weekStart + 7) % 7));
4152
+ else if (e.key === 'End') next = addDays(day, 6 - ((dateOf(day).getUTCDay() - weekStart + 7) % 7));
4153
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (allowed(day)) choose(day); return; }
4154
+ if (!next) return;
4155
+ e.preventDefault();
4156
+ open.focus = next;
4157
+ if (next.slice(0, 7) !== open.view.slice(0, 7)) { open.view = next; render(); }
4158
+ else {
4159
+ box.querySelectorAll('button[data-iso]').forEach((b) => (b.tabIndex = b.dataset.iso === next ? 0 : -1));
4160
+ box.querySelector(`button[data-iso="${next}"]`)?.focus({ preventScroll: true });
4161
+ }
4162
+ });
4163
+ box.addEventListener('click', (e) => {
4164
+ const b = e.target.closest('button[data-iso]');
4165
+ if (b && !b.disabled) choose(b.dataset.iso);
4166
+ });
4167
+ }
3956
4168
  document.addEventListener('click', (e) => {
3957
4169
  const btn = e.target.closest?.('[data-date-pick]');
3958
- if (!btn) return;
3959
- const native = document.querySelector(`input[data-date-for="${CSS.escape(btn.dataset.datePick)}"]`);
3960
- if (!native) return;
3961
- try {
3962
- native.showPicker();
3963
- } catch {
3964
- native.focus();
4170
+ if (btn) {
4171
+ const field = document.getElementById(btn.dataset.datePick);
4172
+ if (!field) return;
4173
+ e.preventDefault();
4174
+ if (open && open.btn === btn) close(true);
4175
+ else show(field, btn);
4176
+ return;
3965
4177
  }
4178
+ if (open && !e.target.closest?.('.date-picker')) close(false);
3966
4179
  });
3967
- document.addEventListener('change', (e) => {
3968
- const native = e.target;
3969
- if (!native.matches?.('input[data-date-for]')) return;
3970
- const text = document.getElementById(native.dataset.dateFor);
3971
- if (!text) return;
3972
- let months = { long: [], short: [] };
3973
- try { months = JSON.parse(text.dataset.months || '{}'); } catch {}
3974
- text.value = format(native.value, text.dataset.dateMask || 'YYYY-MM-DD', months);
3975
- text.dispatchEvent(new Event('change', { bubbles: true }));
3976
- text.focus();
4180
+ // Alt+Down in the field opens the picker (as a native date field does)
4181
+ document.addEventListener('keydown', (e) => {
4182
+ if (e.altKey && e.key === 'ArrowDown' && e.target.matches?.('input[data-date-mask]')) {
4183
+ const btn = e.target.parentElement?.querySelector('[data-date-pick]');
4184
+ if (btn) { e.preventDefault(); show(e.target, btn); }
4185
+ }
3977
4186
  });
3978
4187
  })();
@@ -368,7 +368,7 @@ a.pd-node:hover { background: var(--surface-3); }
368
368
  .js .pd-gal { cursor: grab; }
369
369
  .pd-gal:hover { background: var(--surface-3); }
370
370
  .pd-gal .icon { width: 22px; height: 22px; color: var(--muted); stroke-width: 1.5; }
371
- .pd-gal span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
371
+ .pd-gal span { min-width: 0; overflow-wrap: anywhere; line-height: 1.2; }
372
372
 
373
373
  /* ------------------------------------------------------------ help and search tabs */
374
374
  .pd-help, .pd-search { padding: 12px 16px; max-width: 60rem; }
@@ -1012,3 +1012,23 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
1012
1012
  .pd-goto-no input { width: 5.5em; font-variant-numeric: tabular-nums; }
1013
1013
  /* on narrower screens the previous / next arrows and the page list do it */
1014
1014
  @media (max-width: 1279px) { .pd-goto-no { display: none; } }
1015
+
1016
+ /* (sprint 89, SQL-4) the Object Browser: a table's tabs on one row with More, and the Columns toolbar */
1017
+ .ob-tabs { flex-wrap: nowrap; align-items: stretch; }
1018
+ .ob-tabs > a { white-space: nowrap; }
1019
+ .ob-more { position: relative; }
1020
+ .ob-more > summary { display: flex; align-items: center; gap: 0.25rem; height: 100%; padding: 0.5rem 0.85rem; color: var(--muted); cursor: pointer; white-space: nowrap; }
1021
+ .ob-more > summary:hover, .ob-more[open] > summary { color: var(--text); }
1022
+ .ob-more .menu-panel { right: 0; left: auto; min-width: 12rem; }
1023
+ .ob-more .icon { width: 0.9rem; height: 0.9rem; }
1024
+ .ob-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
1025
+ .ob-tool { position: relative; }
1026
+ .ob-tool > summary { list-style: none; }
1027
+ .ob-tool .menu-panel { left: 0; min-width: 18rem; padding: 0.75rem; }
1028
+ .ob-tool .ob-action { flex-direction: column; align-items: stretch; }
1029
+ .ob-tool .ob-action input:not([type=checkbox]), .ob-tool .ob-action select { width: 100%; }
1030
+ .ob-toolbar-note { font-size: 0.85rem; }
1031
+ @media (max-width: 640px) { .ob-tabs { overflow-x: auto; } .ob-tool .menu-panel { min-width: min(18rem, 90vw); } }
1032
+
1033
+ /* (sprint 90, PD-4) a commented-out component in the tree (APEX: Comment Out): struck through and muted */
1034
+ .pd-node[data-commented] .pd-label { text-decoration: line-through; color: var(--muted); }
package/public/builder.js CHANGED
@@ -270,6 +270,11 @@
270
270
  list.push(['Duplicate', () => submit(`${base}/layout/duplicate`, { kind, id })], ['Copy to other page…', copyDialog]);
271
271
  if (kind !== 'region') list.push(['Create dynamic action', go('new=dynamic_action')]);
272
272
  if (node.classList.contains('has-kids')) list.push(null, ['Expand all below', () => setAll(node, true)], ['Collapse all below', () => setAll(node, false)]);
273
+ // (sprint 90; APEX: Comment Out) leave a component out of the page without deleting it (build option COMMENTED_OUT)
274
+ if (['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'].includes(kind))
275
+ list.push(null, node.hasAttribute('data-commented')
276
+ ? ['Uncomment', () => submit(`${base}/layout/comment`, { kind, id, on: 'false' })]
277
+ : ['Comment out', () => submit(`${base}/layout/comment`, { kind, id, on: 'true' })]);
273
278
  list.push(null, ['Delete', () => submit(`${base}/c/${kind}/${id}/delete`, {}, `Delete ${kind} ${label}?`), 'danger']);
274
279
  return list;
275
280
  };
@@ -1167,3 +1172,65 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
1167
1172
  });
1168
1173
  });
1169
1174
  });
1175
+
1176
+ // (sprint 87) The create application wizard:
1177
+ // - an alias follows its name (data-derive-from) until the developer types in it, like aliasFor() on the server;
1178
+ // - choosing a schema under Data reloads the Pages list (and the proposed name and alias, while untouched)
1179
+ // without leaving the page; without JavaScript, "Show its tables" does the same with a reload;
1180
+ // - First user and Password are hidden while Authentication is None (the server ignores them then).
1181
+ (() => {
1182
+ const slug = (v) => {
1183
+ let a = v.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
1184
+ if (a && !/^[a-z]/.test(a)) a = `app-${a}`;
1185
+ return a;
1186
+ };
1187
+ document.querySelectorAll('input[data-derive-from]').forEach((alias) => {
1188
+ const name = document.getElementById(alias.dataset.deriveFrom);
1189
+ if (!name) return;
1190
+ alias.addEventListener('input', () => alias.setAttribute('data-edited', ''));
1191
+ name.addEventListener('input', () => {
1192
+ if (!alias.hasAttribute('data-edited')) alias.value = slug(name.value);
1193
+ });
1194
+ });
1195
+
1196
+ document.querySelectorAll('select[name="authentication"]').forEach((auth) => {
1197
+ const form = auth.form;
1198
+ const fields = ['admin_user', 'admin_password'].map((n) => form?.elements.namedItem(n)?.closest?.('.field')).filter(Boolean);
1199
+ if (!fields.length) return;
1200
+ const sync = () => fields.forEach((f) => (f.hidden = auth.value === 'none'));
1201
+ auth.addEventListener('change', sync);
1202
+ sync();
1203
+ });
1204
+
1205
+ const picker = document.querySelector('form.create-schema select[name="schema"]');
1206
+ const pages = document.getElementById('aw-pages');
1207
+ const hidden = document.getElementById('aw-schema-fields');
1208
+ if (!picker || !pages || !hidden) return;
1209
+ const name = document.getElementById('f_name');
1210
+ let nameTouched = false;
1211
+ name?.addEventListener('input', () => (nameTouched = true));
1212
+ picker.addEventListener('change', async () => {
1213
+ const url = `${picker.form.action}?schema=${encodeURIComponent(picker.value)}`;
1214
+ pages.setAttribute('aria-busy', 'true');
1215
+ try {
1216
+ const res = await fetch(url, { headers: { accept: 'text/html' }, credentials: 'same-origin' });
1217
+ if (!res.ok) throw new Error(String(res.status));
1218
+ const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
1219
+ const nextPages = doc.getElementById('aw-pages');
1220
+ const nextHidden = doc.getElementById('aw-schema-fields');
1221
+ if (!nextPages || !nextHidden) throw new Error('unexpected page');
1222
+ pages.replaceWith(nextPages);
1223
+ hidden.replaceWith(nextHidden);
1224
+ // the proposals for the new schema, unless the developer already typed a name or alias
1225
+ const alias = document.querySelector('input[data-derive-from]');
1226
+ if (!nameTouched && name) name.value = doc.getElementById('f_name')?.value ?? name.value;
1227
+ if (alias && !alias.hasAttribute('data-edited')) alias.value = doc.getElementById('f_alias')?.value ?? alias.value;
1228
+ history.replaceState(null, '', url);
1229
+ document.getElementById('aw-pages')?.removeAttribute('aria-busy');
1230
+ } catch {
1231
+ picker.form.submit();
1232
+ }
1233
+ });
1234
+ // the button is for pages without JavaScript
1235
+ picker.form.querySelector('button')?.setAttribute('hidden', '');
1236
+ })();
package/public/sw.js CHANGED
@@ -15,11 +15,12 @@ self.addEventListener('install', (event) => {
15
15
  event.waitUntil(caches.open(STATIC).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
16
16
  });
17
17
 
18
+ // older versions' static files, and (sprint 86) the caches of pgkiln's old name (pgapex-*)
18
19
  self.addEventListener('activate', (event) => {
19
20
  event.waitUntil(
20
21
  caches
21
22
  .keys()
22
- .then((keys) => Promise.all(keys.filter((k) => k.startsWith('pgkiln-static-') && k !== STATIC).map((k) => caches.delete(k))))
23
+ .then((keys) => Promise.all(keys.filter((k) => (k.startsWith('pgkiln-static-') && k !== STATIC) || k.startsWith('pgapex-')).map((k) => caches.delete(k))))
23
24
  .then(() => self.clients.claim()),
24
25
  );
25
26
  });
package/src/app.ts CHANGED
@@ -49,6 +49,9 @@ import { accountRoutes } from './runtime/account.ts';
49
49
  import { taskRoutes } from './runtime/tasks.ts';
50
50
  import { dataReporterRoutes } from './runtime/data-reporter.ts';
51
51
  import { reporterBuilderRoutes } from './builder/reporter.ts';
52
+ import { formDesignerRoutes } from './runtime/form-designer.ts';
53
+ import { formDesignerBuilderRoutes } from './builder/form-designer.ts';
54
+ import { builderErrorPages, builderServerError } from './builder/errors.ts';
52
55
  import { workflowRoutes } from './runtime/workflows.ts';
53
56
  import { pwaRoutes } from './runtime/pwa.ts';
54
57
  import { pushRoutes } from './runtime/push.ts';
@@ -138,10 +141,13 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
138
141
  });
139
142
  // An id in the URL that isn't a number (or is too big) fails in PostgreSQL:
140
143
  // that's a page that doesn't exist, not a server error.
141
- app.setErrorHandler((err: Error & { code?: string }, _req, reply) => {
144
+ app.setErrorHandler((err: Error & { code?: string }, req, reply) => {
142
145
  if (err.code === '22P02' || err.code === '22003') return reply.code(404).type('text/plain').send('Not found');
146
+ // (sprint 86) a browser on a builder page gets an HTML error page
147
+ if (builderServerError(req, reply, err)) return reply;
143
148
  return reply.send(err);
144
149
  });
150
+ builderErrorPages(app);
145
151
  await app.register(cookie);
146
152
  await app.register(formbody, { bodyLimit: 5 * 1024 * 1024 });
147
153
  // file upload items; oversized files are cut off and reported, not thrown
@@ -223,6 +229,8 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
223
229
  await app.register(codeEditorRoutes);
224
230
  await app.register(dataReporterRoutes);
225
231
  await app.register(reporterBuilderRoutes);
232
+ await app.register(formDesignerRoutes);
233
+ await app.register(formDesignerBuilderRoutes);
226
234
  await app.register(sampleDataRoutes);
227
235
  await app.register(assistantRoutes);
228
236
  await app.register(aiFilterRoutes);
@@ -50,6 +50,9 @@ interface Relation {
50
50
  calendar: boolean;
51
51
  /** (sprint 73) a table with a foreign key to this one, for master-detail */
52
52
  detail: boolean;
53
+ /** (sprint 87) row level security is on, with this many policies */
54
+ rls: boolean;
55
+ policies: number;
53
56
  }
54
57
 
55
58
  /** The tables, partitioned tables, views and materialized views of a schema (not partitions). */
@@ -59,7 +62,8 @@ export async function schemaRelations(schema: string): Promise<Relation[]> {
59
62
  `select c.relname, meta.wizard_table_label(c.oid) as label, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
60
63
  meta.wizard_pk(c.oid) as pk, case when c.reltuples >= 0 then c.reltuples::bigint end as rows,
61
64
  meta.wizard_defaults('calendar', c.oid) ->> 'start' is not null as calendar,
62
- meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail
65
+ meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail,
66
+ c.relrowsecurity as rls, (select count(*)::int from pg_policy p where p.polrelid = c.oid) as policies
63
67
  from pg_class c join pg_namespace n on n.oid = c.relnamespace
64
68
  where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition
65
69
  order by c.relkind in ('v', 'm'), c.relname`,
@@ -311,6 +315,16 @@ const SOURCES: [string, string, string, string][] = [
311
315
  ['gallery', 'grid', 'From the App Gallery', 'A starter application'],
312
316
  ];
313
317
 
318
+ /**
319
+ * (sprint 87) A table's row level security, before the application is created: on with its number
320
+ * of policies, or on without any (the application's role then sees no rows unless it owns the table).
321
+ */
322
+ const rlsBadge = (r: Relation) =>
323
+ !r.rls ? ''
324
+ : r.policies > 0
325
+ ? html` <span class="tag aw-rls" title="Row level security is on (${r.policies} ${r.policies === 1 ? 'policy' : 'policies'}): each user sees only the rows the policies allow">${icon('shield')} RLS · ${r.policies}</span>`
326
+ : html` <span class="tag tag-error aw-rls" title="Row level security is on without policies: the application's role sees no rows (unless it owns the table or bypasses row level security)">${icon('shield')} RLS, no policies</span>`;
327
+
314
328
  async function createPage(s: Session, reply: FastifyReply, schema: string, b: Body | null, message: Raw | '' = '') {
315
329
  const schemas = await appSchemas();
316
330
  const known = schemas.includes(schema);
@@ -334,8 +348,8 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
334
348
  'The application\'s parsing schema. Choose a schema to get a page per table and view; a new schema is named after the alias.')}</div>
335
349
  <div class="buttons"><button class="btn">${icon('search')} Show its tables</button></div></form>`)}
336
350
  <form method="post" action="${BASE}/apps" data-progress="Creating the application and its pages…">${csrf(s)}
337
- <input type="hidden" name="schema" value="${known ? schema : ''}"><input type="hidden" name="features" value="1">
338
- ${known ? html`<input type="hidden" name="tables" value="1">` : ''}
351
+ <input type="hidden" name="features" value="1">
352
+ <div id="aw-schema-fields"><input type="hidden" name="schema" value="${known ? schema : ''}">${known ? html`<input type="hidden" name="tables" value="1">` : ''}</div>
339
353
  ${region('Application', html`<div class="form-grid">
340
354
  <div class="field"><label class="label" for="f_name">Name</label>
341
355
  <input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once></div>
@@ -354,14 +368,16 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
354
368
  ? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
355
369
  <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
356
370
  <select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${PAGE_KINDS[k]}</option>`)}</select>
357
- <small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small></li>`)
371
+ <small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small>${rlsBadge(r)}</li>`)
358
372
  : html`<li class="muted">${schema} has no tables or views.</li>`
359
373
  : html`<li class="muted">Choose a schema under Data for a page per table, or add pages with the page wizards later.</li>`}
360
374
  </ul>
361
- ${known ? check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a column whose values repeat', b ? b.chart === 'true' : true) : ''}`)}
375
+ ${known ? check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a column whose values repeat', b ? b.chart === 'true' : true) : ''}`, '', 'aw-pages')}
362
376
  ${region('Features', html`<div class="aw-features">${FEATURES.map(([k, label, help]) => check(`f_${k}`, label, features[k], help))}</div>`)}
363
377
  ${region('Settings', html`<div class="form-grid">
364
- ${input('alias', 'Alias (URL)', v('alias', known ? await aliasFor(schema) : ''), { required: true, help: 'lowercase, e.g. inventory → /a/inventory' })}
378
+ <div class="field"><label class="label" for="f_alias">Alias (URL)</label>
379
+ <input id="f_alias" name="alias" value="${v('alias', known ? await aliasFor(schema) : '')}" required pattern="[a-z][a-z0-9_\-]*" maxlength="50" data-derive-from="f_name"${b && b.alias ? raw(' data-edited') : ''}>
380
+ <small class="help">lowercase, e.g. inventory → /a/inventory; follows the name until you change it</small></div>
365
381
  ${select('authentication', 'Authentication', v('authentication', 'app_users'), [['app_users', 'App users (login page)'], ['none', 'None (public)']])}
366
382
  ${input('admin_user', 'First user', v('admin_user', ''), { placeholder: 'e.g. your name', help: 'Gets the admin role. An existing account in Users is reused.' })}
367
383
  ${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'For a new account; at least 8 characters.' })}