pgkiln 0.51.0 → 0.53.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 (124) hide show
  1. package/README.md +1 -1
  2. package/SECURITY.md +10 -1
  3. package/db/migrations/121_ai_agents.sql +94 -0
  4. package/db/migrations/122_team_development.sql +186 -0
  5. package/db/migrations/123_workflow_outcomes.sql +407 -0
  6. package/db/migrations/124_component_details.sql +51 -0
  7. package/db/migrations/125_app_change.sql +23 -0
  8. package/db/migrations/126_mail_queue_network_access.sql +119 -0
  9. package/docs/apex-feature-parity.md +50 -19
  10. package/docs/guide/01-installation.md +8 -4
  11. package/docs/guide/03-builder.md +197 -10
  12. package/docs/guide/04-pages-and-regions.md +71 -21
  13. package/docs/guide/05-items.md +7 -1
  14. package/docs/guide/06-processing.md +129 -9
  15. package/docs/guide/07-dynamic-actions.md +34 -0
  16. package/docs/guide/08-security.md +55 -0
  17. package/docs/guide/09-reference.md +5 -2
  18. package/docs/guide/11-from-apex.md +3 -1
  19. package/docs/guide/12-development.md +87 -16
  20. package/docs/guide/14-globalization.md +49 -1
  21. package/docs/guide/18-cli.md +21 -1
  22. package/docs/guide/19-rest-data-sources.md +12 -0
  23. package/docs/guide/20-ai-agents.md +12 -4
  24. package/examples/hr/README.md +2 -0
  25. package/examples/hr/hr_64_ai_agent.sql +97 -0
  26. package/examples/hr/hr_65_ask_reports.sql +21 -0
  27. package/examples/hr/hr_66_team_development.sql +58 -0
  28. package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
  29. package/examples/hr/hr_68_component_details.sql +96 -0
  30. package/examples/hr/hr_70_data_security.sql +79 -0
  31. package/package.json +1 -1
  32. package/public/app.css +135 -21
  33. package/public/app.js +134 -20
  34. package/public/builder.css +133 -1
  35. package/public/builder.js +51 -0
  36. package/src/ai/chat.ts +18 -18
  37. package/src/ai/service.ts +35 -11
  38. package/src/ai/types.ts +7 -2
  39. package/src/app.ts +15 -0
  40. package/src/builder/advisor.ts +6 -3
  41. package/src/builder/ai-agents.ts +372 -0
  42. package/src/builder/ai-builder.ts +3 -3
  43. package/src/builder/ai.ts +13 -9
  44. package/src/builder/app-updates.ts +55 -0
  45. package/src/builder/assistant.ts +22 -7
  46. package/src/builder/components.ts +38 -9
  47. package/src/builder/data-security.ts +182 -0
  48. package/src/builder/designer.ts +8 -3
  49. package/src/builder/feedback.ts +6 -1
  50. package/src/builder/home.ts +16 -5
  51. package/src/builder/locks.ts +7 -1
  52. package/src/builder/mail-queue.ts +212 -0
  53. package/src/builder/mail.ts +72 -45
  54. package/src/builder/network-access.ts +140 -0
  55. package/src/builder/object-actions.ts +1 -0
  56. package/src/builder/object-browser.ts +1 -1
  57. package/src/builder/region-help.ts +2 -2
  58. package/src/builder/rest-catalogs.ts +19 -4
  59. package/src/builder/routes.ts +4 -1
  60. package/src/builder/shared.ts +1 -1
  61. package/src/builder/sql.ts +2 -0
  62. package/src/builder/team.ts +991 -0
  63. package/src/builder/themeroller.ts +47 -6
  64. package/src/builder/ui.ts +7 -1
  65. package/src/builder/websources.ts +1 -1
  66. package/src/builder/workflow-migrate.ts +210 -0
  67. package/src/builder/workflows.ts +29 -6
  68. package/src/builder/workspace-home.ts +2 -0
  69. package/src/builder/workspace-mail.ts +121 -0
  70. package/src/builder/workspace-transfer.ts +1 -1
  71. package/src/builder/workspaces.ts +1 -0
  72. package/src/cli/main.ts +25 -3
  73. package/src/cli/mcp.ts +40 -4
  74. package/src/cli/only.ts +52 -0
  75. package/src/cli/replace.ts +11 -1
  76. package/src/datasec.ts +343 -0
  77. package/src/i18n/ar.ts +17 -0
  78. package/src/i18n/cs.ts +17 -0
  79. package/src/i18n/da.ts +17 -0
  80. package/src/i18n/de.ts +17 -0
  81. package/src/i18n/el.ts +17 -0
  82. package/src/i18n/es.ts +17 -0
  83. package/src/i18n/fi.ts +17 -0
  84. package/src/i18n/fr.ts +17 -0
  85. package/src/i18n/he.ts +17 -0
  86. package/src/i18n/it.ts +17 -0
  87. package/src/i18n/ja.ts +17 -0
  88. package/src/i18n/ko.ts +17 -0
  89. package/src/i18n/nb.ts +17 -0
  90. package/src/i18n/pl.ts +17 -0
  91. package/src/i18n/pt.ts +17 -0
  92. package/src/i18n/ru.ts +17 -0
  93. package/src/i18n/sv.ts +17 -0
  94. package/src/i18n/tr.ts +17 -0
  95. package/src/i18n/uk.ts +17 -0
  96. package/src/i18n/zh.ts +17 -0
  97. package/src/i18n.ts +34 -0
  98. package/src/instance.ts +2 -0
  99. package/src/mail/mail.ts +50 -11
  100. package/src/mail/providers.ts +6 -3
  101. package/src/metadata.ts +11 -3
  102. package/src/network-acl.ts +126 -0
  103. package/src/objectstore.ts +3 -0
  104. package/src/runtime/ai-agents.ts +365 -0
  105. package/src/runtime/ai-filter.ts +385 -107
  106. package/src/runtime/assistant.ts +64 -11
  107. package/src/runtime/context.ts +2 -0
  108. package/src/runtime/engine.ts +3 -1
  109. package/src/runtime/ir-phrases.ts +812 -0
  110. package/src/runtime/ir-plan.ts +449 -0
  111. package/src/runtime/items.ts +1 -1
  112. package/src/runtime/regions.ts +6 -2
  113. package/src/runtime/render.ts +5 -3
  114. package/src/runtime/report.ts +1 -1
  115. package/src/runtime/styles.ts +54 -12
  116. package/src/runtime/tasks.ts +63 -12
  117. package/src/task-events.ts +232 -0
  118. package/src/team-samples.ts +74 -0
  119. package/src/team.ts +342 -0
  120. package/src/webclient.ts +45 -4
  121. package/src/webrequests.ts +5 -0
  122. package/src/websources.ts +30 -12
  123. package/src/workflow.ts +135 -18
  124. package/src/workspace-export.ts +16 -1
