pgkiln 0.53.0 → 0.55.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 (83) hide show
  1. package/SECURITY.md +2 -0
  2. package/db/migrations/127_builder_language.sql +16 -0
  3. package/docs/apex-feature-parity.md +7 -7
  4. package/docs/guide/03-builder.md +62 -8
  5. package/docs/guide/12-development.md +50 -3
  6. package/examples/gallery/README.md +37 -6
  7. package/examples/gallery/gallery.json +23 -1
  8. package/examples/gallery/samples/sample-calendar.sql +172 -0
  9. package/examples/gallery/samples/sample-cards.sql +157 -0
  10. package/examples/gallery/samples/sample-charts.sql +265 -0
  11. package/examples/gallery/samples/sample-dynamic-actions.sql +177 -0
  12. package/examples/gallery/samples/sample-grids.sql +151 -0
  13. package/examples/gallery/samples/sample-reporting.sql +151 -0
  14. package/examples/gallery/samples/ux-patterns.sql +295 -0
  15. package/examples/gallery/utilities/announcements.json +24 -0
  16. package/examples/gallery/utilities/lookup-lists.json +28 -0
  17. package/examples/gallery/utilities/release-notes.json +41 -0
  18. package/package.json +1 -1
  19. package/public/app.css +2 -0
  20. package/public/builder.css +39 -1
  21. package/public/worksheet.css +3 -2
  22. package/src/builder/advisor.ts +1 -1
  23. package/src/builder/ai-agents.ts +5 -5
  24. package/src/builder/ai-builder.ts +3 -3
  25. package/src/builder/api.ts +1 -1
  26. package/src/builder/autorest.ts +2 -2
  27. package/src/builder/availability.ts +1 -1
  28. package/src/builder/backups.ts +1 -1
  29. package/src/builder/changelog.ts +1 -1
  30. package/src/builder/data-modeler.ts +4 -4
  31. package/src/builder/data-security.ts +1 -1
  32. package/src/builder/dataload.ts +1 -1
  33. package/src/builder/datapump.ts +1 -1
  34. package/src/builder/ddl-tools.ts +2 -2
  35. package/src/builder/designer.ts +134 -124
  36. package/src/builder/diagnostics.ts +2 -2
  37. package/src/builder/errors.ts +10 -12
  38. package/src/builder/feedback.ts +1 -1
  39. package/src/builder/gallery.ts +94 -47
  40. package/src/builder/globalization.ts +1 -1
  41. package/src/builder/home.ts +59 -57
  42. package/src/builder/i18n/de.ts +675 -0
  43. package/src/builder/i18n/en.ts +678 -0
  44. package/src/builder/i18n/es.ts +675 -0
  45. package/src/builder/i18n/fr.ts +675 -0
  46. package/src/builder/i18n/nl.ts +675 -0
  47. package/src/builder/i18n/pt.ts +675 -0
  48. package/src/builder/i18n.ts +116 -0
  49. package/src/builder/language.ts +74 -0
  50. package/src/builder/launchpad.ts +13 -8
  51. package/src/builder/object-browser.ts +1 -1
  52. package/src/builder/plugins.ts +1 -1
  53. package/src/builder/querybuilder.ts +1 -1
  54. package/src/builder/quicksql.ts +1 -1
  55. package/src/builder/rest-modules.ts +2 -2
  56. package/src/builder/routes.ts +88 -66
  57. package/src/builder/sampledata.ts +1 -1
  58. package/src/builder/scheduling.ts +1 -1
  59. package/src/builder/scripts.ts +1 -1
  60. package/src/builder/search.ts +1 -1
  61. package/src/builder/shared.ts +5 -5
  62. package/src/builder/sql-charts.ts +2 -2
  63. package/src/builder/sql-monitor.ts +2 -2
  64. package/src/builder/sql-tools.ts +6 -0
  65. package/src/builder/sql-utilities.ts +3 -3
  66. package/src/builder/sql.ts +24 -22
  67. package/src/builder/static-files.ts +1 -1
  68. package/src/builder/subscriptions.ts +1 -1
  69. package/src/builder/supporting.ts +1 -1
  70. package/src/builder/table-editor.ts +1 -1
  71. package/src/builder/team.ts +7 -5
  72. package/src/builder/themeroller.ts +1 -1
  73. package/src/builder/top-sql.ts +1 -1
  74. package/src/builder/ui.ts +87 -77
  75. package/src/builder/unload.ts +1 -1
  76. package/src/builder/wizards.ts +1 -1
  77. package/src/builder/workflow-migrate.ts +1 -1
  78. package/src/builder/workingcopies.ts +2 -2
  79. package/src/builder/worksheet.ts +1 -1
  80. package/src/builder/workspace-home.ts +62 -59
  81. package/src/builder/workspaces.ts +2 -1
  82. package/src/runtime/charts.ts +4 -1
  83. package/src/runtime/regions.ts +8 -5
