pgkiln 0.41.0 → 0.42.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 (53) hide show
  1. package/db/migrations/097_generated_app_quality.sql +911 -0
  2. package/db/migrations/098_app_activity.sql +37 -0
  3. package/docs/apex-feature-parity.md +5 -5
  4. package/docs/guide/03-builder.md +66 -18
  5. package/docs/guide/09-reference.md +6 -2
  6. package/docs/guide/11-from-apex.md +1 -1
  7. package/docs/guide/12-development.md +1 -1
  8. package/docs/guide/14-globalization.md +7 -3
  9. package/docs/guide/17-mobile.md +1 -1
  10. package/package.json +1 -1
  11. package/public/app.css +175 -33
  12. package/public/app.js +80 -0
  13. package/public/builder.css +30 -2
  14. package/public/builder.js +31 -0
  15. package/src/builder/appfromfile.ts +5 -4
  16. package/src/builder/appsheets.ts +23 -9
  17. package/src/builder/appwizard.ts +215 -70
  18. package/src/builder/blueprints.ts +2 -1
  19. package/src/builder/components.ts +1 -1
  20. package/src/builder/home.ts +2 -32
  21. package/src/builder/newapp.ts +182 -5
  22. package/src/builder/routes.ts +4 -28
  23. package/src/i18n/ar.ts +1 -0
  24. package/src/i18n/cs.ts +1 -0
  25. package/src/i18n/da.ts +1 -0
  26. package/src/i18n/de.ts +1 -0
  27. package/src/i18n/el.ts +1 -0
  28. package/src/i18n/es.ts +1 -0
  29. package/src/i18n/fi.ts +1 -0
  30. package/src/i18n/fr.ts +1 -0
  31. package/src/i18n/he.ts +1 -0
  32. package/src/i18n/it.ts +1 -0
  33. package/src/i18n/ja.ts +1 -0
  34. package/src/i18n/ko.ts +1 -0
  35. package/src/i18n/nb.ts +1 -0
  36. package/src/i18n/pl.ts +1 -0
  37. package/src/i18n/pt.ts +1 -0
  38. package/src/i18n/ru.ts +1 -0
  39. package/src/i18n/sv.ts +1 -0
  40. package/src/i18n/tr.ts +1 -0
  41. package/src/i18n/uk.ts +1 -0
  42. package/src/i18n/zh.ts +1 -0
  43. package/src/i18n.ts +2 -0
  44. package/src/icons.ts +2 -1
  45. package/src/layout.ts +2 -2
  46. package/src/runtime/charts.ts +8 -3
  47. package/src/runtime/devbar.ts +35 -14
  48. package/src/runtime/engine.ts +7 -4
  49. package/src/runtime/locale.ts +2 -2
  50. package/src/runtime/regions.ts +25 -2
  51. package/src/runtime/render.ts +18 -4
  52. package/src/runtime/report.ts +15 -9
  53. package/src/runtime/styles.ts +5 -2
package/public/app.css CHANGED
@@ -76,12 +76,100 @@
76
76
  color-scheme: dark;
77
77
  }
78
78
 
79
+ /* ---------- base style "Iris" (theme.base = 'iris'; the default for new
80
+ applications, like APEX 26's Iris theme style): (sprint 72) a light body,
81
+ a dark warm-grey header and menu with a thin accent strip, square-ish
82
+ regions with a rule under their header, 4 px corners, a dark primary button
83
+ and a large page title on the body. Selectors are html[data-style="iris"]:
84
+ they beat the Standard values above, and the application's own colours
85
+ (src/runtime/styles.ts, html:root, later in the page) beat them in turn.
86
+ Contrast: text ≥ 15:1, muted ≥ 5.8:1, accent ≥ 5.5:1, menu text ≥ 10:1. */
87
+ html[data-style="iris"] {
88
+ --bg: #ffffff;
89
+ --surface: #ffffff;
90
+ --surface-2: #f7f6f5;
91
+ --surface-3: #efedeb;
92
+ --text: #1f1c1a;
93
+ --muted: #645e59;
94
+ --border: #e2dfdc;
95
+ --accent: #2b62c4;
96
+ --accent-text: #ffffff;
97
+ --accent-soft: #e9f0fb;
98
+ --header: #3a3632;
99
+ --header-text: #f7f5f3;
100
+ --nav-bg: #2f2b28;
101
+ --nav-text: #ece8e4;
102
+ --nav-muted: #b9b2ab;
103
+ --nav-hover: #403b37;
104
+ --nav-active: #4a443f;
105
+ --nav-current: #ffffff;
106
+ --hot: #2f2b28;
107
+ --hot-text: #ffffff;
108
+ --radius: 4px;
109
+ --font: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
110
+ --shadow: none;
111
+ }
112
+ @media (prefers-color-scheme: dark) {
113
+ html[data-style="iris"]:not([data-theme="light"]) {
114
+ --bg: #1a1817;
115
+ --surface: #232120;
116
+ --surface-2: #2a2826;
117
+ --surface-3: #33302d;
118
+ --text: #ece9e6;
119
+ --muted: #aaa49e;
120
+ --border: #3a3633;
121
+ --accent: #84aef2;
122
+ --accent-text: #10131a;
123
+ --accent-soft: #23324a;
124
+ --header: #121110;
125
+ --header-text: #ece9e6;
126
+ --nav-bg: #1d1b1a;
127
+ --nav-text: #e2ded9;
128
+ --nav-muted: #a39c95;
129
+ --nav-hover: #2c2927;
130
+ --nav-active: #36322f;
131
+ --nav-current: #ffffff;
132
+ --hot: #ece9e6;
133
+ --hot-text: #1a1817;
134
+ }
135
+ }
136
+ html[data-style="iris"][data-theme="dark"] {
137
+ --bg: #1a1817;
138
+ --surface: #232120;
139
+ --surface-2: #2a2826;
140
+ --surface-3: #33302d;
141
+ --text: #ece9e6;
142
+ --muted: #aaa49e;
143
+ --border: #3a3633;
144
+ --accent: #84aef2;
145
+ --accent-text: #10131a;
146
+ --accent-soft: #23324a;
147
+ --header: #121110;
148
+ --header-text: #ece9e6;
149
+ --nav-bg: #1d1b1a;
150
+ --nav-text: #e2ded9;
151
+ --nav-muted: #a39c95;
152
+ --nav-hover: #2c2927;
153
+ --nav-active: #36322f;
154
+ --nav-current: #ffffff;
155
+ --hot: #ece9e6;
156
+ --hot-text: #1a1817;
157
+ }
158
+ html[data-style="iris"] .t-header { box-shadow: inset 0 3px 0 var(--accent); }
159
+ html[data-style="iris"] .t-nav { border-right-color: transparent; }
160
+ html[data-style="iris"] .t-titlebar { background: transparent; border-bottom: 0; padding: 1.25rem 1.5rem 0.25rem; }
161
+ html[data-style="iris"] .t-titlebar h1 { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.2; }
162
+ html[data-style="iris"] .region-header h2 { font-size: 1.05rem; }
163
+ html[data-style="iris"] .btn-hot { background: var(--hot); border-color: var(--hot); color: var(--hot-text); }
164
+ html[data-style="iris"] .btn-hot:hover { background: color-mix(in srgb, var(--hot) 82%, var(--bg)); }
165
+ html[data-style="iris"] .btn-hot .btn-badge { background: var(--hot-text); color: var(--hot); }
166
+
79
167
  /* ---------- base style "Iris" (theme.base = 'iris'; the default for new
80
168
  applications, like APEX 26.1's Iris theme style). Selectors are
81
- html[data-style="iris"]: they beat the Standard values above, and the
169
+ html[data-style="lavender"]: they beat the Standard values above, and the
82
170
  application's own colours (src/runtime/styles.ts, html:root, later in the
83
171
  page) beat them in turn. Contrast: text ≥ 15:1, muted ≥ 5.8:1, accent ≥ 6.5:1. */