@@ -0,0 +1,58 @@
1
+ -- =====================================================================
2
+ -- HR example, part 66: Team Development (docs/guide/03-builder.md,
3
+ -- "Team Development")
4
+ --
5
+ -- What Builder → Team Development → Set up installs in the Default
6
+ -- workspace (the label groups Type and Priority, milestones 1.0 and 1.1,
7
+ -- a bug report template and a canned response), and one issue about the
8
+ -- HR application: "Salary rounding on page 12", a Bug of milestone 1.1,
9
+ -- assigned to admin. The Page Designer of page 12 shows it, and the
10
+ -- Workspace home counts it until it is closed. Issues are workspace data
11
+ -- (the workspace export), not part of the HR application's export.
12
+ -- =====================================================================
13
+
14
+ insert into meta.team_label_group (workspace_id, name, description, seq) values
15
+ (1, 'Type', 'What kind of work the issue is.', 10),
16
+ (1, 'Priority', 'How soon it should be done.', 20)
17
+ on conflict (workspace_id, lower(name)) do nothing;
18
+
19
+ insert into meta.team_label (workspace_id, group_id, name, color, description, seq)
20
+ select 1, g.id, l.name, l.color, l.description, l.seq
21
+ from (values ('Type', 'Bug', 'red', 'Something does not work as it should.', 10),
22
+ ('Type', 'Feature', 'blue', 'New functionality.', 20),
23
+ ('Type', 'Task', 'teal', 'Work to do that is neither a bug nor a feature.', 30),
24
+ ('Priority', 'High', 'orange', 'Do this first.', 10),
25
+ ('Priority', 'Medium', 'yellow', 'Do this soon.', 20),
26
+ ('Priority', 'Low', 'gray', 'Do this when there is time.', 30)) as l(grp, name, color, description, seq)
27
+ join meta.team_label_group g on g.workspace_id = 1 and g.name = l.grp
28
+ on conflict (workspace_id, lower(name)) do nothing;
29
+
30
+ insert into meta.team_milestone (workspace_id, name, description, due_date) values
31
+ (1, '1.0', 'The first release.', current_date + 30),
32
+ (1, '1.1', 'The first update after the release.', current_date + 90)
33
+ on conflict (workspace_id, lower(name)) do nothing;
34
+
35
+ insert into meta.team_template (workspace_id, kind, name, title, body, labels, seq) values
36
+ (1, 'issue', 'Bug report', 'Bug: ', E'## What happens\n\n\n## What should happen\n\n\n## Steps to reproduce\n\n1. \n2. \n', '{Bug}', 10),
37
+ (1, 'response', 'Thanks, we will look at it', null, 'Thank you for reporting this. We will look at it and keep this issue up to date.', '{}', 10)
38
+ on conflict (workspace_id, kind, lower(name)) do nothing;
39
+
40
+ with issue as (
41
+ insert into meta.team_issue (workspace_id, title, body, milestone_id, app_id, page_no, created_by)
42
+ select 1, 'Salary rounding on page 12',
43
+ E'The leave calendar shows daily salary costs rounded to whole euros; finance expects cents.\n\n'
44
+ || E'- Open **page 12** (Leave calendar)\n- Compare a day with the salary in `hr.emp`\n\n@admin can you have a look?',
45
+ (select id from meta.team_milestone where workspace_id = 1 and name = '1.1'),
46
+ a.id, 12, 'admin'
47
+ from meta.app a
48
+ where a.alias = 'hr'
49
+ and not exists (select 1 from meta.team_issue i where i.workspace_id = 1 and i.title = 'Salary rounding on page 12')
50
+ returning id
51
+ ), labelled as (
52
+ insert into meta.team_issue_label (issue_id, label_id)
53
+ select issue.id, l.id from issue join meta.team_label l on l.workspace_id = 1 and l.name in ('Bug', 'Medium')
54
+ ), assigned as (
55
+ insert into meta.team_issue_assignee (issue_id, username)
56
+ select issue.id, 'admin' from issue where exists (select 1 from meta.developer where username = 'admin')
57
+ )
58
+ insert into meta.team_issue_event (issue_id, username, action) select id, 'admin', 'created' from issue;
@@ -0,0 +1,113 @@
1
+ -- =====================================================================
2
+ -- HR example, part 67: custom task outcomes, task events and
3
+ -- notifications, a Send E-Mail step, drafts and versions of a workflow
4
+ -- (migration 123; docs/guide/06-processing.md, "Approvals" and "Workflows")
5
+ --
6
+ -- SALARY_DECISION is a task with three outcomes instead of Approve / Reject:
7
+ -- Yes, No and Undecided. Its owners (role admin) get an e-mail when a task
8
+ -- is created (notification SALARY_TASK); when one is completed an event
9
+ -- records the decision as a notification for the initiator.
10
+ --
11
+ -- SALARY_CHANGE asks for that decision and branches on its outcome:
12
+ -- 1. APPROVE the task (an administrator decides)
13
+ -- 2. DECIDE YES: apply the new salary; UNDECIDED: ask again; otherwise the end
14
+ -- 3. APPLY the new salary (SQL)
15
+ -- 4. TELL an e-mail to the employee (version 2: a send_mail step)
16
+ -- Version 1 had no TELL step and is inactive; version 2 is active; version 3
17
+ -- is in development with a draft step (BUDGET_CHECK) that is still a
18
+ -- placeholder: it is drawn dashed, and can't be activated until it is
19
+ -- replaced. Instances of version 1 can be migrated to version 2 under
20
+ -- Shared Components → Workflows → SALARY_CHANGE → Migrate running instances.
21
+ --
22
+ -- Start one from SQL (e.g. the SQL Workshop, signed in to the application):
23
+ -- select meta.start_workflow('SALARY_CHANGE', '7499', '{"ENAME": "Allen", "NEW_SAL": 1760}')
24
+ -- and decide on it under My tasks (page 14) as king.
25
+ -- =====================================================================
26
+
27
+ insert into meta.email_template (app_id, name, subject, html_body, text_body)
28
+ select id, 'SALARY_TASK', 'To decide: #SUBJECT#',
29
+ $h$<p>A salary change waits for your decision: <b>#SUBJECT#</b>.</p>
30
+ <p>Requested by #INITIATOR#. Decide under <i>My tasks</i> in HR Demo.</p>$h$,
31
+ $t$A salary change waits for your decision: #SUBJECT#.
32
+
33
+ Requested by #INITIATOR#. Decide under "My tasks" in HR Demo.$t$
34
+ from meta.app where alias = 'hr'
35
+ union all
36
+ select id, 'SALARY_CHANGED', 'Your salary changes to #NEW_SAL#',
37
+ $h$<p>Dear #ENAME#,</p><p>Your new salary of <b>#NEW_SAL#</b> was approved by #TASK_APPROVER#.</p><p>Kind regards,<br>HR</p>$h$,
38
+ $t$Dear #ENAME#,
39
+
40
+ Your new salary of #NEW_SAL# was approved by #TASK_APPROVER#.
41
+
42
+ Kind regards,
43
+ HR$t$
44
+ from meta.app where alias = 'hr';
45
+
46
+ insert into meta.task_definition (app_id, name, type, subject, owner_roles, admin_role, priority, due_in, outcomes, events, notifications)
47
+ select id, 'SALARY_DECISION', 'approval', 'Salary of &ENAME. to &NEW_SAL.', '{admin}'::text[], 'admin', 2, '5 days',
48
+ $o$[{"name": "YES", "label": "Yes", "colour": "success"},
49
+ {"name": "NO", "label": "No", "colour": "danger"},
50
+ {"name": "UNDECIDED", "label": "Undecided", "colour": "warning"}]$o$::jsonb,
51
+ $e$[{"on": "complete", "type": "sql", "code": "select hr.salary_decided(:DETAIL_PK::int, :OUTCOME, :INITIATOR)"}]$e$::jsonb,
52
+ '{"create": "SALARY_TASK"}'::jsonb
53
+ from meta.app where alias = 'hr';
54
+
55
+ -- Notifications are for other users, which RLS forbids the app: security definer, like the onboarding ones.
56
+ create function hr.salary_decided(p_empno int, p_outcome text, p_initiator text) returns void
57
+ language plpgsql security definer set search_path = hr, pg_catalog as $$
58
+ begin
59
+ insert into hr.notification (username, message)
60
+ select p_initiator, format('Salary change of %s: %s.', initcap(e.ename), lower(p_outcome))
61
+ from hr.emp e where e.empno = p_empno;
62
+ end
63
+ $$;
64
+ create function hr.salary_change_apply(p_empno int, p_sal numeric) returns timestamptz
65
+ language plpgsql security definer set search_path = hr, pg_catalog as $$
66
+ begin
67
+ if p_sal is null or p_sal <= 0 then
68
+ raise exception 'A salary is more than 0.';
69
+ end if;
70
+ update hr.emp set sal = p_sal where empno = p_empno;
71
+ return now();
72
+ end
73
+ $$;
74
+ -- the e-mail address of an employee's account (the accounts are pgkiln's, not the application's)
75
+ create function hr.employee_email(p_empno int) returns text
76
+ language sql stable security definer set search_path = hr, pg_catalog as $$
77
+ select a.email from hr.emp e join meta.account a on lower(a.username) = lower(e.username) where e.empno = p_empno and a.active
78
+ $$;
79
+ revoke all on function hr.salary_decided(int, text, text), hr.salary_change_apply(int, numeric), hr.employee_email(int) from public;
80
+ grant execute on function hr.salary_decided(int, text, text), hr.salary_change_apply(int, numeric), hr.employee_email(int) to hr_app;
81
+
82
+ -- version 1, then version 2 (with the e-mail) becomes active, then version 3 in development (a draft step)
83
+ insert into meta.workflow_definition (app_id, name, title, description, admin_role, steps)
84
+ select id, 'SALARY_CHANGE', 'Salary of &ENAME. to &NEW_SAL.', 'A salary change, decided by an administrator (Yes, No or Undecided).', 'admin',
85
+ $s$[
86
+ {"name": "APPROVE", "type": "task", "task": "SALARY_DECISION"},
87
+ {"name": "DECIDE", "type": "switch", "cases": [{"outcome": "YES", "next": "APPLY"}, {"outcome": "UNDECIDED", "next": "APPROVE"}], "otherwise": "END"},
88
+ {"name": "APPLY", "type": "sql", "code": "select hr.salary_change_apply(:DETAIL_PK::int, :NEW_SAL::numeric) as applied_at", "next": "END"},
89
+ {"name": "END", "type": "end"}
90
+ ]$s$::jsonb
91
+ from meta.app where alias = 'hr';
92
+
93
+ select meta.new_workflow_version(d.id) from meta.workflow_definition d join meta.app a on a.id = d.app_id where a.alias = 'hr' and d.name = 'SALARY_CHANGE';
94
+ update meta.workflow_definition d set dev_steps = $s$[
95
+ {"name": "APPROVE", "type": "task", "task": "SALARY_DECISION"},
96
+ {"name": "DECIDE", "type": "switch", "cases": [{"outcome": "YES", "next": "APPLY"}, {"outcome": "UNDECIDED", "next": "APPROVE"}], "otherwise": "END"},
97
+ {"name": "APPLY", "type": "sql", "code": "select hr.salary_change_apply(:DETAIL_PK::int, :NEW_SAL::numeric) as applied_at"},
98
+ {"name": "TELL", "type": "send_mail", "to": "select hr.employee_email(:DETAIL_PK::int)", "template": "SALARY_CHANGED", "next": "END"},
99
+ {"name": "END", "type": "end"}
100
+ ]$s$::jsonb
101
+ from meta.app a where a.id = d.app_id and a.alias = 'hr' and d.name = 'SALARY_CHANGE';
102
+ select meta.activate_workflow_version(d.id) from meta.workflow_definition d join meta.app a on a.id = d.app_id where a.alias = 'hr' and d.name = 'SALARY_CHANGE';
103
+
104
+ select meta.new_workflow_version(d.id) from meta.workflow_definition d join meta.app a on a.id = d.app_id where a.alias = 'hr' and d.name = 'SALARY_CHANGE';
105
+ update meta.workflow_definition d set dev_steps = $s$[
106
+ {"name": "BUDGET_CHECK", "type": "draft", "note": "check the department's budget", "next": "APPROVE"},
107
+ {"name": "APPROVE", "type": "task", "task": "SALARY_DECISION"},
108
+ {"name": "DECIDE", "type": "switch", "cases": [{"outcome": "YES", "next": "APPLY"}, {"outcome": "UNDECIDED", "next": "APPROVE"}], "otherwise": "END"},
109
+ {"name": "APPLY", "type": "sql", "code": "select hr.salary_change_apply(:DETAIL_PK::int, :NEW_SAL::numeric) as applied_at"},
110
+ {"name": "TELL", "type": "send_mail", "to": "select hr.employee_email(:DETAIL_PK::int)", "template": "SALARY_CHANGED", "next": "END"},
111
+ {"name": "END", "type": "end"}
112
+ ]$s$::jsonb
113
+ from meta.app a where a.id = d.app_id and a.alias = 'hr' and d.name = 'SALARY_CHANGE';
@@ -0,0 +1,96 @@
1
+ -- =====================================================================
2
+ -- HR example, part 68: component details of APEX 26.1 (migration 124;
3
+ -- docs/guide/05-items.md "Popup LOV", docs/guide/07-dynamic-actions.md
4
+ -- "Wait popup" and "Menu entry chosen")
5
+ --
6
+ -- Page 47 "Component details":
7
+ -- * P47_REF, a popup LOV over 6,000 reference numbers: the dialog loads
8
+ -- the next 100 as the list scrolls (no More button).
9
+ -- * RECALC, a submit button whose process takes two seconds: a dynamic
10
+ -- action shows the wait popup on its click; the next page removes it.
11
+ -- * CHECK ("Check in the background"): wait popup, an Ajax call (execute_sql,
12
+ -- a second and a half) that fills P47_RESULT, remove wait popup.
13
+ -- * ACTIONS, a menu button: a link, and two entries that run dynamic
14
+ -- actions (event menu_entry): COUNT (the same Ajax call with a wait
15
+ -- popup) and HELLO (a success message).
16
+ -- =====================================================================
17
+
18
+ insert into meta.page (app_id, page_no, name, title, parent_page)
19
+ select id, 47, 'Component details', 'Component details', 1 from meta.app where alias = 'hr';
20
+
21
+ insert into meta.region (page_id, seq, title, type, columns, template, source)
22
+ select p.id, 10, 'Component details', 'static', 12, 'standard',
23
+ '<p>A popup list of values that loads more rows as you scroll, a wait popup around a long process and an Ajax call, and a menu button whose entries run dynamic actions.</p>'
24
+ from meta.page p join meta.app a on a.id = p.app_id
25
+ where a.alias = 'hr' and p.page_no = 47;
26
+
27
+ insert into meta.item (page_id, region_id, seq, name, label, type, lov, config, help)
28
+ select p.id, r.id, i.seq, i.name, i.label, i.type, i.lov, i.config::jsonb, i.help
29
+ from meta.page p join meta.app a on a.id = p.app_id
30
+ join meta.region r on r.page_id = p.id and r.title = 'Component details', (values
31
+ (10, 'P47_REF', 'Reference', 'popup_lov',
32
+ 'select ''REF-'' || lpad(n::text, 5, ''0'') || '' · '' || (array[''Accounting'', ''Research'', ''Sales'', ''Operations''])[1 + n % 4] as name, n as ref
33
+ from generate_series(1, 6000) n
34
+ order by n',
35
+ '{"page_size": 100}', '6,000 reference numbers: scroll the dialog to load more.'),
36
+ (20, 'P47_RESULT', 'Result', 'text', null, '{}', 'Filled by the Ajax call behind Check in the background, or Actions → Count employees.')
37
+ ) as i (seq, name, label, type, lov, config, help)
38
+ where a.alias = 'hr' and p.page_no = 47;
39
+
40
+ insert into meta.button (page_id, region_id, seq, name, label, action, hot, menu)
41
+ select p.id, r.id, b.seq, b.name, b.label, b.action, b.hot, b.menu::jsonb
42
+ from meta.page p join meta.app a on a.id = p.app_id
43
+ join meta.region r on r.page_id = p.id and r.title = 'Component details', (values
44
+ (10, 'RECALC', 'Recalculate', 'submit', true, null),
45
+ (20, 'CHECK', 'Check in the background', 'da', false, null),
46
+ (30, 'ACTIONS', 'Actions', 'menu', false,
47
+ '[{"label": "Count employees", "action": "COUNT", "icon": "users"},
48
+ {"label": "Say hello", "action": "HELLO", "icon": "bell"},
49
+ {"label": "Pick an employee", "page": 26, "icon": "search"}]')
50
+ ) as b (seq, name, label, action, hot, menu)
51
+ where a.alias = 'hr' and p.page_no = 47;
52
+
53
+ -- the long process: two seconds on RECALC
54
+ insert into meta.process (page_id, seq, name, type, point, code, when_button, success_message)
55
+ select p.id, 10, 'Recalculate (slow)', 'sql', 'submit', 'select pg_sleep(2)', 'RECALC', 'Recalculated.'
56
+ from meta.page p join meta.app a on a.id = p.app_id
57
+ where a.alias = 'hr' and p.page_no = 47;
58
+
59
+ insert into meta.dynamic_action (page_id, seq, name, event, trigger_element, action, affected_items, code, message)
60
+ select p.id, d.seq, d.name, d.event, d.trig, d.action, d.affected, d.code, d.message
61
+ from meta.page p join meta.app a on a.id = p.app_id, (values
62
+ (10, 'Wait while recalculating', 'click', 'RECALC', 'wait_popup', null, null, 'Recalculating…'),
63
+ (20, 'Wait for the check', 'click', 'CHECK', 'wait_popup', null, null, 'Checking…'),
64
+ (30, 'The check', 'click', 'CHECK', 'execute_sql', 'P47_RESULT',
65
+ 'select (select count(*) from hr.emp) || '' employees, checked at '' || to_char(clock_timestamp(), ''HH24:MI:SS'') as p47_result from pg_sleep(1.5)', null),
66
+ (40, 'Check done', 'click', 'CHECK', 'remove_wait_popup', null, null, null),
67
+ (50, 'Wait for the count', 'menu_entry', 'COUNT', 'wait_popup', null, null, 'Counting employees…'),
68
+ (60, 'The count', 'menu_entry', 'COUNT', 'execute_sql', 'P47_RESULT',
69
+ 'select (select count(*) from hr.emp) || '' employees, counted at '' || to_char(clock_timestamp(), ''HH24:MI:SS'') as p47_result from pg_sleep(1.5)', null),
70
+ (70, 'Count done', 'menu_entry', 'COUNT', 'remove_wait_popup', null, null, null),
71
+ (80, 'Hello', 'menu_entry', 'HELLO', 'show_success', null, null, 'Hello from a menu entry.')
72
+ ) as d (seq, name, event, trig, action, affected, code, message)
73
+ where a.alias = 'hr' and p.page_no = 47;
74
+
75
+ insert into meta.nav_entry (app_id, seq, label, icon, target_page)
76
+ select id, 34, 'Component details', 'list', 47 from meta.app where alias = 'hr';
77
+
78
+ insert into meta.translation (app_id, language, source, target)
79
+ select a.id, 'nl', t.source, t.target
80
+ from meta.app a, (values
81
+ ('Component details', 'Componentdetails'),
82
+ ('Reference', 'Referentie'),
83
+ ('Result', 'Resultaat'),
84
+ ('Recalculate', 'Herberekenen'),
85
+ ('Check in the background', 'Op de achtergrond controleren'),
86
+ ('Actions', 'Acties'),
87
+ ('Count employees', 'Medewerkers tellen'),
88
+ ('Say hello', 'Hallo zeggen'),
89
+ ('Recalculating…', 'Herberekenen…'),
90
+ ('Checking…', 'Controleren…'),
91
+ ('Counting employees…', 'Medewerkers tellen…'),
92
+ ('Recalculated.', 'Herberekend.'),
93
+ ('Hello from a menu entry.', 'Hallo vanuit een menu-item.')
94
+ ) as t(source, target)
95
+ where a.alias = 'hr'
96
+ on conflict do nothing;
@@ -0,0 +1,79 @@
1
+ -- =====================================================================
2
+ -- HR example, part 70: data security per table (sprint 119, WS-13;
3
+ -- docs/guide/08-security.md "Data security per table")
4
+ --
5
+ -- hr.emp_masked, the masking view that SQL Workshop › hr.emp › Security
6
+ -- writes when sal and comm are masked (empty) for everyone but the
7
+ -- application role manager. It runs as hr_masking, so row level security
8
+ -- on hr.emp would still apply inside it. hr_app keeps SELECT on hr.emp
9
+ -- because the other pages read it: the Security page warns that the mask
10
+ -- holds only where the view is read.
11
+ --
12
+ -- Page 48 "Salaries (masked)": a report on the view. Signed in as KING
13
+ -- (manager) it shows salaries; as ALLEN (no manager role) they are empty.
14
+ -- =====================================================================
15
+
16
+ do $role$ begin
17
+ if not exists (select from pg_roles where rolname = 'hr_masking') then
18
+ create role hr_masking nologin;
19
+ end if;
20
+ end $role$;
21
+ grant hr_masking to current_user;
22
+ grant usage on schema hr to hr_masking;
23
+ grant select on hr.emp to hr_masking;
24
+ grant hr_app to hr_masking;
25
+
26
+ create view hr.emp_masked with (security_barrier) as
27
+ select t.empno,
28
+ t.ename,
29
+ t.job,
30
+ t.mgr,
31
+ t.hiredate,
32
+ case when (select meta.has_role('manager')) then t.sal end as sal,
33
+ case when (select meta.has_role('manager')) then t.comm end as comm,
34
+ t.deptno,
35
+ t.active,
36
+ t.username,
37
+ t.photo,
38
+ t.photo_name,
39
+ t.photo_mime,
40
+ t.work_location
41
+ from hr.emp t;
42
+ alter view hr.emp_masked owner to hr_masking;
43
+ comment on view hr.emp_masked is 'pgkiln masking view of emp: {"table":"emp","roles":["manager"],"cols":{"comm":{"mask":"null","see":["manager"],"others":false},"sal":{"mask":"null","see":["manager"],"others":false}}}';
44
+ grant select on hr.emp_masked to hr_app;
45
+
46
+ do $check$ begin
47
+ if exists (select from pg_roles r where r.rolname = 'hr_masking'
48
+ and (r.rolsuper or r.rolbypassrls or pg_has_role(r.oid, (select relowner from pg_class where oid = 'hr.emp'::regclass), 'USAGE'))) then
49
+ raise exception 'the masking role hr_masking would bypass row level security on hr.emp';
50
+ end if;
51
+ end $check$;
52
+
53
+ insert into meta.page (app_id, page_no, name, title, parent_page)
54
+ select id, 48, 'Salaries (masked)', 'Salaries (masked)', 1 from meta.app where alias = 'hr';
55
+
56
+ insert into meta.region (page_id, seq, title, type, columns, template, source, config)
57
+ select p.id, r.seq, r.title, r.type, 12, 'standard', r.source, r.config::jsonb
58
+ from meta.page p join meta.app a on a.id = p.app_id, (values
59
+ (10, 'Salaries (masked)', 'static',
60
+ '<p>This report reads <code>hr.emp_masked</code>: salary and commission are only shown to the role <b>manager</b>. Open SQL Workshop › hr.emp › Security to change who sees what.</p>', '{}'),
61
+ (20, 'Employees', 'report',
62
+ 'select ename, job, sal, comm from hr.emp_masked where active order by ename',
63
+ '{"page_size": 15, "headings": {"ename": "Name", "job": "Job", "sal": "Salary", "comm": "Commission"}}')
64
+ ) as r (seq, title, type, source, config)
65
+ where a.alias = 'hr' and p.page_no = 48;
66
+
67
+ insert into meta.nav_entry (app_id, seq, label, icon, target_page)
68
+ select id, 35, 'Salaries (masked)', 'shield', 48 from meta.app where alias = 'hr';
69
+
70
+ insert into meta.translation (app_id, language, source, target)
71
+ select a.id, 'nl', t.source, t.target
72
+ from meta.app a, (values
73
+ ('Salaries (masked)', 'Salarissen (gemaskeerd)'),
74
+ ('Employees', 'Medewerkers'),
75
+ ('Salary', 'Salaris'),
76
+ ('Commission', 'Commissie')
77
+ ) as t(source, target)
78
+ where a.alias = 'hr'
79
+ on conflict do nothing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -155,8 +155,8 @@ html[data-style="iris"][data-theme="dark"] {
155
155
  --hot: #ece9e6;
156
156
  --hot-text: #1a1817;
157
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; }
158
+ html[data-style="iris"] .t-header { box-shadow: inset 0 3px 0 var(--strip, var(--accent)); }
159
+ html[data-style="iris"] .t-nav { border-inline-end-color: transparent; }
160
160
  html[data-style="iris"] .t-titlebar { background: transparent; border-bottom: 0; padding: 1.25rem 1.5rem 0.25rem; }