@@ -9,6 +9,7 @@ import { isAdmin } from './locks.ts';
9
9
  import { back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
10
10
  import { currentWorkspace, inWorkspaceSql, workspaceAdmin } from './workspaces.ts';
11
11
  import { teamHomeBlock } from './team.ts';
12
+ import { tr } from './i18n.ts';
12
13
 
13
14
  // (sprint 109, WS-1) The Workspace home (APEX: the workspace home page), the page the builder opens after
14
15
  // signing in: cards for the tools (App Builder, SQL Workshop, App Gallery), the workspace message (edited in place
@@ -83,6 +84,7 @@ export async function homeData(ws: number, tf: Timeframe) {
83
84
 
84
85
  /** The System message: the running version, and a newer release when the check is on. */
85
86
  async function systemMessage(s: Session, admin: boolean) {
87
+ const tx = tr(s);
86
88
  const check = await releaseCheckEnabled();
87
89
  // never wait for GitHub: show what is cached, refresh in the background
88
90
  if (check.on) void refreshRelease();
@@ -91,27 +93,26 @@ async function systemMessage(s: Session, admin: boolean) {
91
93
  const newer = r && newerVersion(r.version, VERSION) ? r : null;
92
94
  const body = newer
93
95
  ? html`<div class="alert alert-info wh-release" role="status">${icon('bell')}<div>
94
- <strong>pgkiln ${newer.version} is available</strong>
95
- <p>${newer.name}${newer.publishedAt ? html` <span class="muted">(${ago(newer.publishedAt)})</span>` : ''}. This server runs pgkiln ${VERSION}.</p>
96
- <p><a href="${newer.url}" target="_blank" rel="noopener noreferrer">Release notes ${icon('external')}</a> ·
97
- <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/01-installation.md#upgrading" target="_blank" rel="noopener">How to upgrade</a></p>
98
- <p class="small muted">To upgrade: back up the database, install the new version (<code>npm install -g pgkiln@${newer.version}</code>, or the
99
- Docker image <code>pgkiln/pgkiln:${newer.version}</code>), run <code>pgkiln migrate</code> and restart the server.</p></div></div>`
100
- : html`<p class="u-mt0">This server runs <strong>pgkiln ${VERSION}</strong>.${check.on && r && !newer ? ' It is the latest release.' : ''}</p>`;
96
+ <strong>${tx('wh.available', { version: newer.version })}</strong>
97
+ <p>${newer.name}${newer.publishedAt ? html` <span class="muted">(${ago(newer.publishedAt, Date.now(), tx)})</span>` : ''}. ${tx('wh.server_runs', { version: VERSION })}</p>
98
+ <p><a href="${newer.url}" target="_blank" rel="noopener noreferrer">${tx('wh.release_notes')} ${icon('external')}</a> ·
99
+ <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/01-installation.md#upgrading" target="_blank" rel="noopener">${tx('wh.how_upgrade')}</a></p>
100
+ <p class="small muted">${tx.rich('wh.upgrade_steps', { npm: html`<code>npm install -g pgkiln@${newer.version}</code>`, docker: html`<code>pgkiln/pgkiln:${newer.version}</code>`, migrate: html`<code>pgkiln migrate</code>` })}</p></div></div>`
101
+ : html`<p class="u-mt0">${tx.rich('wh.server_runs_strong', { version: html`<strong>pgkiln ${VERSION}</strong>` })}${check.on && r && !newer ? ` ${tx('wh.latest')}` : ''}</p>`;
101
102
  const status = !check.on
102
- ? 'The check for new versions is off.'
103
+ ? tx('wh.check_off')
103
104
  : c?.error
104
- ? `The last check failed (${c.error})${c.release ? '; the result above is older' : ''}.`
105
- : c ? `Checked ${ago(new Date(c.at))}.` : 'Checking for a new version…';
105
+ ? tx(c.release ? 'wh.check_failed_older' : 'wh.check_failed', { error: c.error })
106
+ : c ? tx('wh.checked', { ago: ago(new Date(c.at), Date.now(), tx) }) : tx('wh.checking');
106
107
  const admins = admin
107
108
  ? html`<form method="post" action="${HOME}/release-check" class="wh-release-form">${csrf(s)}
108
- <p class="small muted u-mt0">${status} ${check.on ? '' : html`When on, the server asks GitHub (<code>api.github.com</code>) once a day for the latest release; nothing about this installation is sent.`}
109
+ <p class="small muted u-mt0">${status} ${check.on ? '' : tx.rich('wh.check_explain', { host: html`<code>api.github.com</code>` })}
109
110
  ${check.from === 'environment' ? html`(<code>PGKILN_RELEASE_CHECK</code>)` : ''}</p>
110
111
  <div class="buttons">${check.on
111
- ? html`<button class="btn btn-sm" name="check" value="now">${icon('refresh')} Check now</button><button class="btn btn-sm" name="check" value="off">Turn off</button>`
112
- : html`<button class="btn btn-sm" name="check" value="on">Check for new versions</button>`}</div></form>`
112
+ ? html`<button class="btn btn-sm" name="check" value="now">${icon('refresh')} ${tx('wh.check_now')}</button><button class="btn btn-sm" name="check" value="off">${tx('wh.turn_off')}</button>`
113
+ : html`<button class="btn btn-sm" name="check" value="on">${tx('wh.check_on')}</button>`}</div></form>`
113
114
  : '';
114
- return region('System message', html`${body}${admins}`, '', 'system-message');
115
+ return region(tx('wh.system'), html`${body}${admins}`, '', 'system-message');
115
116
  }
116
117
 
117
118
  /** (Instance settings page) the release check: on, off, or as the environment says. */
@@ -133,16 +134,17 @@ function workspaceMessage(s: Session, canEdit: boolean) {
133
134
  const w = s.workspace;
134
135
  if (!w) return '';
135
136
  if (!w.message && !canEdit) return '';
137
+ const tx = tr(s);
136
138
  return html`<section class="region region-standard wh-message" id="workspace-message" aria-labelledby="wh-message-title">
137
- <header class="region-header"><h2 id="wh-message-title">Workspace message</h2></header>
139
+ <header class="region-header"><h2 id="wh-message-title">${tx('wh.message')}</h2></header>
138
140
  <div class="region-body">
139
- ${w.message ? html`<p class="wh-message-text">${w.message}</p>` : html`<p class="muted u-mt0">No message for the developers of ${w.name}.</p>`}
140
- ${canEdit ? html`<details class="wh-edit"><summary class="btn btn-sm" title="Edit the workspace message">${icon('edit')}<span>Edit message</span></summary>
141
+ ${w.message ? html`<p class="wh-message-text">${w.message}</p>` : html`<p class="muted u-mt0">${tx('wh.message_none', { name: w.name })}</p>`}
142
+ ${canEdit ? html`<details class="wh-edit"><summary class="btn btn-sm" title="${tx('wh.message_edit_title')}">${icon('edit')}<span>${tx('wh.message_edit')}</span></summary>
141
143
  <form method="post" action="${HOME}/message">${csrf(s)}<input type="hidden" name="workspace" value="${w.id}">
142
- <div class="field"><label class="label" for="f_message">Workspace message</label>
144
+ <div class="field"><label class="label" for="f_message">${tx('wh.message')}</label>
143
145
  <textarea id="f_message" name="message" rows="3" maxlength="2000">${w.message ?? ''}</textarea>
144
- <small class="help">Shown to the developers of ${w.name} here and on the App Builder home. Plain text, up to 2000 characters; empty removes it.</small></div>
145
- <div class="buttons"><button class="btn btn-hot">Save message</button></div></form></details>` : ''}
146
+ <small class="help">${tx('wh.message_help', { name: w.name })}</small></div>
147
+ <div class="buttons"><button class="btn btn-hot">${tx('wh.message_save')}</button></div></form></details>` : ''}
146
148
  </div></section>`;
147
149
  }
148
150
 
@@ -160,68 +162,69 @@ export async function workspaceHomeRoutes(app: FastifyInstance) {
160
162
  const data = await homeData(ws, tf);
161
163
  const sql = s.workspace?.sql_workshop !== false;
162
164
  const reporter = app.hasRoute({ method: 'GET', url: REPORTER });
165
+ const tx = tr(s);
163
166
  const card = (href: string, ic: string, title: string, text: string) =>
164
167
  html`<a class="ab-tile" href="${href}"><span class="ab-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`;
165
168
  const stat = (n: number, one: string, many: string, href: string | null) => {
166
169
  const inner = html`<b>${n}</b><span>${n === 1 ? one : many}</span>`;
167
170
  return href ? html`<a class="stat" href="${href}">${inner}</a>` : html`<div class="stat">${inner}</div>`;
168
171
  };
169
- const tfLabel = TIMEFRAMES[tf].label;
172
+ const tfLabel = tx(`wh.tf.${tf}`);
170
173
  const timeframe = html`<form method="post" action="${HOME}/timeframe" class="segmented wh-timeframe" role="group" aria-labelledby="wh-tf-label">${csrf(s)}
171
- <span class="sr-only" id="wh-tf-label">Reporting timeframe</span>
172
- ${(Object.keys(TIMEFRAMES) as Timeframe[]).map((k) => html`<button name="timeframe" value="${k}" aria-pressed="${k === tf ? 'true' : 'false'}">${TIMEFRAMES[k].label}</button>`)}</form>`;
173
- const none = html`<p class="muted u-mt0">No page views in the last ${tfLabel}.</p>`;
174
+ <span class="sr-only" id="wh-tf-label">${tx('wh.timeframe')}</span>
175
+ ${(Object.keys(TIMEFRAMES) as Timeframe[]).map((k) => html`<button name="timeframe" value="${k}" aria-pressed="${k === tf ? 'true' : 'false'}">${tx(`wh.tf.${k}`)}</button>`)}</form>`;
176
+ const none = html`<p class="muted u-mt0">${tx('wh.no_views', { tf: tfLabel })}</p>`;
174
177
  const people = (rows: { username: string; views: number; apps: number }[], caption: string, who: string) => rows.length
175
- ? html`<div class="table-wrap"><table class="report"><caption class="sr-only">${caption}, last ${tfLabel}</caption>
176
- <thead><tr><th scope="col">${who}</th><th scope="col" class="num">Page views</th><th scope="col" class="num">Applications</th></tr></thead>
178
+ ? html`<div class="table-wrap"><table class="report"><caption class="sr-only">${caption}</caption>
179
+ <thead><tr><th scope="col">${who}</th><th scope="col" class="num">${tx('wh.col.views')}</th><th scope="col" class="num">${tx('wh.col.apps')}</th></tr></thead>
177
180
  <tbody>${rows.map((u) => html`<tr><td class="cell-break">${u.username}</td><td class="num">${u.views}</td><td class="num">${u.apps}</td></tr>`)}</tbody></table></div>`
178
181
  : none;
179
182
  const topApps = data.apps.length
180
- ? html`<div class="table-wrap"><table class="report"><caption class="sr-only">Top applications, last ${tfLabel}</caption>
181
- <thead><tr><th scope="col">Application</th><th scope="col" class="num">Page views</th><th scope="col" class="num">Users</th></tr></thead>
183
+ ? html`<div class="table-wrap"><table class="report"><caption class="sr-only">${tx('wh.top_apps_caption', { tf: tfLabel })}</caption>
184
+ <thead><tr><th scope="col">${tx('wh.col.app')}</th><th scope="col" class="num">${tx('wh.col.views')}</th><th scope="col" class="num">${tx('wh.col.users')}</th></tr></thead>
182
185
  <tbody>${data.apps.map((a) => html`<tr><td class="cell-break"><a href="${BASE}/apps/${a.id}">${a.name}</a></td><td class="num">${a.views}</td><td class="num">${a.users}</td></tr>`)}</tbody></table></div>`
183
186
  : none;
184
187
  const ext = (href: string, label: string) => html`<li><a href="${href}" target="_blank" rel="noopener">${label}${icon('chevron')}</a></li>`;
185
188
  const main = html`<div class="ab-home wh-home">
186
189
  <div class="ab-main">
187
- <h1 class="wh-title">${s.workspace ? html`Workspace ${s.workspace.name}` : 'Workspace home'}</h1>
188
- <nav class="ab-tiles ab-tiles-wrap wh-tools" aria-label="Tools">
189
- ${card(`${BASE}/apps`, 'grid', 'App Builder', 'Create, edit and run the applications of this workspace.')}
190
- ${sql ? card(`${BASE}/sql`, 'database', 'SQL Workshop', 'Run SQL, browse database objects, load data and build REST services.') : ''}
191
- ${reporter ? card(REPORTER, 'pie-chart', 'Data Reporter', 'Datasets and reports for business users, without building pages.') : ''}
192
- ${card(`${BASE}/gallery`, 'package', 'App Gallery', 'Sample and starter applications to install.')}
190
+ <h1 class="wh-title">${s.workspace ? tx('wh.title_ws', { name: s.workspace.name }) : tx('wh.title')}</h1>
191
+ <nav class="ab-tiles ab-tiles-wrap wh-tools" aria-label="${tx('wh.tools')}">
192
+ ${card(`${BASE}/apps`, 'grid', tx('rail.app_builder'), tx('wh.card.apps'))}
193
+ ${sql ? card(`${BASE}/sql`, 'database', tx('rail.sql_workshop'), tx('wh.card.sql')) : ''}
194
+ ${reporter ? card(REPORTER, 'pie-chart', tx('rail.reporter'), tx('wh.card.reporter')) : ''}
195
+ ${card(`${BASE}/gallery`, 'package', tx('rail.gallery'), tx('wh.card.gallery'))}
193
196
  </nav>
194
197
  ${workspaceMessage(s, canEdit)}
195
198
  <section class="wh-summary" aria-labelledby="wh-summary-title">
196
- <h2 id="wh-summary-title">Summary</h2>
199
+ <h2 id="wh-summary-title">${tx('wh.summary')}</h2>
197
200
  <div class="stat-grid wh-stats">
198
- ${stat(data.counts.apps, 'application', 'applications', `${BASE}/apps`)}
199
- ${stat(data.counts.tables, 'table', 'tables', sql ? `${BASE}/sql/objects` : null)}
200
- ${stat(data.counts.developers, 'developer', 'developers', `${BASE}/developers`)}
201
- ${stat(data.counts.reporting, 'reporting application', 'reporting applications', reporter ? REPORTER : null)}
201
+ ${stat(data.counts.apps, tx('wh.stat.app'), tx('wh.stat.apps'), `${BASE}/apps`)}
202
+ ${stat(data.counts.tables, tx('wh.stat.table'), tx('wh.stat.tables'), sql ? `${BASE}/sql/objects` : null)}
203
+ ${stat(data.counts.developers, tx('wh.stat.developer'), tx('wh.stat.developers'), `${BASE}/developers`)}
204
+ ${stat(data.counts.reporting, tx('wh.stat.reporting'), tx('wh.stat.reportings'), reporter ? REPORTER : null)}
202
205
  </div>
203
206
  </section>
204
- <div class="wh-top-head"><h2>Activity</h2>${timeframe}</div>
207
+ <div class="wh-top-head"><h2>${tx('wh.activity')}</h2>${timeframe}</div>
205
208
  <div class="wh-top">
206
- ${region(`Top applications (${tfLabel})`, topApps)}
207
- ${region(`Top users (${tfLabel})`, people(data.users, 'Top users', 'User'))}
208
- ${region(`Top developers (${tfLabel})`, people(data.developers, 'Top developers', 'Developer'))}
209
+ ${region(tx('wh.top_apps', { tf: tfLabel }), topApps)}
210
+ ${region(tx('wh.top_users', { tf: tfLabel }), people(data.users, tx('wh.top_users_caption', { tf: tfLabel }), tx('wh.col.user')))}
211
+ ${region(tx('wh.top_devs', { tf: tfLabel }), people(data.developers, tx('wh.top_devs_caption', { tf: tfLabel }), tx('wh.col.developer')))}
209
212
  </div>
210
213
  ${await systemMessage(s, admin)}
211
214
  </div>
212
- <aside class="ab-side" aria-label="About and learn more">
213
- <section><h2>About</h2><p>The workspace home: its tools, how its applications are used and what is new. ${s.workspace ? html`You work in ${s.workspace.name}.` : ''}</p></section>
215
+ <aside class="ab-side" aria-label="${tx('wh.aside')}">
216
+ <section><h2>${tx('common.about')}</h2><p>${tx('wh.about')} ${s.workspace ? tx('wh.you_work', { name: s.workspace.name }) : ''}</p></section>
214
217
  ${await teamHomeBlock(s)}
215
- <section><h2>Learn more</h2><ul class="ab-links ab-tasks">
216
- ${ext(`${GUIDE}/docs/README.md`, 'The pgkiln guide')}
217
- ${ext(`${GUIDE}/docs/guide/10-tutorial.md`, 'Tutorial')}
218
- ${ext(`${GUIDE}/docs/guide/11-from-apex.md`, 'Coming from APEX')}
219
- ${ext(`${GUIDE}/CHANGELOG.md`, "What's new (changelog)")}
220
- ${ext(`${REPO}/discussions`, 'Discussions')}
218
+ <section><h2>${tx('wh.learn_more')}</h2><ul class="ab-links ab-tasks">
219
+ ${ext(`${GUIDE}/docs/README.md`, tx('wh.guide'))}
220
+ ${ext(`${GUIDE}/docs/guide/10-tutorial.md`, tx('wh.tutorial'))}
221
+ ${ext(`${GUIDE}/docs/guide/11-from-apex.md`, tx('wh.from_apex'))}
222
+ ${ext(`${GUIDE}/CHANGELOG.md`, tx('wh.whats_new'))}
223
+ ${ext(`${REPO}/discussions`, tx('wh.discussions'))}
221
224
  </ul></section>
222
- <section><h2>Resources</h2><ul class="ab-links ab-tasks">
223
- ${ext(`${GUIDE}/docs/guide/20-ai-agents.md`, 'AI coding agents (MCP)')}
224
- ${ext(`${REPO}/issues`, 'Report a problem')}
225
+ <section><h2>${tx('wh.resources')}</h2><ul class="ab-links ab-tasks">
226
+ ${ext(`${GUIDE}/docs/guide/20-ai-agents.md`, tx('wh.mcp'))}
227
+ ${ext(`${REPO}/issues`, tx('wh.report_problem'))}
225
228
  </ul></section>
226
229
  </aside>
227
230
  </div>`;
@@ -247,13 +250,13 @@ export async function workspaceHomeRoutes(app: FastifyInstance) {
247
250
  if (!(await workspaceAdmin(s.username, id))) return reply.code(403).send('Only the workspace\'s administrators change its message.');
248
251
  const message = String(req.body?.message ?? '').replace(/\r\n/g, '\n').trim();
249
252
  if (message.length > 2000) {
250
- flash(s, 'The message is at most 2000 characters.', 'error');
253
+ flash(s, tr(s)('wh.flash.too_long'), 'error');
251
254
  return back(reply, s, HOME);
252
255
  }
253
256
  const r = await owner.query('update meta.workspace set message = $2 where id = $1', [id, message || null]);
254
257
  if (!r.rowCount) return reply.code(404).send('Not found');
255
258
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id}: message` });
256
- flash(s, message ? 'Workspace message saved.' : 'Workspace message removed.');
259
+ flash(s, tr(s)(message ? 'wh.flash.saved' : 'wh.flash.removed'));
257
260
  return back(reply, s, `${HOME}#workspace-message`);
258
261
  });