84
- html[data-style="iris"] {
172
+ html[data-style="lavender"] {
85
173
  --bg: #f6f6fb;
86
174
  --surface: #ffffff;
87
175
  --surface-2: #f8f8fc;
@@ -101,7 +189,7 @@ html[data-style="iris"] {
101
189
  --shadow: 0 1px 2px rgb(30 27 75 / 5%), 0 2px 8px rgb(30 27 75 / 6%);
102
190
  }
103
191
  @media (prefers-color-scheme: dark) {
104
- html[data-style="iris"]:not([data-theme="light"]) {
192
+ html[data-style="lavender"]:not([data-theme="light"]) {
105
193
  --bg: #121120;
106
194
  --surface: #1a1930;
107
195
  --surface-2: #201f38;
@@ -119,7 +207,7 @@ html[data-style="iris"] {
119
207
  --shadow: 0 1px 2px rgb(0 0 0 / 30%), 0 2px 8px rgb(0 0 0 / 25%);
120
208
  }
121
209
  }
122
- html[data-style="iris"][data-theme="dark"] {
210
+ html[data-style="lavender"][data-theme="dark"] {
123
211
  --bg: #121120;
124
212
  --surface: #1a1930;
125
213
  --surface-2: #201f38;
@@ -136,13 +224,13 @@ html[data-style="iris"][data-theme="dark"] {
136
224
  --nav-active: #2a2752;
137
225
  --shadow: 0 1px 2px rgb(0 0 0 / 30%), 0 2px 8px rgb(0 0 0 / 25%);
138
226
  }
139
- /* Iris: rounded navigation pills instead of an edge bar, quieter region headers, pill-shaped primary buttons */
140
- html[data-style="iris"] .t-nav a[aria-current="page"] { box-shadow: none; border-radius: calc(var(--radius) * 0.75); }
141
- html[data-style="iris"] .t-nav a, html[data-style="iris"] .t-nav summary { border-radius: calc(var(--radius) * 0.75); }
142
- html[data-style="iris"] .region-header { border-bottom-color: transparent; }
143
- html[data-style="iris"] .region-header h2 { letter-spacing: -0.01em; }
144
- html[data-style="iris"] .btn-hot { border-radius: 999px; }
145
- html[data-style="iris"] :focus-visible { outline-color: var(--accent); }
227
+ /* Lavender: rounded navigation pills instead of an edge bar, quieter region headers, pill-shaped primary buttons */
228
+ html[data-style="lavender"] .t-nav a[aria-current="page"] { box-shadow: none; border-radius: calc(var(--radius) * 0.75); }
229
+ html[data-style="lavender"] .t-nav a, html[data-style="lavender"] .t-nav summary { border-radius: calc(var(--radius) * 0.75); }
230
+ html[data-style="lavender"] .region-header { border-bottom-color: transparent; }
231
+ html[data-style="lavender"] .region-header h2 { letter-spacing: -0.01em; }
232
+ html[data-style="lavender"] .btn-hot { border-radius: 999px; }
233
+ html[data-style="lavender"] :focus-visible { outline-color: var(--accent); }
146
234
 
147
235
  * { box-sizing: border-box; }
148
236
  html { -webkit-text-size-adjust: 100%; }
@@ -225,17 +313,18 @@ details.menu > summary::-webkit-details-marker { display: none; }
225
313
  .t-nav ul ul { padding: 0 0 0 1.4rem; }
226
314
  .t-nav a, .t-nav summary {
227
315
  display: flex; align-items: center; gap: 0.65rem; padding: 0.5rem 0.65rem; border-radius: 6px;
228
- color: var(--text); text-decoration: none; cursor: pointer; white-space: nowrap; font-size: 0.93rem;
316
+ color: var(--nav-text, var(--text)); text-decoration: none; cursor: pointer; white-space: nowrap; font-size: 0.93rem;
229
317
  }
230
318
  .t-nav summary { list-style: none; }
231
319
  .t-nav summary::-webkit-details-marker { display: none; }
232
- .t-nav summary::after { content: "›"; margin-left: auto; color: var(--muted); transition: transform 0.15s; }
320
+ .t-nav summary::after { content: "›"; margin-left: auto; color: var(--nav-muted, var(--muted)); transition: transform 0.15s; }
233
321
  .t-nav details[open] > summary::after { transform: rotate(90deg); }
234
- .t-nav a:hover, .t-nav summary:hover { background: var(--surface-3); }
235
- .t-nav a[aria-current="page"] { background: var(--nav-active); color: var(--accent); font-weight: 600; box-shadow: inset 3px 0 0 var(--accent); }
236
- .t-nav .in-trail { color: var(--accent); }
237
- .t-nav .icon { color: var(--muted); }
238
- .t-nav a[aria-current="page"] .icon, .t-nav .in-trail .icon { color: var(--accent); }
322
+ .t-nav a:hover, .t-nav summary:hover { background: var(--nav-hover, var(--surface-3)); }
323
+ /* (sprint 72) --nav-* let a style have a dark menu (Iris); without them the menu takes the page's colours */
324
+ .t-nav a[aria-current="page"] { background: var(--nav-active); color: var(--nav-current, var(--accent)); font-weight: 600; box-shadow: inset 3px 0 0 var(--accent); }
325
+ .t-nav .in-trail { color: var(--nav-current, var(--accent)); }
326
+ .t-nav .icon { color: var(--nav-muted, var(--muted)); }
327
+ .t-nav a[aria-current="page"] .icon, .t-nav .in-trail .icon { color: var(--nav-current, var(--accent)); }
239
328
 
240
329
  /* ---------- main ---------- */
241
330
  .t-main { min-width: 0; }
@@ -259,6 +348,9 @@ details.menu > summary::-webkit-details-marker { display: none; }
259
348
  .region-hero { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 1rem 1.5rem; padding: 2rem 1.75rem; border-radius: var(--radius);
260
349
  background: var(--accent-soft); color: var(--text); }
261
350
  .region-hero-icon .icon { width: 3rem; height: 3rem; color: var(--accent); }
351
+ /* (097) the generated home page: the icon on a tile in the accent colour */
352
+ .region-hero-icon { display: grid; place-items: center; width: 4.5rem; height: 4.5rem; border-radius: calc(var(--radius) + 4px); background: var(--accent); }
353
+ .region-hero-icon .icon { width: 2.4rem; height: 2.4rem; color: var(--accent-text, #fff); }
262
354
  .region-hero-text h2 { font-size: 1.75rem; margin: 0 0 0.35rem; }
263
355
  .region-hero-buttons, .region-title-buttons, .region-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; }
264
356
  @media (max-width: 640px) { .region-hero { grid-template-columns: 1fr; padding: 1.25rem 1rem; } }
@@ -401,7 +493,11 @@ details.btn-menu[open] > summary .btn-caret { transform: rotate(-90deg); }
401
493
  table.report { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
402
494
  .report th, .report td { text-align: left; padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
403
495
  .report tbody tr:last-child td { border-bottom: 0; }
404
- .report th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); background: var(--surface-2); font-weight: 650; }
496
+ .report th { font-size: 0.85rem; color: var(--muted); background: var(--surface-2); font-weight: 650; }
497
+ /* (097) the edit link of a row: a pencil in a narrow first column */
498
+ .report th.row-edit, .report td.row-edit { width: 2.25rem; text-align: center; padding-left: 0.4rem; padding-right: 0.4rem; }
499
+ .report td.row-edit a { display: inline-grid; place-items: center; width: 1.9rem; height: 1.9rem; border-radius: 6px; color: var(--accent); }
500
+ .report td.row-edit a:hover, .report td.row-edit a:focus-visible { background: var(--accent-soft); }
405
501
  .report th a { color: inherit; text-decoration: none; display: inline-flex; gap: 0.3rem; }
406
502
  .report th a:hover { color: var(--accent); }
407
503
  .sort-ind { font-size: 0.7em; }
@@ -446,7 +542,19 @@ table.report { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
446
542
  .swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--c); flex: none; }
447
543
 
448
544
  /* horizontal bars */
449
- .chart-bar { display: flex; flex-direction: column; gap: 0.45rem; }
545
+ .chart-bar { display: flex; flex-direction: column; gap: 0; }
546
+ .chart-bar > .bar-row { padding: 0.22rem 0; }
547
+ /* (097) gridlines behind the bars and a value axis under them */
548
+ .bar-stack { position: relative; }
549
+ .bar-grid { position: absolute; inset: -0.22rem 0; pointer-events: none; }
550
+ .bar-gridline { position: absolute; top: 0; bottom: 0; border-left: 1px solid var(--chart-grid); }
551
+ .bar-gridline.baseline { border-left-color: var(--chart-axis); }
552
+ .bar-stack > .bar-track { position: relative; }
553
+ .bar-axis { display: grid; grid-template-columns: minmax(70px, 30%) 1fr auto; gap: 0.75rem; font-size: 0.72rem; color: var(--muted); }
554
+ .bar-axis > :last-child { min-width: 3rem; }
555
+ .bar-ticks { position: relative; height: 1.1rem; }
556
+ .bar-ticks > span { position: absolute; top: 0.15rem; transform: translateX(-50%); font-variant-numeric: tabular-nums; white-space: nowrap; }
557
+ .bar-ticks > span:first-child { transform: none; }
450
558
  .bar-row { display: grid; grid-template-columns: minmax(70px, 30%) 1fr auto; gap: 0.75rem; align-items: center; font-size: 0.88rem; }
451
559
  .bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
452
560
  .bar-stack { display: flex; flex-direction: column; gap: 2px; }
@@ -822,7 +930,7 @@ pre.source { font-family: var(--mono); font-size: 0.82rem; background: var(--sur
822
930
  .card.metric { grid-template-columns: 1fr; row-gap: 0.25rem; }
823
931
  .metric-icon { grid-row: auto; width: 36px; height: 36px; }
824
932
  .metric-value { font-size: 1.35rem; }
825
- .bar-row { grid-template-columns: minmax(60px, 35%) 1fr auto; gap: 0.5rem; }
933
+ .bar-row, .bar-axis { grid-template-columns: minmax(60px, 35%) 1fr auto; gap: 0.5rem; }
826
934
  .title-row .buttons { width: 100%; }
827
935
  .title-row .buttons .btn { flex: 1 1 auto; }
828
936
 
@@ -842,7 +950,9 @@ pre.source { font-family: var(--mono); font-size: 0.82rem; background: var(--sur
842
950
  display: grid; grid-template-columns: minmax(90px, 38%) minmax(0, 1fr); gap: 0.75rem;
843
951
  border: 0; padding: 0.3rem 0.8rem; white-space: normal; max-width: none; text-align: left !important; overflow-wrap: anywhere;
844
952
  }
845
- .report-reflow td::before { content: attr(data-label); font-size: 0.78rem; font-weight: 650; color: var(--muted); text-transform: uppercase; letter-spacing: 0.02em; }
953
+ .report-reflow td::before { content: attr(data-label); font-size: 0.8rem; font-weight: 650; color: var(--muted); }
954
+ .report-reflow td.row-edit { width: auto; text-align: left; }
955
+ .report-reflow td.row-edit::before { content: none; }
846
956
  .report-reflow td.empty { display: block; }
847
957
  .report-reflow td.empty::before { content: none; }
848
958
  .report-reflow td.pre { white-space: pre-wrap; }
@@ -1689,19 +1799,51 @@ dialog.crop-dialog::backdrop { background: rgb(15 20 30 / 55%); }
1689
1799
  .crop-dialog .buttons { display: flex; justify-content: flex-end; gap: 0.5rem; }
1690
1800
 
1691
1801
  /* (079) Developer toolbar: shown to developers with a builder session in the same browser.
1692
- Fixed at the bottom left, collapsible (a <details>, so it works without JavaScript). */
1693
- .devbar { position: fixed; left: 0.75rem; bottom: 0.75rem; z-index: 900; max-width: calc(100vw - 1.5rem);
1694
- background: #1b2330; color: #fff; border-radius: var(--radius); box-shadow: 0 4px 16px rgb(0 0 0 / 25%); font-size: 0.8125rem; }
1695
- .devbar summary { cursor: pointer; padding: 0.4rem 0.75rem; font-weight: 600; list-style: none; }
1696
- .devbar summary::-webkit-details-marker { display: none; }
1697
- .devbar summary::before { content: '▸ '; }
1698
- .devbar[open] summary::before { content: '▾ '; }
1699
- .devbar ul { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.75rem; list-style: none; margin: 0; padding: 0 0.75rem 0.5rem; }
1700
- .devbar a { color: #9cc8ff; }
1701
- .devbar a:focus-visible, .devbar button:focus-visible, .devbar select:focus-visible { outline: 2px solid #9cc8ff; outline-offset: 2px; }
1702
- .devbar-debug { display: flex; align-items: center; gap: 0.35rem; margin: 0; }
1802
+ (sprint 72) A compact bar of icons centred at the bottom (APEX's runtime developer toolbar);
1803
+ app.js docks it at the top, left or right and shrinks it to a slim tab when it is not used.
1804
+ The page keeps room for it, so it never covers the menu's last entry or a report's pager. */
1805
+ .devbar { --devbar-size: 2.75rem; position: fixed; z-index: 900; left: 50%; bottom: 0.5rem; transform: translateX(-50%);
1806
+ background: #1b2330; color: #fff; border-radius: 10px; box-shadow: 0 4px 16px rgb(0 0 0 / 25%); font-size: 0.8125rem;
1807
+ transition: transform 0.2s ease, opacity 0.2s ease; }
1808
+ .devbar-tools { display: flex; align-items: center; gap: 0.15rem; list-style: none; margin: 0; padding: 0.25rem; }
1809
+ .devbar .devbar-tool { display: inline-grid; place-items: center; width: 2.25rem; height: 2.25rem; border-radius: 7px; color: #dbe7ff;
1810
+ background: none; border: 0; cursor: pointer; list-style: none; padding: 0; }
1811
+ .devbar-tool::-webkit-details-marker { display: none; }
1812
+ .devbar .devbar-tool:hover, .devbar-menu[open] > .devbar-tool, .devbar-quick[aria-pressed="true"] { background: #2f3b4f; color: #fff; }
1813
+ .devbar-debug-menu.is-on > .devbar-tool { color: #ffd166; }
1814
+ .devbar a, .devbar-pop a { color: #9cc8ff; }
1815
+ .devbar a:focus-visible, .devbar button:focus-visible, .devbar select:focus-visible, .devbar summary:focus-visible, .devbar input:focus-visible { outline: 2px solid #9cc8ff; outline-offset: 2px; }
1816
+ .devbar-menu { position: relative; }
1817
+ .devbar-pop { position: absolute; bottom: calc(100% + 0.6rem); left: 50%; transform: translateX(-50%); min-width: 14rem; max-width: min(22rem, calc(100vw - 2rem));
1818
+ background: #1b2330; border-radius: 8px; box-shadow: 0 4px 16px rgb(0 0 0 / 30%); padding: 0.6rem 0.75rem; display: grid; gap: 0.5rem; }
1819
+ .devbar-debug { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin: 0; }
1820
+ .devbar-debug label { flex-basis: 100%; }
1703
1821
  .devbar-debug select, .devbar-debug button { font: inherit; font-size: 0.8125rem; padding: 0.1rem 0.3rem; border-radius: 4px; border: 1px solid #5e6a7a; background: #2b3340; color: #fff; }
1704
1822
  .devbar-debug button { cursor: pointer; }
1823
+ .devbar-dock { border: 0; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 0.25rem 0.75rem; }
1824
+ .devbar-dock legend { padding: 0; margin-bottom: 0.25rem; font-weight: 600; }
1825
+ .devbar-pop .devbar-facts { color: #fff; }
1826
+ /* docked at the top, or on the left or right edge (a column of icons) */
1827
+ .devbar[data-dock="top"] { bottom: auto; top: 0.5rem; }
1828
+ .devbar[data-dock="top"] .devbar-pop { bottom: auto; top: calc(100% + 0.6rem); }
1829
+ .devbar[data-dock="left"], .devbar[data-dock="right"] { left: 0.5rem; bottom: auto; top: 50%; transform: translateY(-50%); }
1830
+ .devbar[data-dock="right"] { left: auto; right: 0.5rem; }
1831
+ .devbar[data-dock="left"] .devbar-tools, .devbar[data-dock="right"] .devbar-tools { flex-direction: column; }
1832
+ .devbar[data-dock="left"] .devbar-pop { bottom: auto; top: 0; left: calc(100% + 0.6rem); transform: none; }
1833
+ .devbar[data-dock="right"] .devbar-pop { bottom: auto; top: 0; left: auto; right: calc(100% + 0.6rem); transform: none; }
1834
+ /* not used for a while: a slim tab at the edge; pointing at it or focusing it brings it back */
1835
+ .devbar.is-idle { opacity: 0.85; }
1836
+ .devbar.is-idle[data-dock="bottom"] { transform: translate(-50%, calc(100% - 6px)); }
1837
+ .devbar.is-idle[data-dock="top"] { transform: translate(-50%, calc(-100% + 6px)); }
1838
+ .devbar.is-idle[data-dock="left"] { transform: translate(calc(-100% + 6px), -50%); }
1839
+ .devbar.is-idle[data-dock="right"] { transform: translate(calc(100% - 6px), -50%); }
1840
+ .devbar.is-idle .devbar-tools { pointer-events: none; }
1841
+ @media (prefers-reduced-motion: reduce) { .devbar { transition: none; } }
1842
+ /* room for the bar: the menu and the page end above it */
1843
+ body:has(.devbar[data-dock="bottom"]) .t-main, body:has(.devbar[data-dock="bottom"]) .t-nav > ul { padding-bottom: 3.75rem; }
1844
+ body:has(.devbar[data-dock="top"]) .t-body { padding-top: 3.25rem; }
1845
+ body:has(.devbar[data-dock="left"]) .t-body { padding-left: 3.25rem; }
1846
+ body:has(.devbar[data-dock="right"]) .t-main { padding-right: 3.5rem; }
1705
1847
  .devbar-page .t-main { max-width: 72rem; margin: 0 auto; padding: 1rem; }
1706
1848
  .devbar-facts { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; }
1707
1849
  .devbar-facts dd { margin: 0; overflow-wrap: anywhere; }
package/public/app.js CHANGED
@@ -828,6 +828,15 @@ const pgkilnUnsaved = (() => {
828
828
  });
829
829
  drawer.addEventListener('change', () => toggle.setAttribute('aria-expanded', String(navOpen())));
830
830
  }
831
+ // (097) a long menu shows the current page's entry: the menu scrolls, not the page
832
+ {
833
+ const nav = document.getElementById('t-nav');
834
+ const current = nav?.querySelector('a[aria-current="page"]');
835
+ if (nav && current && nav.scrollHeight > nav.clientHeight) {
836
+ const top = current.getBoundingClientRect().top - nav.getBoundingClientRect().top;
837
+ if (top < 0 || top + current.offsetHeight > nav.clientHeight) nav.scrollTop += top - nav.clientHeight / 3;
838
+ }
839
+ }
831
840
 
832
841
  // ------------------------------------------------------------ modal dialogs
833
842
  const POSITIONS = ['center', 'left', 'right', 'top', 'bottom'];
@@ -3606,6 +3615,77 @@ function areaFilter(map, f) {
3606
3615
  check();
3607
3616
  })();
3608
3617
 
3618
+ // (sprint 72) Install app (PWA): the header's button appears when the browser offers the installation.
3619
+ (() => {
3620
+ const button = document.querySelector('[data-pwa-install]');
3621
+ if (!button) return;
3622
+ let offer = null;
3623
+ window.addEventListener('beforeinstallprompt', (e) => {
3624
+ e.preventDefault();
3625
+ offer = e;
3626
+ button.hidden = false;
3627
+ });
3628
+ button.addEventListener('click', async () => {
3629
+ if (!offer) return;
3630
+ offer.prompt();
3631
+ await offer.userChoice.catch(() => null);
3632
+ offer = null;
3633
+ button.hidden = true;
3634
+ });
3635
+ window.addEventListener('appinstalled', () => { button.hidden = true; });
3636
+ })();
3637
+
3638
+ // (sprint 72) The developer toolbar: docked where the developer chose (bottom, top, left, right;
3639
+ // kept in this browser), shrunk to a slim tab after a few seconds without use (unless turned off),
3640
+ // back when pointed at or focused. Its panels close when another opens or on Escape.
3641
+ (() => {
3642
+ const bar = document.querySelector('.devbar');
3643
+ if (!bar) return;
3644
+ const KEY = 'pgkiln.devbar';
3645
+ let prefs = { dock: 'bottom', autohide: true };
3646
+ try { prefs = { ...prefs, ...JSON.parse(localStorage.getItem(KEY) || '{}') }; } catch {}
3647
+ if (!['bottom', 'top', 'left', 'right'].includes(prefs.dock)) prefs.dock = 'bottom';
3648
+ const save = () => { try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch {} };
3649
+ const settings = bar.querySelector('.devbar-settings');
3650
+ settings?.closest('li')?.removeAttribute('hidden');
3651
+ const apply = () => {
3652
+ bar.dataset.dock = prefs.dock;
3653
+ bar.querySelectorAll('input[name="devbar-dock"]').forEach((r) => { r.checked = r.value === prefs.dock; });
3654
+ const auto = bar.querySelector('input[name="devbar-autohide"]');
3655
+ if (auto) auto.checked = !!prefs.autohide;
3656
+ };
3657
+ apply();
3658
+ bar.addEventListener('change', (e) => {
3659
+ if (e.target.name === 'devbar-dock') prefs.dock = e.target.value;
3660
+ else if (e.target.name === 'devbar-autohide') prefs.autohide = e.target.checked;
3661
+ else return;
3662
+ save();
3663
+ apply();
3664
+ });
3665
+ const menus = [...bar.querySelectorAll('details.devbar-menu')];
3666
+ menus.forEach((m) => m.addEventListener('toggle', () => { if (m.open) menus.forEach((o) => { if (o !== m) o.open = false; }); }));
3667
+ document.addEventListener('keydown', (e) => {
3668
+ if (e.key === 'Escape' && menus.some((m) => m.open)) {
3669
+ const open = menus.find((m) => m.open);
3670
+ open.open = false;
3671
+ open.querySelector('summary')?.focus();
3672
+ }
3673
+ });
3674
+ document.addEventListener('click', (e) => { if (!bar.contains(e.target)) menus.forEach((m) => { m.open = false; }); });
3675
+ let timer = 0;
3676
+ const busy = () => bar.matches(':hover') || bar.contains(document.activeElement) || menus.some((m) => m.open) || !!bar.querySelector('[aria-pressed="true"]');
3677
+ const later = () => {
3678
+ clearTimeout(timer);
3679
+ if (prefs.autohide) timer = setTimeout(() => { if (!busy()) bar.classList.add('is-idle'); else later(); }, 3000);
3680
+ };
3681
+ const wake = () => { bar.classList.remove('is-idle'); later(); };
3682
+ bar.addEventListener('pointerenter', wake);
3683
+ bar.addEventListener('focusin', wake);
3684
+ bar.addEventListener('pointerleave', later);
3685
+ bar.addEventListener('change', wake);
3686
+ later();
3687
+ })();
3688
+
3609
3689
  // (sprint 59) Quick edit from the developer toolbar (APEX: Quick Edit): while it is on, pointing at a
3610
3690
  // region, item or button outlines it and a click opens it in the Page Designer (in the builder's tab);
3611
3691
  // Esc or the button again turns it off. The toolbar is only there for developers of the application.
@@ -768,7 +768,12 @@ button.is-busy { opacity: 0.7; cursor: progress; }
768
768
  --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --font-size: 15px; --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 6%);
769
769
  color-scheme: light; margin-bottom: 1rem; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
770
770
  background: var(--bg); color: var(--text); font: var(--font-size)/1.5 var(--font); }
771
- .tr-preview-iris { --bg: #f6f6fb; --surface-2: #f8f8fc; --surface-3: #eeeef6; --text: #1d1b2e; --muted: #5f5d75; --border: #e3e2ef;
771
+ .tr-preview-iris { --bg: #ffffff; --surface-2: #f7f6f5; --surface-3: #efedeb; --text: #1f1c1a; --muted: #645e59; --border: #e2dfdc;
772
+ --accent: #2b62c4; --accent-soft: #e9f0fb; --header: #3a3632; --header-text: #f7f5f3; --radius: 4px; --shadow: none;
773
+ --font: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; }
774
+ .tr-preview-iris .tr-preview-header { box-shadow: inset 0 3px 0 var(--accent); }
775
+ .tr-preview-iris .btn-hot { background: #2f2b28; border-color: #2f2b28; color: #fff; }
776
+ .tr-preview-lavender { --bg: #f6f6fb; --surface-2: #f8f8fc; --surface-3: #eeeef6; --text: #1d1b2e; --muted: #5f5d75; --border: #e3e2ef;
772
777
  --accent: #5146d8; --accent-soft: #eeedfd; --header: #1e1a4d; --header-text: #f1f0ff; --radius: 12px;
773
778
  --font: "Inter", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; --shadow: 0 1px 2px rgb(30 27 75 / 5%), 0 2px 8px rgb(30 27 75 / 6%); }
774
779
  .tr-preview-header { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 1rem; background: var(--header); color: var(--header-text); font-weight: 700; }
@@ -779,7 +784,7 @@ button.is-busy { opacity: 0.7; cursor: progress; }
779
784
  .tr-preview input { background: var(--surface); color: var(--text); border-color: var(--border); }
780
785
  .tr-preview .btn { background: var(--surface); color: var(--text); border-color: var(--border); border-radius: calc(var(--radius) * 0.75); font: inherit; }
781
786
  .tr-preview .btn-hot { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
782
- .tr-preview-iris .btn-hot { border-radius: 999px; }
787
+ .tr-preview-lavender .btn-hot { border-radius: 999px; }
783
788
 
784
789
  /* icon picker (forms.ts iconPicker) */
785
790
  .icon-picker > summary { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 34px; padding: 0.3rem 0.6rem; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; list-style: none; background: var(--surface); }
@@ -872,3 +877,26 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
872
877
  .gal-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
873
878
  .gal-foot form { margin: 0; }
874
879
  .ab-group select { min-width: 140px; }
880
+
881
+ /* (sprint 73) the Create Application wizard on one page */
882
+ .create-sources { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 0.5rem; margin: 0.75rem 0 1rem; }
883
+ .create-source { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); text-decoration: none; }
884
+ .create-source:hover, .create-source:focus-visible { border-color: var(--accent); }
885
+ .create-source .icon { color: var(--accent); margin-top: 0.15rem; }
886
+ .create-source span { display: grid; gap: 0.1rem; min-width: 0; overflow-wrap: anywhere; }
887
+ .create-app .region + form, .create-app form .region { margin-top: 1rem; }
888
+ .aw-pages { list-style: none; margin: 0 0 0.5rem; padding: 0; display: grid; gap: 0.4rem; }
889
+ .aw-pages li { display: grid; grid-template-columns: minmax(10rem, 1fr) minmax(12rem, 16rem); gap: 0.15rem 0.75rem; align-items: center; padding: 0.35rem 0; border-bottom: 1px solid var(--border); }
890
+ .aw-pages li > small, .aw-pages li > .muted:last-child { grid-column: 1 / -1; }
891
+ .aw-page-kind { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 600; overflow-wrap: anywhere; min-width: 0; }
892
+ @media (max-width: 560px) { .aw-pages li { grid-template-columns: 1fr; } }
893
+ .aw-features { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0 1rem; }
894
+ .aw-advanced > summary { cursor: pointer; list-style: none; }
895
+ .aw-advanced > summary::-webkit-details-marker { display: none; }
896
+ .aw-advanced .region-body { padding: 1rem; }
897
+ /* the overlay while a long form (Create application) is being processed */
898
+ .progress-overlay { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; background: rgb(0 0 0 / 35%); }
899
+ .progress-box { display: flex; align-items: center; gap: 0.75rem; padding: 1rem 1.25rem; border-radius: var(--radius); background: var(--surface); color: var(--text); box-shadow: 0 8px 24px rgb(0 0 0 / 25%); }
900
+ .progress-spinner { width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); animation: progress-spin 0.8s linear infinite; }
901
+ @keyframes progress-spin { to { transform: rotate(360deg); } }
902
+ @media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
package/public/builder.js CHANGED
@@ -859,3 +859,34 @@ document.addEventListener('click', async (e) => {
859
859
  show(e.target.closest?.('.pe [data-prop]'));
860
860
  });
861
861
  })();
862
+
863
+ // (sprint 73) a proposed value is selected on the first focus, so typing replaces it (data-select-once)
864
+ document.querySelectorAll('input[data-select-once]').forEach((el) => {
865
+ el.addEventListener('focus', () => {
866
+ el.select();
867
+ // the click that focused it would put the caret back: keep the selection
868
+ el.addEventListener('mouseup', (e) => e.preventDefault(), { once: true });
869
+ }, { once: true });
870
+ });
871
+
872
+ // (sprint 73) a form that takes a while (data-progress="Creating …"): an overlay with the message while the server works
873
+ document.addEventListener('submit', (e) => {
874
+ const form = e.target.closest?.('form[data-progress]');
875
+ if (!form || e.defaultPrevented || !form.checkValidity()) return;
876
+ const overlay = document.createElement('div');
877
+ overlay.className = 'progress-overlay';
878
+ overlay.setAttribute('role', 'status');
879
+ overlay.setAttribute('aria-live', 'polite');
880
+ const box = document.createElement('div');
881
+ box.className = 'progress-box';
882
+ const spin = document.createElement('span');
883
+ spin.className = 'progress-spinner';
884
+ spin.setAttribute('aria-hidden', 'true');
885
+ const text = document.createElement('span');
886
+ text.textContent = form.dataset.progress;
887
+ box.append(spin, text);
888
+ overlay.append(box);
889
+ document.body.append(overlay);
890
+ // back from the browser's cache (the back button): no overlay
891
+ window.addEventListener('pageshow', (ev) => { if (ev.persisted) overlay.remove(); }, { once: true });
892
+ });
@@ -21,7 +21,7 @@ import type { Session } from '../session.ts';
21
21
  import { MAX_MB, preview, resultHtml } from './dataload.ts';
22
22
  import { buildAppFromSheets, MAX_SHEETS, parseBook, sheetsForm, type BuiltBook, type NamedSheet } from './appsheets.ts';
23
23
  import { appWizardRoutes, PASTED } from './appwizard.ts';
24
- import { checkNewApp, createApp, createAppError, reservedSchema, type CheckedApp } from './newapp.ts';
24
+ import { finishGeneratedApp, checkNewApp, createApp, createAppError, reservedSchema, type CheckedApp } from './newapp.ts';
25
25
  import { currentWorkspace } from './workspaces.ts';
26
26
  import { BASE, csrf, developer, input, region, select, send, shell, type Body, type Req } from './ui.ts';
27
27
 
@@ -149,9 +149,9 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
149
149
  const generate = async (kind: string, page: number, options: Record<string, unknown>) => {
150
150
  await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, kind, T, page, JSON.stringify(options)]);
151
151
  };
152
- const label = (await c.query(`select meta.wizard_label($1) as l`, [tableName])).rows[0].l as string;
153
- await generate('report_form', 2, { form_page: 3, label });
154
- pages.push({ page: 2, label }, { page: 3, label: `${label} form` });
152
+ const { label, singular } = (await c.query('select meta.wizard_table_label($1::regclass) as label, meta.wizard_table_label($1::regclass, false) as singular', [T])).rows[0] as { label: string; singular: string };
153
+ await generate('report_form', 2, { form_page: 3 });
154
+ pages.push({ page: 2, label }, { page: 3, label: `${singular} form` });
155
155
  let next = 4;
156
156
  // the statistics tell which columns group the rows: a few values, repeated (not a name per row)
157
157
  const rows = result.inserted;
@@ -178,6 +178,7 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
178
178
  pages.push({ page: next++, label: `Faceted search (${facets.join(', ')})` });
179
179
  }
180
180
  }
181
+ await finishGeneratedApp(c, app.id, { description: checked.description });
181
182
  return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, table: T, result, pages, notes };
182
183
  });
183
184
  }
@@ -19,7 +19,7 @@ import {
19
19
  type Sheet,
20
20
  } from '../dataload.ts';
21
21
  import { preview } from './dataload.ts';
22
- import { createApp, createAppError, type CheckedApp } from './newapp.ts';
22
+ import { finishGeneratedApp, createApp, createAppError, type CheckedApp } from './newapp.ts';
23
23
  import { groupColumn } from './appfromfile.ts';
24
24
  import { region, type Body } from './ui.ts';
25
25
 
@@ -278,14 +278,21 @@ interface Catalog {
278
278
  * A dashboard page: a chart per table (at most six) with the number of rows per
279
279
  * parent row (the first foreign key), or per value of a text, yes/no or date
280
280
  * column whose values repeat (from the statistics). Null when no table has one.
281
+ * (097) Tables with row level security are left out (the application's role may
282
+ * see none of their rows: an empty chart); the charts are titled "Employees by
283
+ * Manager", and the Dashboard comes right after Home in the navigation menu.
281
284
  */
282
- export async function addDashboard(c: Client, alias: string, tables: string[], page: number): Promise<{ page: number; label: string } | null> {
285
+ export async function addDashboard(c: Client, alias: string, tables: string[], page: number, skipped: string[] = []): Promise<{ page: number; label: string } | null> {
283
286
  const picks: { table: string; group: string }[] = [];
284
287
  for (const t of tables) {
285
288
  if (picks.length >= MAX_CHARTS) break;
289
+ const rel = (await c.query('select relrowsecurity as rls, greatest(reltuples, 0) as n from pg_class where oid = $1::regclass', [t])).rows[0];
290
+ if (rel.rls) {
291
+ skipped.push(t);
292
+ continue;
293
+ }
286
294
  const catalog = (await c.query<Catalog>('select column_name, kind, is_pk, distinct_values, fk_table::text from meta.wizard_catalog($1::regclass)', [t])).rows;
287
- const rows = Number((await c.query('select greatest(reltuples, 0) as n from pg_class where oid = $1::regclass', [t])).rows[0].n);
288
- const group = catalog.find((x) => x.fk_table && !x.is_pk)?.column_name ?? groupColumn(catalog, rows);
295
+ const group = catalog.find((x) => x.fk_table && !x.is_pk)?.column_name ?? groupColumn(catalog, Number(rel.n));
289
296
  if (group) picks.push({ table: t, group });
290
297
  }
291
298
  if (!picks.length) return null;
@@ -295,7 +302,7 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
295
302
  JSON.stringify({ label: 'Dashboard', icon: 'chart', nav, label_column: group, function: 'count', value_column: null }),
296
303
  ]);
297
304
  const title = async (table: string, group: string) =>
298
- (await c.query(`select meta.wizard_label(relname) || ' per ' || lower(meta.wizard_label(regexp_replace($2, '_(id|no|code)$', ''))) as t from pg_class where oid = $1::regclass`, [table, group])).rows[0].t as string;
305
+ (await c.query(`select meta.wizard_table_label($1::regclass) || ' by ' || meta.wizard_column_label($1::regclass, $2) as t`, [table, group])).rows[0].t as string;
299
306
  const dash = (await generate(picks[0].table, picks[0].group, page, true)).rows[0].id;
300
307
  await c.query('update meta.region set title = $2 where page_id = $1', [dash, await title(picks[0].table, picks[0].group)]);
301
308
  for (const [n, p] of picks.slice(1).entries()) {
@@ -306,7 +313,13 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
306
313
  await c.query('delete from meta.page where id = $1', [tmp]);
307
314
  }
308
315
  if (picks.length > 1) await c.query('update meta.region set columns = 6 where page_id = $1', [dash]);
309
- return { page, label: `Dashboard (${picks.map((p) => `${p.table.replace(/^.*\./, '')} per ${p.group}`).join(', ')})` };
316
+ // right after Home (the first top-level entry)
317
+ await c.query(
318
+ `update meta.nav_entry e set seq = coalesce((select min(seq) from meta.nav_entry h where h.app_id = e.app_id and h.parent_id is null and h.target_page = a.home_page), 0) + 1
319
+ from meta.app a where a.alias = $1 and e.app_id = a.id and e.parent_id is null and e.target_page = $2`,
320
+ [alias, page],
321
+ );
322
+ return { page, label: `Dashboard (${(await Promise.all(picks.map((p) => title(p.table, p.group)))).join(', ')})` };
310
323
  }
311
324
 
312
325
  /** Create the application, the tables, the rows, the foreign keys and the pages, in one transaction. */
@@ -371,9 +384,9 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
371
384
  let next = 2;
372
385
  for (const p of plans) {
373
386
  const T = qualified.get(p.k)!;
374
- const label = (await c.query('select meta.wizard_label($1) as l', [p.table])).rows[0].l as string;
375
- await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, 'report_form', T, next, JSON.stringify({ form_page: next + 1, label })]);
376
- pages.push({ page: next, label }, { page: next + 1, label: `${label} form` });
387
+ const { label, singular } = (await c.query('select meta.wizard_table_label($1::regclass) as label, meta.wizard_table_label($1::regclass, false) as singular', [T])).rows[0] as { label: string; singular: string };
388
+ await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb)', [checked.alias, 'report_form', T, next, JSON.stringify({ form_page: next + 1 })]);
389
+ pages.push({ page: next, label }, { page: next + 1, label: `${singular} form` });
377
390
  next += 2;
378
391
  }
379
392
  if (b.chart === 'true') {
@@ -381,6 +394,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
381
394
  if (dash) pages.push(dash);
382
395
  else notes.push('No dashboard: no table has a foreign key or a text, yes/no or date column with a few values that repeat, to count the rows by.');
383
396
  }
397
+ await finishGeneratedApp(c, app.id, { description: checked.description });
384
398
  return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, tables, foreignKeys, pages, notes };
385
399
  });
386
400
  }