161
161
  html[data-style="iris"] .t-titlebar h1 { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.2; }
162
162
  html[data-style="iris"] .region-header h2 { font-size: 1.05rem; }
@@ -225,7 +225,7 @@ html[data-style="lavender"][data-theme="dark"] {
225
225
  --shadow: 0 1px 2px rgb(0 0 0 / 30%), 0 2px 8px rgb(0 0 0 / 25%);
226
226
  }
227
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); }
228
+ html[data-style="lavender"] .t-nav a[aria-current="page"] { border-inline-start-color: transparent; border-radius: calc(var(--radius) * 0.75); }
229
229
  html[data-style="lavender"] .t-nav a, html[data-style="lavender"] .t-nav summary { border-radius: calc(var(--radius) * 0.75); }
230
230
  html[data-style="lavender"] .region-header { border-bottom-color: transparent; }
231
231
  html[data-style="lavender"] .region-header h2 { letter-spacing: -0.01em; }
@@ -243,8 +243,8 @@ h2 { font-size: 1rem; margin: 0; font-weight: 600; }
243
243
  .lead { font-size: 1.05rem; margin: 0; }
244
244
  [hidden] { display: none !important; }
245
245
  .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
246
- .skip-link { position: absolute; left: -9999px; top: 0; z-index: 100; background: var(--surface); padding: 0.5rem 1rem; }
247
- .skip-link:focus { left: 0.5rem; top: 0.5rem; }
246
+ .skip-link { position: absolute; inset-inline-start: -9999px; top: 0; z-index: 100; background: var(--surface); padding: 0.5rem 1rem; }
247
+ .skip-link:focus { inset-inline-start: 0.5rem; top: 0.5rem; }
248
248
  .icon { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
249
249
  /* (0.31) icon modifiers, like Font APEX's: "users lg", "refresh spin", "truck flip-h success" */
250
250
  .icon.icon-xs { width: 0.75em; height: 0.75em; } .icon.icon-sm { width: 0.875em; height: 0.875em; }
@@ -285,15 +285,15 @@ details.menu { position: relative; }
285
285
  details.menu > summary { list-style: none; }
286
286
  details.menu > summary::-webkit-details-marker { display: none; }
287
287
  .menu-panel {
288
- position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; min-width: 240px;
288
+ position: absolute; z-index: 30; top: calc(100% + 6px); inset-inline-start: 0; min-width: 240px;
289
289
  background: var(--surface); color: var(--text); border: 1px solid var(--border);
290
290
  border-radius: var(--radius); box-shadow: 0 8px 24px rgb(16 24 40 / 16%); padding: 0.4rem 0;
291
291
  max-height: min(80vh, 42rem); overflow: auto;
292
292
  }
293
- .menu-panel.align-right, .actions-menu .menu-panel { left: auto; right: 0; }
293
+ .menu-panel.align-right, .actions-menu .menu-panel { inset-inline-start: auto; inset-inline-end: 0; }
294
294
  .menu-section { padding: 0.5rem 0.9rem; display: flex; flex-direction: column; gap: 0.4rem; }
295
295
  .menu-section + .menu-section { border-top: 1px solid var(--border); }
296
- .menu-links a, .menu-links button { color: var(--text); text-decoration: none; padding: 0.2rem 0; display: flex; align-items: center; gap: 0.45rem; background: none; border: 0; font: inherit; cursor: pointer; text-align: left; }
296
+ .menu-links a, .menu-links button { color: var(--text); text-decoration: none; padding: 0.2rem 0; display: flex; align-items: center; gap: 0.45rem; background: none; border: 0; font: inherit; cursor: pointer; text-align: start; }
297
297
  .menu-links a:hover, .menu-links button:hover { color: var(--accent); }
298
298
  .menu-links .icon { width: 1em; height: 1em; }
299
299
  .filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; min-width: 320px; }