259
262
 
@@ -270,15 +273,15 @@ export async function workspaceHomeRoutes(app: FastifyInstance) {
270
273
  await logActivity({ appId: null, username: s.username, event: 'instance_settings', ip: clientIp(req), detail: `release check ${v}` });
271
274
  const now = await releaseCheckEnabled();
272
275
  if (now.on) await refreshRelease(true);
273
- flash(s, now.on ? 'The server checks for new versions once a day.' : 'The check for new versions is off.');
276
+ flash(s, tr(s)(now.on ? 'wh.flash.check_daily' : 'wh.check_off'));
274
277
  } else if (v === 'now') {
275
278
  if (!(await releaseCheckEnabled()).on) {
276
- flash(s, 'The check for new versions is off.', 'error');
279
+ flash(s, tr(s)('wh.check_off'), 'error');
277
280
  return back(reply, s, to);
278
281
  }
279
282
  await refreshRelease(true);
280
283
  const c = cachedRelease();
281
- flash(s, c?.error ? `The check failed (${c.error}).` : 'Checked for a new version.', c?.error ? 'error' : 'ok');
284
+ flash(s, c?.error ? tr(s)('wh.flash.check_failed', { error: c.error }) : tr(s)('wh.flash.checked'), c?.error ? 'error' : 'ok');
282
285
  } else return reply.code(400).send('Unknown action');
