pgkiln 0.88.0 → 0.89.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.
@@ -225,9 +225,16 @@ department. Every installation has the **Default** workspace, which holds the ap
225
225
  - **New applications** (Create, from a file, pasted data, existing tables, a blueprint) and **imports** go into the
226
226
  current workspace; a working copy stays in its main application's workspace. Imports made with the command line
227
227
  (`pgkiln import`) go into Default. Exports don't carry the workspace.
228
- - **Workspace utilities**: tiles for users and sign-in, application groups, the App Gallery, developers, the SQL
229
- Workshop, the dashboard, [static workspace files, remote servers and web credentials](#static-workspace-files-remote-servers-and-web-credentials) (and, for administrators, instance settings, workspaces, AI and mail services and the
230
- installation), with **Cross Application Reports** in the side column (APEX: Cross Application Reports), each over
228
+ - **Workspace utilities** (the rail's **Administration**, `/builder/utilities`; UX-4): the utilities in sections with a
229
+ **filter** field at the top: *People and sign-in* (users, developers, identity providers, LDAP directories, password
230
+ policy), *Connections* ([remote servers, web credentials](#static-workspace-files-remote-servers-and-web-credentials),
231
+ remote databases, REST source catalogs, mail services; for workspace administrators the mail queue and network
232
+ access), *Data and AI* (AI services, vector providers, UI defaults, static workspace files, icons), *Operations*
233
+ (application groups, backups, export and import, the dashboard, OpenTelemetry), the current **workspace** (for its
234
+ administrators: its page, [workspace message and environment banner](#workspace-message-and-environment-banner),
235
+ preferences, extension links, [workspace utilization](#workspace-utilization)) and the **instance** (for
236
+ administrators: instance settings, workspaces, [Database users](#database-users), the installation), then
237
+ **Cross Application Reports** (APEX: Cross Application Reports), each over
231
238
  every application of the current workspace: *Application attributes* (type, sign-in, availability, build status,
232
239
  debug level, language, database role), *Build options*, *Developer comments*, *Authentication* (sign-in methods,
233
240
  identity providers, LDAP, access, the login page, Forgot password), *Authorization schemes*, *Password items* and
@@ -247,7 +254,7 @@ its developers must not reach other teams' applications or data.
247
254
  ### Workspace message and environment banner
248
255
 
249
256
  Administrators and the workspace's administrators set them on the workspace's page (*Manage service*, or the
250
- Administration menu → *Workspace message and environment banner*; APEX: Define Workspace Message, Define Environment
257
+ Administration → *Workspace message and environment banner*; APEX: Define Workspace Message, Define Environment
251
258
  Banner): a **message** (plain text, up to 2000 characters) shown to the workspace's developers on the
252
259
  [Workspace home](#workspace-home) (where they can also edit it in place) and the App Builder home,
253
260
  and an **environment banner**: a label of up to 40 characters in one of eight colours, shown at the top of every
@@ -255,7 +262,7 @@ builder page while the workspace is current.
255
262
 
256
263
  ### Workspace preferences
257
264
 
258
- Administrators and the workspace's administrators set them on the workspace's page (Administration menu →
265
+ Administrators and the workspace's administrators set them on the workspace's page (Administration →
259
266
  *Workspace preferences*; APEX: Set Workspace Preferences):
260
267
 
261
268
  - **Enable the SQL Workshop**: off, every SQL Workshop page (the SQL Worksheet, the Object Browser, SQL scripts, Load and
@@ -349,7 +356,7 @@ says what was added, kept and replaced.
349
356
 
350
357
  ### Workspace utilization
351
358
 
352
- Administration menu → **Workspace utilization** (`/builder/workspaces/<id>/utilization`; APEX: Workspace
359
+ Administration → **Workspace utilization** (`/builder/workspaces/<id>/utilization`; APEX: Workspace
353
360
  Utilization), for administrators and the workspace's administrators: the number of applications, pages, developers,
354
361
  page views of the last 30 days and SQL scripts; per application its pages, regions, items, page views, users and the
355
362
  size of its static files; and the schemas the workspace's database roles use with their tables and storage.
@@ -462,11 +469,14 @@ longer sign in. Delete a workspace only when it is shared and empty; its logins
462
469
  Every builder page has the same frame, modelled on an IDE:
463
470
 
464
471
  - **The icon rail** on the left: Home (the [Workspace home](#workspace-home); the logo leads there too), App Builder,
465
- SQL Workshop, [App Gallery](#app-gallery), [Data Reporter](#data-reporter-datasets-and-reporting-applications), **Search** (on the
466
- pages of an application); at the bottom the **Administration** menu (Workspace utilities; for administrators of the
467
- current workspace *Manage service*: [workspace message and environment banner](#workspace-message-and-environment-banner),
468
- [workspace utilization](#workspace-utilization) and the workspace's page; Manage users and groups, Developers,
469
- Monitor activity, [Database users](#database-users) for administrators, Change my password), Help, and your account menu (the circle with your initials) with the
472
+ SQL Workshop, [App Gallery](#app-gallery), [Data Reporter](#data-reporter-datasets-and-reporting-applications),
473
+ Team Development and **Administration** (the workspace utilities, below); at the bottom Help, **Show labels** and your
474
+ account menu (the circle with your initials). (UX-3) The entry of the page you are on is marked, by the page's
475
+ address (an application's pages under App Builder; users, developers, workspaces, instance settings and the other
476
+ utilities under Administration). *Show labels* puts each entry's name next to its icon (*Hide labels* takes them
477
+ away; kept for your session); without them the name shows when you point at an icon or move to it with the
478
+ keyboard. The search is the field at the top of every page (**Ctrl+K**); an application's own search is its
479
+ **Search** tab. Your account menu has the
470
480
  **builder theme** (*Dark*, the default; *Light*; or *System*, which follows the operating system),
471
481
  Change password and Sign out. The theme is remembered on this device, also after signing out;
472
482
  it only changes the builder, not your applications.
@@ -548,6 +558,8 @@ names and help of a shared component's form), the links to this guide, and any S
548
558
  (sprint 109; APEX: the workspace home page) The page the builder opens after signing in, `/builder/home`, above the
549
559
  App Builder and the SQL Workshop. Everything on it is of the current [workspace](#workspaces), and it works without
550
560
  JavaScript. The [default-password banner](#signing-in-and-developer-accounts) stays on top until the password is changed.
561
+ (UX-13) For administrators, **Needs attention** under the heading lists what must not wait: failing
562
+ [lockout checks](#lockout-checks) and, with the release check on, a newer pgkiln (details in the System message).
551
563
 
552
564
  - **Tool cards**: App Builder (`/builder/apps`), SQL Workshop (not when the workspace's preferences turn it off),
553
565
  **Data Reporter** (when the server has the Data Reporter tool at `/builder/reporter`) and the [App Gallery](#app-gallery).
@@ -582,12 +594,13 @@ that may not reach the internet. Under the test runner the server never asks Git
582
594
  The App Builder's home page, `/builder/apps` (`/builder` shows it too, unless the workspace's
583
595
  [preferences](#workspace-preferences) send `/builder` to the Workspace home), is laid out like APEX's App Builder:
584
596
 
585
- - **Four tiles**: **Create** and **Import** (each on its own page), the workspace **Dashboard**
586
- (applications, pages, active accounts; page views, users, failed sign-ins and errors of the last
587
- 24 hours, and per application the last 7 days with a link to its Activity page) and
588
- **Workspace Utilities** (users, identity providers, LDAP directories, password policy,
589
- developers, the SQL Workshop, and for administrators [workspaces](#workspaces), AI services and the
590
- [installation log](#installation)). The list, the dashboard and Recent show the current workspace.
597
+ - **The header** (UX-5): the heading *App Builder*, **Create** (the page's one primary action), **Import** and a
598
+ **⋯** menu with the workspace **Dashboard** (applications, pages, active accounts; page views, users, failed
599
+ sign-ins and errors of the last 24 hours, and per application the last 7 days with a link to its Activity page)
600
+ and **Administration** (the [workspace utilities](#workspaces)). The list, the dashboard and Recent show the
601
+ current workspace. Every builder page follows the same pattern: one heading, one primary (accent) button,
602
+ the other actions beside it or in a ⋯ menu; an application's Pages tab has **Create page** as its primary action
603
+ in the application's header (other tabs: **Run**), and its Tasks list only what the page doesn't offer already.
591
604
  - **The applications**, as a report (application id, name, alias, pages, page views of the last 24
592
605
  hours, sign-in, last updated, **updated by**, **working copies**, and Edit / Run) or as cards. **Search** filters the list as you type
593
606
  (by name, alias or id; without JavaScript, press Enter). Click a column heading, or use
@@ -1072,6 +1085,13 @@ The page designer has three panes, as in APEX's Page Designer. From 1024 pixels
1072
1085
  side by side; on phones and portrait tablets they are tabs (**Tree**, **Layout**, **Properties**):
1073
1086
  choosing a component opens the Properties tab.
1074
1087
 
1088
+ (UX-2) From 1024 pixels wide the designer is fixed to the window: only the panes scroll, so the toolbar
1089
+ with **Save**, Undo / Redo and the page navigator stays in view, and the property editor's own save button
1090
+ stays at the bottom of its pane. **Ctrl+S** (Cmd+S) saves. A change not saved yet puts a dot on Save, and
1091
+ leaving the page with one asks first. On a window under 800 pixels high the gallery under the layout is only
1092
+ its tabs (*Regions*, *Items*, *Buttons*): choose one to open it, choose the open one to fold it again; the
1093
+ designer remembers which you prefer.
1094
+
1075
1095
  - **Left: the component tree**, with four tabs:
1076
1096
  - **Rendering**: the page, *Pre-Rendering* (branches, computations and processes that run
1077
1097
  before the page is shown), the regions by
@@ -1087,7 +1107,8 @@ choosing a component opens the Properties tab.
1087
1107
  application items), linking to Shared Components.
1088
1108
 
1089
1109
  Icons show which components have an authorization scheme, a condition or a build option; type icons are coloured
1090
- by kind (regions, items, buttons, dynamic actions, processes). Folders open and close
1110
+ by kind (regions, items, buttons, dynamic actions, processes). (UX-12) A component's name comes first: when the
1111
+ pane is narrow its type text is left out (the icon shows the type; hover a component for its name and type). Folders open and close
1091
1112
  with a click; with the keyboard, the arrow keys walk the tree (Left / Right close and open a
1092
1113
  folder), Enter opens a component.
1093
1114
 
@@ -1790,7 +1811,7 @@ sending mailboxes (an application access policy or RBAC for Applications).
1790
1811
 
1791
1812
  ### Mail queue
1792
1813
 
1793
- Administration menu → **Mail queue** (`/builder/mail-queue`; APEX 26.2: Administration → Mail queue): the delivery
1814
+ Administration → **Mail queue** (`/builder/mail-queue`; APEX 26.2: Administration → Mail queue): the delivery
1794
1815
  health of the mail queue, for administrators (every workspace, or one) and a workspace's administrators (the current
1795
1816
  workspace's applications only; other developers get 403). Filter by period (24 hours, 48 hours, 7 days), workspace and
1796
1817
  application:
@@ -1835,7 +1856,7 @@ applications off or send them to a collector of their own as well (through the a
1835
1856
 
1836
1857
  ### Network access
1837
1858
 
1838
- Administration menu → **Network access** (`/builder/network`; APEX 26.2: workspace network ACLs), for administrators
1859
+ Administration → **Network access** (`/builder/network`; APEX 26.2: workspace network ACLs), for administrators
1839
1860
  (any workspace, `?ws=`) and a workspace's administrators (their workspaces): which web services a workspace's
1840
1861
  applications may call. The installation's list (`PGKILN_REST_ALLOWED_HOSTS`, set in the server's environment, shown
1841
1862
  read-only; the private hosts to administrators only) applies to every workspace. A workspace can choose **Only the
@@ -2121,7 +2142,8 @@ others (Quick SQL, Query Builder, Load and Unload Data, Sample Data, DDL, Compar
2121
2142
 
2122
2143
  (sprint 96; Database Actions: the Launchpad) The home page starts with **Pinned & recently visited**: the pages you
2123
2144
  pinned (a tool, a database object from the Object Browser, a saved worksheet from its file menu; at most 24, per
2124
- workspace) and the SQL Workshop pages you opened last in this browser. Below, the tools are in three tabs,
2145
+ workspace) and the SQL Workshop pages you opened last in this browser (UX-14: each page once, the latest visit
2146
+ first, without the Launchpad itself or a page that is pinned already). Below, the tools are in three tabs,
2125
2147
  **Development**, **Administration** and **Monitoring**, each with a line about what it does and a pin; the **More**
2126
2148
  menu uses the same headings.
2127
2149
 
@@ -2133,6 +2155,12 @@ the SQL Workshop's connection may use. Without JavaScript the header's field ope
2133
2155
  - **SQL Worksheet** (`/builder/sql/worksheet`, sprint 91; Database Actions: SQL; was SQL Commands, whose address
2134
2156
  `/builder/sql/commands` opens the same page): one screen with a **navigator** on the left, the **editor** with a
2135
2157
  toolbar, **output tabs** below and a **status bar**.
2158
+ - (UX-1) The **toolbar is one row**: the worksheet's file menu, **Run Statement**, **Run Script**, **Explain Plan**
2159
+ and **Autotrace** (icons with their name as a tooltip when the window is narrow), **Suggest** and **Check**, the
2160
+ commit mode, fullscreen and a **⋯ menu** with *Continue on error*, Format, Convert case, Find, Load data, Clear
2161
+ and the keyboard shortcuts. **Run as** is in the status bar (its tooltip says who the statements run as).
2162
+ The editor takes about half of the window and the output the other half (drag the splitter; the size is
2163
+ remembered); before the first run the output is only its row of tabs, and choosing a tab opens it.
2136
2164
  - The navigator has a schema (the same one as the Object Browser), an object type (*All Objects*, tables, views,
2137
2165
  materialized views, indexes, sequences, types, functions, procedures, triggers, policies) and a search field.
2138
2166
  A table or view opens to its columns with their types (a key marks the primary key); drag it into the editor
@@ -2217,11 +2245,11 @@ the SQL Workshop's connection may use. Without JavaScript the header's field ope
2217
2245
  remembered for the session; the history and the log keep the statement as written.
2218
2246
  - **Format** (**Ctrl + F7**) lays out the selection or the whole worksheet: a clause per line, the columns of a select
2219
2247
  list and the conditions of a WHERE one per line, joins and subqueries indented, keywords in lower or UPPER case
2220
- (chosen in the **?** menu). Strings, comments and `$$` bodies stay as they are. **Convert case** (**Ctrl + B**)
2248
+ (chosen in the **⋯** menu). Strings, comments and `$$` bodies stay as they are. **Convert case** (**Ctrl + B**)
2221
2249
  cycles the selection (or the word at the cursor) through UPPER, lower and Initial caps. **Find and replace**
2222
2250
  (**Ctrl + F**, **Ctrl + H**) with a regular expression or exact case, **Go to line** (**Ctrl + G**), and
2223
- **Ctrl + /** comments the selected lines out or in. The **?** menu lists every key; **F1** opens this help. The
2224
- upload button opens Load Data for the navigator's schema.
2251
+ **Ctrl + /** comments the selected lines out or in. The **⋯** menu lists every key; **F1** opens this help.
2252
+ *Load data* in the same menu opens Load Data for the navigator's schema.
2225
2253
  - Each statement commits on its own (**Autocommit**, like Database Actions). Statements run as the **owner connection** (an isolated workspace's
2226
2254
  developer login for its developers), on a connection of their own that is closed afterwards, so `SET ROLE` or
2227
2255
  `SET search_path` doesn't leak. **Run as** picks an application of your workspace instead: the statements then
@@ -2414,7 +2442,7 @@ the SQL Workshop's connection may use. Without JavaScript the header's field ope
2414
2442
  connections, memory, row security), the installed extensions and the schemas with their size.
2415
2443
  - **SQL Monitor** (administrators only): see [SQL Monitor](#sql-monitor) below (it replaced the Database Monitor;
2416
2444
  the old address `/builder/sql/utilities/monitor` redirects).
2417
- - **Database Users** (administrators only; Administration menu → *Database users*, `/builder/db-users`; sprint 99,
2445
+ - **Database Users** (administrators only; Administration → *Database users*, `/builder/db-users`; sprint 99,
2418
2446
  Database Actions: Database Users): see [Database users](#database-users).
2419
2447
  - **Schema Export / Import** (administrators only; `/builder/sql/data-pump`; sprint 103, Database Actions: Data
2420
2448
  Pump): see [Schema export and import](#schema-export-and-import).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.88.0",
3
+ "version": "0.89.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
@@ -444,7 +444,9 @@ body.ide {
444
444
  .pd > .tab-panel > .pd-pane-title { display: none; }
445
445
  /* with script the designer fills the window and each pane scrolls on its own */
446
446
  .js .ide-full .ide-main { height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
447
- .js .ide-full .ide-work { flex: 1 1 auto; min-height: 0; }
447
+ /* (UX-2) the work area is the containing block and clips: nothing inside (a screen-reader heading placed
448
+ absolutely, a pane's overflow) can make the page itself scroll */
449
+ .js .ide-full .ide-work { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; }
448
450
  .js .ide-full .pd { height: 100%; min-height: 0; }
449
451
  .js .pd-pane { min-height: 0; overflow: auto; }
450
452
  .js .pd-center { display: flex; flex-direction: column; overflow: hidden; }
@@ -484,8 +486,9 @@ body.ide {
484
486
  }
485
487
  .pd-node .icon { width: 16px; height: 16px; color: var(--muted); }
486
488
  a.pd-node:hover { background: var(--surface-3); }
487
- .pd-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
488
- .pd-note { margin-left: auto; padding-left: 8px; max-width: 45%; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-size: 11.5px; }
489
+ /* (UX-12) the name keeps its room: the type gives way first (the icon tells it, the tooltip names it) */
490
+ .pd-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; flex: 0 1 auto; }
491
+ .pd-note { margin-left: auto; padding-left: 8px; max-width: 45%; min-width: 0; flex: 0 1000 auto; overflow: hidden; text-overflow: clip; white-space: nowrap; color: var(--muted); font-size: 11.5px; }
489
492
  .pd-folder { color: var(--muted); font-weight: 600; cursor: default; }
490
493
  .pd-tag { display: inline-flex; }
491
494
  .pd-tag .icon { width: 13px; height: 13px; color: var(--warn); }
@@ -1671,3 +1674,54 @@ a.cp-type:hover, a.cp-type:focus-visible { border-color: var(--accent); }
1671
1674
  .api-client-actions select { width: auto; min-height: 0; padding-block: 0.2rem; font-size: 0.8125rem; }
1672
1675
  .api-client-actions .inline-form { display: flex; gap: 6px; align-items: center; }
1673
1676
  .api-clients td.actions { text-align: end; }
1677
+
1678
+ /* (UX-2) Page Designer on a short window: the gallery folded to its tabs (builder.js), Save at hand */
1679
+ @media (min-width: 1024px) {
1680
+ .js .pd-gallery.is-folded { height: auto; min-height: 0; overflow: hidden; }
1681
+ .js .pd-gallery.is-folded .tab-panel { display: none; }
1682
+ }
1683
+ .tb-save.is-dirty { position: relative; }
1684
+ .tb-save.is-dirty::after { content: ""; position: absolute; top: 3px; right: 3px; width: 7px; height: 7px; border-radius: 50%; background: var(--hot); }
1685
+ #pd-p-props .component-form > .buttons { position: sticky; bottom: 0; z-index: 1; margin: 0; padding: 8px 0; background: var(--surface); border-top: 1px solid var(--border); }
1686
+ .pd-note.is-cut { display: none; }
1687
+
1688
+ /* (UX-3) the rail with labels next to its icons (the toggle at its bottom, kept in the session) */
1689
+ .rail-toggle { width: 100%; border: 0; background: none; cursor: pointer; font: inherit; }
1690
+ @media (min-width: 900px) {
1691
+ .ide.rail-labels { --rail-w: 196px; }
1692
+ .rail-labels .rail-list { padding: 0 10px; }
1693
+ .rail-labels .rail-link { display: flex; justify-content: flex-start; gap: 12px; padding: 0 12px; }
1694
+ .rail-labels .rail-link .rail-label { position: static; opacity: 1; transform: none; padding: 0; background: none; color: inherit;
1695
+ box-shadow: none; font-size: 13.5px; font-weight: 500; pointer-events: auto; }
1696
+ .rail-labels .rail-logo { margin-left: 18px; }
1697
+ .rail-labels .rail-user { justify-content: flex-start; padding-left: 14px; }
1698
+ }
1699
+
1700
+ /* (UX-4) Administration: the workspace utilities in sections of compact tiles, with a filter (builder.js) */
1701
+ .adm { max-width: 1180px; }
1702
+ .adm .title-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
1703
+ .adm-filter input { width: min(320px, 100%); }
1704
+ .adm-lede { max-width: 75ch; }
1705
+ .adm-section { margin-top: 22px; }
1706
+ .adm-section h2 { font-size: 15px; margin: 0 0 8px; color: var(--text-2, var(--muted)); text-transform: none; }
1707
+ .adm-tiles { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: 8px; }
1708
+ .adm-tile { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius);
1709
+ background: var(--surface); color: var(--text); text-decoration: none; transition: border-color var(--dur) var(--ease), background-color var(--dur) var(--ease); }
1710
+ .adm-tile:hover, .adm-tile:focus-visible { border-color: var(--hot); background: var(--surface-2); }
1711
+ .adm-tile-head { display: flex; align-items: center; gap: 8px; }
1712
+ .adm-tile-head .icon { width: 16px; height: 16px; color: var(--hot); flex: none; }
1713
+ .adm-tile-text { color: var(--muted); font-size: 12.5px; line-height: 1.4; }
1714
+ .adm-reports { columns: 2 260px; }
1715
+
1716
+ /* (UX-5) a builder page's header: one heading, one primary (accent) action, secondary actions and a ⋯ menu */
1717
+ .page-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; margin: 0 0 16px; }
1718
+ .page-head h1 { margin: 0; font-size: 22px; }
1719
+ .page-head-sub { margin: 2px 0 0; }
1720
+ .page-head-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
1721
+ .page-head-actions .btn { display: inline-flex; align-items: center; gap: 6px; }
1722
+ .page-head-actions .icon { width: 15px; height: 15px; }
1723
+ /* (UX-13) the Workspace home's "Needs attention" list for administrators, above the tools */
1724
+ .wh-attention { display: block; margin: 0 0 16px; }
1725
+ .wh-attention h2 { margin: 0 0 4px; font-size: 14px; }
1726
+ .wh-attention ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
1727
+ .wh-attention .icon { width: 15px; height: 15px; vertical-align: -2px; }
package/public/builder.js CHANGED
@@ -508,6 +508,38 @@
508
508
  const dirty = () => save.classList.add('is-dirty');
509
509
  form.addEventListener('input', dirty);
510
510
  form.addEventListener('change', dirty);
511
+ // (UX-2) Ctrl+S saves; leaving with unsaved changes asks first (saving doesn't)
512
+ let submitting = false;
513
+ form.addEventListener('submit', () => (submitting = true));
514
+ document.addEventListener('keydown', (e) => {
515
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 's' && !e.target.closest?.('.ws, dialog')) {
516
+ e.preventDefault();
517
+ form.requestSubmit();
518
+ }
519
+ });
520
+ window.addEventListener('beforeunload', (e) => {
521
+ if (!submitting && save.classList.contains('is-dirty')) {
522
+ e.preventDefault();
523
+ e.returnValue = '';
524
+ }
525
+ });
526
+ }
527
+ // (UX-2) on a short window the gallery is only its tabs: choosing a tab opens it, choosing the open one folds it
528
+ const gallery = document.querySelector('.pd-gallery');
529
+ if (gallery) {
530
+ const short = window.matchMedia('(max-height: 800px)');
531
+ const apply = () => gallery.classList.toggle('is-folded', short.matches && store.get('gallery') !== 'open');
532
+ apply();
533
+ short.addEventListener('change', apply);
534
+ gallery.addEventListener('click', (e) => {
535
+ const t = e.target.closest('[role=tab]');
536
+ if (!t || !short.matches) return;
537
+ const folded = gallery.classList.contains('is-folded');
538
+ const wasActive = t.getAttribute('aria-selected') === 'true';
539
+ const open = folded || !wasActive;
540
+ store.set('gallery', open ? 'open' : null);
541
+ gallery.classList.toggle('is-folded', !open);
542
+ }, true); // before the tabs switch, so the tab that was open is known
511
543
  }
512
544
  }
513
545
 
@@ -1011,7 +1043,41 @@
1011
1043
  setupQueryCanvas();
1012
1044
  setupThemePreview();
1013
1045
  setupTabs();
1046
+ // (UX-4) Administration: filter the utilities by their text; sections without a match hide
1047
+ const admQ = document.querySelector('[data-adm-filter]');
1048
+ if (admQ) {
1049
+ admQ.closest('.adm-filter').hidden = false;
1050
+ const page = admQ.closest('.adm');
1051
+ const none = page.querySelector('.adm-none');
1052
+ admQ.addEventListener('input', () => {
1053
+ const q = admQ.value.trim().toLowerCase();
1054
+ let any = false;
1055
+ for (const sec of page.querySelectorAll('.adm-section')) {
1056
+ let shown = 0;
1057
+ for (const li of sec.querySelectorAll('li')) {
1058
+ const hit = !q || li.textContent.toLowerCase().includes(q);
1059
+ li.hidden = !hit;
1060
+ if (hit) shown++;
1061
+ }
1062
+ sec.hidden = !shown;
1063
+ if (shown) any = true;
1064
+ }
1065
+ none.hidden = any;
1066
+ });
1067
+ }
1014
1068
  for (const tree of document.querySelectorAll('.pd-tree')) setupTree(tree);
1069
+ // (UX-12) a node's type text only when it fits whole: the name comes first (the icon and tooltip tell the type)
1070
+ const fitNotes = () => {
1071
+ for (const n of document.querySelectorAll('.pd-tree .pd-note')) {
1072
+ n.classList.remove('is-cut');
1073
+ if (n.scrollWidth > n.clientWidth + 1) n.classList.add('is-cut');
1074
+ }
1075
+ };
1076
+ fitNotes();
1077
+ if ('ResizeObserver' in window) {
1078
+ const pane = document.querySelector('.pd-tree')?.closest('.pd-pane');
1079
+ if (pane) new ResizeObserver(() => requestAnimationFrame(fitNotes)).observe(pane);
1080
+ }
1015
1081
  for (const tree of document.querySelectorAll('.pd-tree[data-tree-menu]')) setupTreeMenu(tree);
1016
1082
  setupProperties();
1017
1083
  setupCanvas();
package/public/palette.js CHANGED
@@ -18,15 +18,22 @@
18
18
  if (/^\/builder\/sql\/./.test(path) && !/\/(panel|navigator|action)$/.test(path)) {
19
19
  const title = document.title.replace(/ · Builder$/, '');
20
20
  const href = path + location.search;
21
- const list = (store.get('sw.recent') || []).filter((r) => r && r.href !== href);
21
+ // (UX-14) one entry per page (its path): opened again with other parameters, the newest wins
22
+ const list = (store.get('sw.recent') || []).filter((r) => r && typeof r.href === 'string' && r.href.split('?')[0] !== path);
22
23
  list.unshift({ href, title });
23
24
  store.set('sw.recent', list.slice(0, 8));
24
25
  }
25
26
  const recent = document.querySelector('[data-lp-recent]');
26
27
  if (recent) {
27
28
  const list = store.get('sw.recent') || [];
29
+ // (UX-14) not the page you are on, not what is pinned already, each path once
30
+ const pinned = new Set([...document.querySelectorAll('.lp-pins .lp-chips:not([data-lp-recent]) a[href]')].map((a) => a.getAttribute('href').split('?')[0]));
31
+ const seen = new Set([path]);
28
32
  for (const r of list) {
29
33
  if (typeof r?.href !== 'string' || !r.href.startsWith('/builder/')) continue;
34
+ const p = r.href.split('?')[0];
35
+ if (seen.has(p) || pinned.has(p)) continue;
36
+ seen.add(p);
30
37
  const li = document.createElement('li');
31
38
  const a = document.createElement('a');
32
39
  a.href = r.href;
@@ -3,7 +3,7 @@
3
3
  screens: navigator | editor over output tabs, filling the window, with a status bar at the bottom. */
4
4
  .ws-page { display: flex; flex-direction: column; padding: 8px 10px 0; background: var(--bg); }
5
5
  .ws-page > .sw-tabs { margin-bottom: 6px; }
6
- .ws { --ws-nav: 280px; --ws-out: 42vh; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; background: var(--surface);
6
+ .ws { --ws-nav: 280px; --ws-out: 45%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; background: var(--surface);
7
7
  border: 1px solid var(--border); border-radius: var(--radius); min-width: 0; }
8
8
  .ws-default { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); border: 0; padding: 0; }
9
9
  .ws-nav { display: flex; flex-direction: column; min-width: 0; border-bottom: 1px solid var(--border); background: var(--surface-2); }
@@ -32,7 +32,7 @@
32
32
  .ws-obj-actions { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 10px 8px 34px; }
33
33
  .ws-obj-actions .btn { font-size: 11px; padding: 1px 6px; }
34
34
 
35
- .ws-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
35
+ .ws-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; container-type: inline-size; }
36
36
  .ws-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 6px 8px; border-bottom: 1px solid var(--border); }
37
37
  .ws-toolbar .btn { display: inline-flex; align-items: center; gap: 4px; }
38
38
  .ws-toolbar .icon { width: 15px; height: 15px; }
@@ -99,12 +99,16 @@
99
99
  .js .ide-full .ws-editor .ce { flex: 1 1 auto; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; border: 0; border-radius: 0; }
100
100
  .js .ide-full .ws-editor .ce-main { min-height: 0; }
101
101
  .js .ide-full .ws-editor .ce textarea.ce-input { height: 100%; min-height: 0; resize: none; }
102
- .js .ide-full .ws-output { flex: 0 0 var(--ws-out); }
102
+ .js .ide-full .ws-output { flex: 0 1 var(--ws-out); }
103
+ /* (UX-1) the editor keeps at least ten lines; an empty output is only its tab strip until a run */
104
+ .js .ide-full .ws-editor { min-height: 13.5rem; }
105
+ .js .ide-full .ws-output[data-empty] { flex: 0 0 auto; }
106
+ .js .ide-full .ws-output[data-empty] .ws-pane { display: none; }
103
107
  .js .ide-full .ws-pane { flex: 1 1 auto; }
104
108
  .js .ide-full .ws-nav { overflow: hidden; }
105
109
  }
106
110
  @media (max-width: 899px) {
107
- .ws-toolbar .btn span:not(.sr-only) { display: none; }
111
+ .ws-toolbar .btn span:not(.sr-only):not(.ws-dots) { display: none; }
108
112
  .ws-toolbar .btn-hot span:not(.sr-only) { display: inline; }
109
113
  }
110
114
  /* fullscreen: only the editor and its toolbar */
@@ -299,3 +303,34 @@ details.menu.obj-menu > summary::before, details.menu.obj-menu > summary::after
299
303
  .ws-txn-badge[data-state="open"] { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); background: color-mix(in srgb, var(--warn) 10%, transparent); font-weight: 600; }
300
304
  .ws-txn-badge[data-state="aborted"] { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); font-weight: 600; }
301
305
  .ws-txn-alert { margin: 6px 8px; }
306
+
307
+ /* (UX-1) one toolbar row: the run buttons, Suggest / Check, Commit mode, fullscreen and a ⋯ menu with the rest */
308
+ /* it fits on one row; an open transaction (badge, Commit, Rollback) may take a second one */
309
+ .ws-toolbar > * { flex: none; }
310
+ /* the commit mode's options name it (Autocommit / Transaction): no label beside the select */
311
+ .ws-toolbar .ws-txn .ws-runas > span { display: none; }
312
+ @container (max-width: 950px) {
313
+ .ws-toolbar .btn:not(.btn-hot) > span:not(.sr-only):not(.ws-dots) { display: none; }
314
+ }
315
+ @container (max-width: 640px) {
316
+ .ws-toolbar > * { flex: 0 1 auto; min-width: 0; }
317
+ .ws-toolbar .ws-txn { flex-wrap: wrap; }
318
+ }
319
+ .ws-more .menu-panel { min-width: 18rem; max-height: calc(100dvh - 190px); overflow: auto; }
320
+ /* with script the worksheet tabs name the file: the file menu is its icon */
321
+ .js .ws-toolbar .ws-file-name { display: none; }
322
+ .ws-more-tools { display: flex; flex-direction: column; gap: 2px; }
323
+ .ws-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border: 0; border-radius: 6px; background: none;
324
+ color: var(--text); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
325
+ .ws-menu-item:hover, .ws-menu-item:focus-visible { background: var(--surface-2); }
326
+ .ws-menu-item .icon { width: 15px; height: 15px; }
327
+ .ws-menu-item kbd { margin-left: auto; font-size: 11.5px; color: var(--muted); }
328
+ .ws-more-tools .ws-check { padding: 6px 8px; }
329
+ .ws-more-head { display: block; margin-bottom: 4px; }
330
+ /* the code editor's hint bar under the editor: only while it has something to say (Check's result) */
331
+ .ws-editor .ce-bar:not(:has(.ce-msg:not(:empty))) { display: none; }
332
+ .ws-editor .ce-bar :is(.ce-hint, .ce-suggest, .ce-check) { display: none; }
333
+ /* (UX-1) who the statements run as: a chip at the right of the status bar */
334
+ .ws-status-run { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1 1 auto; }
335
+ .ws-runas-chip { flex: none; display: inline-flex; align-items: center; gap: 6px; }
336
+ .ws-runas-chip select { padding: 1px 22px 1px 6px; min-height: 0; font-size: 12.5px; }
@@ -104,11 +104,15 @@
104
104
  out.dataset.active = key;
105
105
  };
106
106
  show(out.dataset.active || 'result');
107
+ // (UX-1) before a run the output is only its tab strip; choosing a tab opens it
107
108
  out.querySelector('[role=tablist]').addEventListener('click', (e) => {
108
109
  const t = e.target.closest('[role=tab]');
109
- if (t) show(t.dataset.wsTab);
110
+ if (!t) return;
111
+ delete out.dataset.empty;
112
+ show(t.dataset.wsTab);
110
113
  });
111
114
  out.querySelector('[role=tablist]').addEventListener('keydown', (e) => {
115
+ delete out.dataset.empty;
112
116
  const k = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
113
117
  const next = { ArrowRight: k + 1, ArrowLeft: k - 1, Home: 0, End: tabs.length - 1 }[e.key];
114
118
  if (next === undefined) return;
@@ -552,6 +556,16 @@
552
556
  (k === 'h' ? fr : k === 'g' ? bar.querySelector('[data-ws-goto]') : fq).focus();
553
557
  };
554
558
  form.querySelector('[data-ws-find]').addEventListener('click', () => openFind('f'));
559
+ // (UX-1) Suggest and Check in the toolbar: the code editor's own buttons (its bar below the editor stays hidden
560
+ // until it has a message, such as Check's result)
561
+ const ceButton = (cls) => form.querySelector(`.ws-editor .${cls}`);
562
+ for (const [attr, cls] of [['data-ws-suggest', 'ce-suggest'], ['data-ws-check', 'ce-check']]) {
563
+ const btn = form.querySelector(`[${attr}]`);
564
+ btn?.addEventListener('click', () => ceButton(cls)?.click());
565
+ const sync = () => { if (btn) btn.hidden = !ceButton(cls) || ceButton(cls).hidden; };
566
+ sync();
567
+ window.addEventListener('load', sync);
568
+ }
555
569
  form.querySelector('[data-ws-format]').addEventListener('click', format);
556
570
  form.querySelector('[data-ws-case]').addEventListener('click', convertCase);
557
571
  bar.addEventListener('keydown', (e) => {
@@ -123,7 +123,7 @@ export async function designerRoutes(app: FastifyInstance) {
123
123
  // (sprint 90) a commented-out component: struck through in the tree, Uncomment in its menu
124
124
  const commented = (r: any) => (r.build_option === 'COMMENTED_OUT' ? raw(' data-commented') : '');
125
125
  const leaf = (k: string, r: any, ic: Raw | '', label: string, note = '') =>
126
- html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}"${commented(r)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
126
+ html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}" title="${note ? `${label} (${note})` : label}"${commented(r)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
127
127
  const folder = (label: string, children: Raw[] | Raw, open = true) =>
128
128
  html`<li${open ? '' : raw(' data-collapsed')}><span class="pd-node pd-folder"><span class="pd-label">${label}</span></span><ul>${children}</ul></li>`;
129
129
  const none = (text: string) => html`<li class="pd-empty">${text}</li>`;
@@ -136,7 +136,7 @@ export async function designerRoutes(app: FastifyInstance) {
136
136
  const bySlot = ['region', ...TOP_SLOTS, ...BOTTOM_SLOTS].map((sl) => [sl, buttons.filter((b) => (b.slot ?? 'region') === sl)] as const).filter(([, list]) => list.length);
137
137
  const kids = [...(items.length ? [folder(tx('pd.tree.items'), items.map(itemLeaf))] : []),
138
138
  ...bySlot.map(([sl, list]) => folder(sl === 'region' ? tx('pd.tree.buttons') : tx('pd.tree.buttons_at', { slot: slotLabel(sl) }), list.map(buttonLeaf)))];
139
- return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}"${commented(r)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
139
+ return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}" title="${regionTitle(r)} (${r.type})"${commented(r)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
140
140
  };
141
141
  // (sprint 77, APEX: the rendering tree by display position) the page template's positions, and others in use
142
142
  const treeSide = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
@@ -9,7 +9,7 @@ import { migrate } from '../migrate.ts';
9
9
  import { clientIp, logActivity } from '../session.ts';
10
10
  import { openBlueprint } from './blueprints.ts';
11
11
  import { isAdmin } from './locks.ts';
12
- import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.ts';
12
+ import { back, BASE, csrf, developer, flash, pageHeader, send, shell, type Req } from './ui.ts';
13
13
  import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
14
14
  import { workspacesChanged } from '../workspace-db.ts';
15
15
  import { scopedTx as tx, type BuilderKey } from './i18n.ts';
@@ -104,7 +104,7 @@ export async function galleryRoutes(app: FastifyInstance) {
104
104
  const tabs = html`<nav class="gal-tabs segmented" aria-label="${tx('gal.kinds')}">${TABS.map(([t, l]) =>
105
105
  html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${tx(l)} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
106
106
  const blueprintCard = (kind: 'starters' | 'utilities') => (x: Starter) => card(x.icon, x.title, 'pgkiln', x.description,
107
- html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">${tx('gal.install')}</button></form>`);
107
+ html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-sm">${tx('gal.install')}</button></form>`);
108
108
  const cards =
109
109
  tab === 'samples'
110
110
  ? samples.filter(matches).map((x) => {
@@ -115,13 +115,13 @@ export async function galleryRoutes(app: FastifyInstance) {
115
115
  ? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">${tx('gal.edit')}</a>`
116
116
  : html`<span class="muted small">${tx('gal.other_workspace')}</span>`}`
117
117
  : admin
118
- ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="${tx('gal.confirm_install', { title: x.title })}">${tx('gal.install')}</button></form>`
118
+ ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-sm" data-confirm="${tx('gal.confirm_install', { title: x.title })}">${tx('gal.install')}</button></form>`
119
119
  : html`<span class="muted small">${tx('gal.admin_installs')}</span>`);
120
120
  })
121
121
  : tab === 'starters' || tab === 'utilities'
122
122
  ? (tab === 'starters' ? starters : utilities).filter(matches).map(blueprintCard(tab))
123
123
  : custom.filter(matches).map((x) => card('copy', x.name, tx('gal.this_workspace'), x.description ?? tx('gal.boilerplate_desc'),
124
- html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">${tx('gal.create_from')}</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">${tx('gal.edit')}</a>`));
124
+ html`<a class="btn btn-sm" href="${BASE}/create?boilerplate=${x.id}">${tx('gal.create_from')}</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">${tx('gal.edit')}</a>`));
125
125
  const empty = q && counts[tab]
126
126
  ? html`<p class="muted">${tx.rich('gal.no_match', { q: html`<strong>${q}</strong>` })} <a href="${BASE}/gallery?tab=${tab}">${tx('gal.show_all')}</a></p>`
127
127
  : tab === 'custom'
@@ -129,13 +129,13 @@ export async function galleryRoutes(app: FastifyInstance) {
129
129
  : html`<p class="muted">${tx('gal.empty')}</p>`;
130
130
  const intro = tx(({ samples: 'gal.intro.samples', starters: 'gal.intro.starters', utilities: 'gal.intro.utilities', custom: 'gal.intro.custom' } as const)[tab]);
131
131
  const main = html`<div class="gal">
132
+ ${pageHeader(tx('rail.gallery'))}
132
133
  <div class="gal-bar">
133
134
  <form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">${tx('gal.search')}</label>${icon('search')}
134
135
  <input type="hidden" name="tab" value="${tab}">
135
136
  <input id="gal-q" name="q" type="search" placeholder="${tx('gal.search')}" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
136
137
  ${tabs}
137
138
  </div>
138
- <h1 class="sr-only">${tx('rail.gallery')}</h1>
139
139
  <p class="muted">${intro}</p>
140
140
  ${cards.length ? html`<div class="gal-grid">${cards}</div>` : empty}
141
141
  </div>`;