@@ -308,21 +308,31 @@ details.menu > summary::-webkit-details-marker { display: none; }
308
308
  body.nav-collapsed .t-body { grid-template-columns: 0 minmax(0, 1fr); }
309
309
  body.nav-collapsed .t-nav { visibility: hidden; }
310
310
  }
311
- .t-nav { background: var(--nav-bg); border-right: 1px solid var(--border); overflow: hidden auto; }
311
+ .t-nav { background: var(--nav-bg); border-inline-end: 1px solid var(--border); overflow: hidden auto; }
312
312
  .t-nav ul { list-style: none; margin: 0; padding: 0.5rem; }
313
- .t-nav ul ul { padding: 0 0 0 1.4rem; }
313
+ /* (sprint 117) a subtree hangs on a guide line; the open trail's line in the accent */
314
+ .t-nav ul ul { padding: 0; padding-inline-start: 0.35rem; margin-inline-start: 1.05rem; border-inline-start: 1px solid var(--nav-guide, color-mix(in srgb, var(--nav-text, var(--text)) 16%, transparent)); }
315
+ .t-nav details:has(> summary.in-trail) > ul { border-inline-start-color: color-mix(in srgb, var(--nav-current, var(--accent)) 55%, transparent); }
314
316
  .t-nav a, .t-nav summary {
315
317
  display: flex; align-items: center; gap: 0.65rem; padding: 0.5rem 0.65rem; border-radius: 6px;
318
+ /* (sprint 117) the bar at the inline start (left, or right in RTL) marks the current page */
319
+ border-inline-start: 3px solid transparent; padding-inline-start: calc(0.65rem - 3px);
316
320
  color: var(--nav-text, var(--text)); text-decoration: none; cursor: pointer; white-space: nowrap; font-size: 0.93rem;
317
321
  }