283
286
  return back(reply, s, to);
284
287
  });
@@ -1,4 +1,5 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { tr } from './i18n.ts';
2
3
  import { owner, type Db } from '../db.ts';
3
4
  import { devDbForWorkspace, workspacesChanged } from '../workspace-db.ts';
4
5
  import { ownerUrl, runtimeUrl } from '../db.ts';
@@ -118,7 +119,7 @@ export const inWorkspaceSql = (alias: string, param: number) => `meta.app_worksp
118
119
  export function workspaceMenu(s: Session) {
119
120
  const list = s.workspaces ?? [];
120
121
  if (list.length < 2) return '';
121
- return html`<div class="menu-section"><span class="small muted" id="ws-label">Workspace</span>
122
+ return html`<div class="menu-section"><span class="small muted" id="ws-label">${tr(s)('status.workspace')}</span>
122
123
  <form method="post" action="${BASE}/workspace" class="ws-switch" aria-labelledby="ws-label">${csrf(s)}
123
124
  ${list.map((w) => html`<button name="workspace" value="${w.id}" aria-pressed="${s.workspace?.id === w.id ? 'true' : 'false'}">${icon('layers')}<span>${w.name}</span></button>`)}
124
125
  </form></div>`;
@@ -230,7 +230,10 @@ function column(labels: string[], series: Series[]) {
230
230
  const col = html`<span class="col s${si + 1}${v < 0 ? ' neg' : ''} ${css.cls(`bottom:${pct(bottom)};height:${pct(top - bottom)}`)}"></span>`;
231
231
  return multi && link ? mark('span', i, si, 'col-slot', `${l} · ${s.name}: ${fmt.format(v)}`, col, false) : html`<span class="col-slot">${col}</span>`;
232
232
  })}${!multi && labels.length <= 12
233
- ? html`<span class="col-value ${css.cls(`bottom:${pct(Math.max(y(series[0].values[i]), zero))}`)}">${compact.format(series[0].values[i])}</span>`
233
+ // (sprint 120) inside a drill link the value is drawn by CSS (attr()), so the link's name is its tip alone
234
+ ? link
235
+ ? html`<span class="col-value ${css.cls(`bottom:${pct(Math.max(y(series[0].values[i]), zero))}`)}" aria-hidden="true" data-value="${compact.format(series[0].values[i])}"></span>`
236
+ : html`<span class="col-value ${css.cls(`bottom:${pct(Math.max(y(series[0].values[i]), zero))}`)}" aria-hidden="true">${compact.format(series[0].values[i])}</span>`
234
237
  : ''}`;
235
238
  return multi ? html`<div class="col-group" role="img" data-tip="${tip(l, series, i)}" tabindex="0" aria-label="${tip(l, series, i)}">${inner}</div>` : mark('div', i, 0, 'col-group', tip(l, series, i), inner);
236
239
  })}</div>
@@ -465,7 +465,7 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
465
465
  return html`<dialog class="${cls}" id="R${r.id}"${staticAttr} aria-labelledby="${titleId}" data-inline-region>
466
466
  <header class="region-header"><h2 id="${titleId}">${r.title}</h2>
467
467
  <a class="region-dialog-close icon-button" href="#" role="button" data-region-close aria-label="${ctx.locale.t('common.dismiss')}">${icon('close')}</a></header>
468
- <div class="region-body">${body}</div>
468
+ <div class="region-body"${scrollable(r)}>${body}</div>
469
469
  ${buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
470
470
  </dialog>`;
471
471
 
@@ -473,17 +473,20 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
473
473
  if (r.template === 'collapsible')
474
474
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
475
475
  <details open><summary class="region-header">${header}</summary>
476
- <div class="region-body">${body}</div>
476
+ <div class="region-body"${scrollable(r)}>${body}</div>
477
477
  ${!buttonsOnTop && buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
478
478
  </details></section>`;
479
479
 
480
480
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
481
481
  ${r.title || (buttonsOnTop && buttons) ? html`<header class="region-header">${header}</header>` : ''}
482
- <div class="region-body">${body}</div>
482
+ <div class="region-body"${scrollable(r)}>${body}</div>
483
483
  ${!buttonsOnTop && buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
484
484
  </section>`;
485
485
  }
486
486
 
487
+ /** (sprint 120) A body that scrolls (template option to-scroll) can be reached and scrolled from the keyboard. */
488
+ const scrollable = (r: Region) => (r.template_options?.includes('to-scroll') ? raw(' tabindex="0"') : '');
489
+
487
490
  /**
488
491
  * (099) A region whose buttons have positions (APEX: Button Position): create, edit, change, copy, help and up in
489
492
  * the header; previous, close and delete at the left end of the bottom bar, next at its right end; the others
@@ -506,9 +509,9 @@ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, bo
506
509
  const header = html`<h2 id="${titleId}">${r.title}</h2>${top}`;
507
510
  if (r.template === 'collapsible')
508
511
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
509
- <details open><summary class="region-header">${header}</summary><div class="region-body">${body}</div>${footer}</details></section>`;
512
+ <details open><summary class="region-header">${header}</summary><div class="region-body"${scrollable(r)}>${body}</div>${footer}</details></section>`;
510
513
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
511
514
  ${r.title || top ? html`<header class="region-header">${header}</header>` : ''}
512
- <div class="region-body">${body}</div>${footer}
515
+ <div class="region-body"${scrollable(r)}>${body}</div>${footer}
513
516
  </section>`;
514
517
  }