318
322
  .t-nav summary { list-style: none; }
319
323
  .t-nav summary::-webkit-details-marker { display: none; }
320
- .t-nav summary::after { content: "›"; margin-left: auto; color: var(--nav-muted, var(--muted)); transition: transform 0.15s; }
324
+ .t-nav summary::after { content: "›"; margin-inline-start: auto; color: var(--nav-muted, var(--muted)); transition: transform 0.15s; }
321
325
  .t-nav details[open] > summary::after { transform: rotate(90deg); }
326
+ /* RTL: the bidi algorithm mirrors "›" to "‹"; opened, it turns the other way to point down */
327
+ [dir="rtl"] .t-nav details[open] > summary::after { transform: rotate(-90deg); }
322
328
  .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)); }
329
+ /* (sprint 117) the selected entry (keyboard focus) gets a ring inside the menu, which scrolls and would clip one outside */
330
+ .t-nav a:focus-visible, .t-nav summary:focus-visible { outline: 2px solid var(--nav-current, var(--accent)); outline-offset: -2px; }
331
+ /* (sprint 72) --nav-* let a style have a dark menu (Iris); without them the menu takes the page's colours.
332
+ (sprint 117) Current page: filled, the accent bar and text. Its parent entries (the trail): bold, the menu's text
333
+ colour with an accent icon, no fill, so the two are told apart at a glance. */
334
+ .t-nav a[aria-current="page"] { background: var(--nav-active); color: var(--nav-current, var(--accent)); font-weight: 600; border-inline-start-color: var(--accent); }
335
+ .t-nav .in-trail { color: var(--nav-text, var(--text)); font-weight: 600; }
326
336
  .t-nav .icon { color: var(--nav-muted, var(--muted)); }
327
337
  .t-nav a[aria-current="page"] .icon, .t-nav .in-trail .icon { color: var(--nav-current, var(--accent)); }
328
338
 
@@ -440,7 +450,8 @@ textarea.code { font-family: var(--mono); font-size: 0.84rem; line-height: 1.45;
440
450
  .has-error input, .has-error select, .has-error textarea { border-color: var(--danger); }
441
451
  .error { color: var(--danger); font-size: 0.82rem; font-weight: 500; }
442
452
  .default-submit { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
443
- [aria-busy="true"] { opacity: 0.55; transition: opacity 0.1s; }
453
+ /* (sprint 117) a lighter processing indicator: a slight dim after 150 ms (no flicker on quick answers), a bar on regions (below) */
454
+ [aria-busy="true"] { opacity: 0.8; cursor: progress; transition: opacity 0.2s ease 0.15s; }
444
455
 
445
456
  /* ---------- buttons ---------- */
446
457
  .buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
@@ -902,10 +913,11 @@ pre.source { font-family: var(--mono); font-size: 0.82rem; background: var(--sur
902
913
  .t-body { grid-template-columns: minmax(0, 1fr); }
903
914
  /* Drawer: opened by JS (body.nav-open) or, without JS, by the #t-nav link (:target). */
904
915
  .t-nav {
905
- position: fixed; z-index: 25; top: 52px; bottom: 0; left: 0; width: min(300px, 85vw);
916
+ position: fixed; z-index: 25; top: 52px; bottom: 0; inset-inline-start: 0; width: min(300px, 85vw);
906
917
  box-shadow: 8px 0 24px rgb(0 0 0 / 20%); transition: transform 0.2s ease, visibility 0.2s;
907
918
  transform: translateX(-100%); visibility: hidden;
908
919
  }
920
+ [dir="rtl"] .t-nav { transform: translateX(100%); box-shadow: -8px 0 24px rgb(0 0 0 / 20%); }
909
921
  body.nav-open .t-nav, .t-nav:target { transform: none; visibility: visible; }
910
922
  body.nav-open .t-nav-backdrop { display: block; position: fixed; inset: 52px 0 0 0; z-index: 24; background: rgb(15 20 30 / 40%); }
911
923
  .t-regions > .region { grid-column: span 12 !important; }
@@ -950,7 +962,7 @@ pre.source { font-family: var(--mono); font-size: 0.82rem; background: var(--sur
950
962
  .report-reflow tbody tr:hover { background: var(--surface); }
951
963
  .report-reflow td {
952
964
  display: grid; grid-template-columns: minmax(90px, 38%) minmax(0, 1fr); gap: 0.75rem;
953
- border: 0; padding: 0.3rem 0.8rem; white-space: normal; max-width: none; text-align: left !important; overflow-wrap: anywhere;
965
+ border: 0; padding: 0.3rem 0.8rem; white-space: normal; max-width: none; text-align: start !important; overflow-wrap: anywhere;
954
966
  }
955
967
  .report-reflow td::before { content: attr(data-label); font-size: 0.8rem; font-weight: 650; color: var(--muted); }
956
968
  .report-reflow td.row-edit { width: auto; text-align: left; }
@@ -992,14 +1004,14 @@ fieldset.prop-group > legend { font-size: 0.75rem; text-transform: uppercase; le
992
1004
  body.nav-top .t-body { display: block; }
993
1005
  body.nav-top .t-nav {
994
1006
  position: sticky; top: 52px; z-index: 18; visibility: visible; overflow: visible;
995
- border-right: 0; border-bottom: 1px solid var(--border);
1007
+ border-inline-end: 0; border-bottom: 1px solid var(--border);
996
1008
  }
997
1009
  body.nav-top .t-nav > ul { display: flex; gap: 0.25rem; padding: 0.3rem 0.75rem; }
998
1010
  body.nav-top .t-nav li { position: relative; }
999
- body.nav-top .t-nav > ul > li > a[aria-current="page"] { box-shadow: inset 0 -3px 0 var(--accent); }
1011
+ body.nav-top .t-nav > ul > li > a[aria-current="page"] { box-shadow: inset 0 -3px 0 var(--accent); border-inline-start-color: transparent; }
1000
1012
  body.nav-top .t-nav details > ul {
1001
- position: absolute; top: calc(100% + 4px); left: 0; min-width: 220px; padding: 0.4rem;
1002
- background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 24px rgb(16 24 40 / 16%);
1013
+ position: absolute; top: calc(100% + 4px); inset-inline-start: 0; margin: 0; min-width: 220px; padding: 0.4rem;
1014
+ background: var(--nav-bg, var(--surface)); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 24px rgb(16 24 40 / 16%);
1003
1015
  }
1004
1016
  body.nav-top .t-nav details > summary::after { transform: rotate(90deg); }
1005
1017
  }
@@ -2048,3 +2060,105 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
2048
2060
  .reporter-actions .buttons { margin: 0; }
2049
2061
  .reporter-link { flex: 1 1 14rem; min-width: 0; font-family: var(--mono, monospace); font-size: 0.85rem; }
2050
2062
  .reporter-coldesc { display: block; font-size: 0.78rem; }
2063
+
2064
+ /* (121) AI agents: links a navigate tool built under an answer; tool arguments in the AI usage log */
2065
+ .assistant-links { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.4rem; }
2066
+ .assistant-links .btn { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
2067
+ .ai-args { overflow-wrap: anywhere; word-break: break-word; }
2068
+
2069
+ /* (sprint 112) the report's settings as chips under "Ask in your own words" (src/runtime/ai-filter.ts): Edit unfolds a small form */
2070
+ .ask-chips { margin: 0.6rem 0 0; padding: 0; list-style: none; }
2071
+ .ask-chip { flex-wrap: wrap; max-width: 100%; min-width: 0; overflow-wrap: anywhere; padding-right: 0.2rem; }
2072
+ .ask-chip-text { min-width: 0; }
2073
+ .ask-edit > summary { cursor: pointer; font-size: 0.78rem; text-decoration: underline; list-style: none; padding: 0 0.2rem; }
2074
+ .ask-edit > summary::-webkit-details-marker { display: none; }
2075
+ .ask-edit[open] { flex-basis: 100%; }
2076
+ .ask-edit-body { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.4rem; padding: 0.4rem 0.2rem 0.3rem; }
2077
+ .ask-field { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; flex: 1 1 8rem; max-width: 100%; }
2078
+ .ask-field label { font-size: 0.75rem; }
2079
+ .ask-field select, .ask-field input { min-width: 0; width: 100%; box-sizing: border-box; }
2080
+ .ask-chip-x { background: none; border: 0; color: inherit; font-size: 1rem; line-height: 1; padding: 0.15rem 0.4rem; border-radius: 99px; cursor: pointer; }
2081
+ .ask-chip-x:hover, .ask-chip-x:focus-visible { background: var(--surface); }
2082
+ /* (123) workflows: draft and send e-mail steps; custom task outcomes */
2083
+ .wf-send_mail rect { stroke: var(--success); stroke-width: 2; }
2084
+ .wf-draft rect { fill: var(--surface-2, var(--surface)); stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 6 4; }
2085
+ .wf-draft .wf-name, .wf-draft .wf-type { font-style: italic; }
2086
+ .btn.to-warning { background: var(--warning-soft); border-color: color-mix(in srgb, #b86e00 45%, var(--border)); color: var(--text); }
2087
+ .btn.to-warning:hover { background: color-mix(in srgb, var(--warning-soft) 80%, #b86e00); }
2088
+ .wf-export { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0 0; }
2089
+ .wf-migrate-map { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); gap: 0.5rem 1rem; }
2090
+ .wf-migrate-map label { display: flex; flex-direction: column; gap: 0.2rem; overflow-wrap: anywhere; }
2091
+ .wf-migrate-map select { max-width: 100%; }
2092
+ .wf-problem { color: var(--danger); overflow-wrap: anywhere; }
2093
+
2094
+ /* (sprint 115) the popup LOV's end of list while the next page loads, and the wait popup (dynamic action wait_popup) */
2095
+ .popup-lov-sentinel { margin: 0.5rem 0 0; text-align: center; font-size: 0.875rem; }
2096
+ dialog.wait-popup { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); box-shadow: var(--shadow); padding: 1.25rem 1.5rem; max-width: min(360px, calc(100vw - 2rem)); display: none; }
2097
+ dialog.wait-popup[open] { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
2098
+ dialog.wait-popup:focus { outline: none; }
2099
+ dialog.wait-popup::backdrop { background: rgb(15 20 30 / 35%); cursor: wait; }
2100
+ .wait-popup-spinner { width: 1.75rem; height: 1.75rem; border-radius: 50%; border: 2.5px solid color-mix(in srgb, var(--accent) 18%, transparent); border-top-color: var(--accent); }
2101
+ .wait-popup-text { margin: 0; text-align: center; overflow-wrap: anywhere; }
2102
+ @media (prefers-reduced-motion: no-preference) { .wait-popup-spinner { animation: icon-spin 0.8s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; } }
2103
+
2104
+ /* ---------- (sprint 117, APEX 26.2) Pillar Light / Pillar Dark navigation appearances ----------
2105
+ body.nav-pillar + nav-pillar-light / nav-pillar-dark (theme.appearance or a Theme Roller style's,
2106
+ src/runtime/styles.ts). Header and menu read as one column ("pillar"); a header pillar strip in
2107
+ --strip (Theme Roller), else the accent; the current page is a filled pill in the accent, its
2108
+ parents bold with an accent guide line. Light: the page's own surfaces (dark ones in dark mode).
2109
+ Dark: header and menu in the header colour. Contrast: menu text ≥ 7:1, the pill's text ≥ 4.5:1. */
2110
+ body.nav-pillar .t-header { box-shadow: inset 0 4px 0 var(--strip, var(--accent)); }
2111
+ body.nav-pillar .t-nav a, body.nav-pillar .t-nav summary { border-radius: 999px; }
2112
+ body.nav-pillar .t-nav a[aria-current="page"] { background: var(--accent); color: var(--accent-text); border-inline-start-color: transparent; }
2113
+ body.nav-pillar .t-nav a[aria-current="page"] .icon { color: var(--accent-text); }
2114
+ body.nav-pillar .t-nav a:focus-visible, body.nav-pillar .t-nav summary:focus-visible { outline-color: var(--nav-text); }
2115
+ body.nav-pillar-light {
2116
+ --header: var(--surface-2); --header-text: var(--text);
2117
+ --nav-bg: var(--surface-2); --nav-text: var(--text); --nav-muted: var(--muted);
2118
+ --nav-hover: var(--surface-3); --nav-active: var(--accent-soft); --nav-current: var(--accent);
2119
+ }
2120
+ body.nav-pillar-light .t-header { border-bottom: 1px solid var(--border); }
2121
+ body.nav-pillar-light .t-nav { border-inline-end-color: var(--border); }
2122
+ body.nav-pillar-light .icon-button:hover, body.nav-pillar-light .t-user > summary:hover,
2123
+ body.nav-pillar-light .t-navbar > ul > li > a:hover, body.nav-pillar-light .t-navbar > ul > li > details > summary:hover { background: var(--surface-3); }
2124
+ body.nav-pillar-light .t-navbar a[aria-current="page"] { background: var(--accent-soft); }
2125
+ body.nav-pillar-dark {
2126
+ --nav-bg: var(--header); --nav-text: var(--header-text);
2127
+ --nav-muted: color-mix(in srgb, var(--header-text) 72%, var(--header));
2128
+ --nav-hover: color-mix(in srgb, var(--header) 86%, #ffffff); --nav-active: color-mix(in srgb, var(--header) 76%, #ffffff);
2129
+ --nav-current: var(--header-text);
2130
+ --nav-guide: color-mix(in srgb, var(--header-text) 22%, transparent);
2131
+ }
2132
+ body.nav-pillar-dark .t-nav { border-inline-end-color: transparent; }
2133
+ body.nav-pillar-dark .t-nav .in-trail .icon, body.nav-pillar-dark .t-nav a[aria-current="page"] .icon { color: inherit; }
2134
+ body.nav-pillar-dark .t-nav details:has(> summary.in-trail) > ul { border-inline-start-color: color-mix(in srgb, var(--strip, var(--accent)) 70%, var(--header-text)); }
2135
+ @media (min-width: 1024px) {
2136
+ body.nav-pillar.nav-top .t-nav > ul > li > a[aria-current="page"] { box-shadow: none; }
2137
+ body.nav-pillar-dark.nav-top .t-nav { border-bottom-color: transparent; }
2138
+ }
2139
+
2140
+ /* ---------- (sprint 117, APEX 26.2) subtle colour utilities: a tinted background with readable text,
2141
+ in light and dark mode (text mostly the page's text colour, so contrast holds for every hue).
2142
+ u-color-1-subtle … u-color-15-subtle and u-color-{success,danger,warning,info,neutral}-subtle. */
2143
+ [class*="-subtle"][class*="u-color-"] {
2144
+ background: color-mix(in srgb, var(--u-hue) 14%, var(--surface)); color: color-mix(in srgb, var(--u-hue) 38%, var(--text));
2145
+ border-color: color-mix(in srgb, var(--u-hue) 32%, var(--surface));
2146
+ }
2147
+ .u-color-1-subtle { --u-hue: #309fdb; } .u-color-2-subtle { --u-hue: #13b6cf; } .u-color-3-subtle { --u-hue: #2ebfbc; }
2148
+ .u-color-4-subtle { --u-hue: #3caf85; } .u-color-5-subtle { --u-hue: #81bb5f; } .u-color-6-subtle { --u-hue: #c9cf1f; }
2149
+ .u-color-7-subtle { --u-hue: #f2bf2c; } .u-color-8-subtle { --u-hue: #f18b48; } .u-color-9-subtle { --u-hue: #e95b54; }
2150
+ .u-color-10-subtle { --u-hue: #e85d88; } .u-color-11-subtle { --u-hue: #ca589d; } .u-color-12-subtle { --u-hue: #8f5ea8; }
2151
+ .u-color-13-subtle { --u-hue: #5a68ad; } .u-color-14-subtle { --u-hue: #627c94; } .u-color-15-subtle { --u-hue: #8a7a6b; }
2152
+ .u-color-success-subtle { --u-hue: var(--success); } .u-color-danger-subtle { --u-hue: var(--danger); }
2153
+ .u-color-warning-subtle { --u-hue: #d18b00; } .u-color-info-subtle { --u-hue: var(--accent); } .u-color-neutral-subtle { --u-hue: var(--muted); }
2154
+
2155
+ /* ---------- (sprint 117) the processing bar on a busy region (dynamic actions, refreshes) */
2156
+ .region[aria-busy="true"] { position: relative; }
2157
+ .region[aria-busy="true"]::after {
2158
+ content: ""; position: absolute; z-index: 2; top: 0; inset-inline: 0; height: 3px; border-radius: var(--radius) var(--radius) 0 0; pointer-events: none;
2159
+ background: linear-gradient(90deg, transparent, var(--accent), transparent) 0 0 / 40% 100% no-repeat, color-mix(in srgb, var(--accent) 15%, transparent);
2160
+ }
2161
+ @media (prefers-reduced-motion: no-preference) {
2162
+ .region[aria-busy="true"]::after { animation: busy-slide 1.2s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; }
2163
+ }
2164
+ @keyframes busy-slide { from { background-position: -60% 0, 0 0; } to { background-position: 160% 0, 0 0; } }