pgkiln 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/db/migrations/097_generated_app_quality.sql +911 -0
  2. package/db/migrations/098_app_activity.sql +37 -0
  3. package/docs/apex-feature-parity.md +5 -5
  4. package/docs/guide/03-builder.md +66 -18
  5. package/docs/guide/09-reference.md +6 -2
  6. package/docs/guide/11-from-apex.md +1 -1
  7. package/docs/guide/12-development.md +1 -1
  8. package/docs/guide/14-globalization.md +7 -3
  9. package/docs/guide/17-mobile.md +1 -1
  10. package/package.json +1 -1
  11. package/public/app.css +175 -33
  12. package/public/app.js +80 -0
  13. package/public/builder.css +30 -2
  14. package/public/builder.js +31 -0
  15. package/src/builder/appfromfile.ts +5 -4
  16. package/src/builder/appsheets.ts +23 -9
  17. package/src/builder/appwizard.ts +215 -70
  18. package/src/builder/blueprints.ts +2 -1
  19. package/src/builder/components.ts +1 -1
  20. package/src/builder/home.ts +2 -32
  21. package/src/builder/newapp.ts +182 -5
  22. package/src/builder/routes.ts +4 -28
  23. package/src/i18n/ar.ts +1 -0
  24. package/src/i18n/cs.ts +1 -0
  25. package/src/i18n/da.ts +1 -0
  26. package/src/i18n/de.ts +1 -0
  27. package/src/i18n/el.ts +1 -0
  28. package/src/i18n/es.ts +1 -0
  29. package/src/i18n/fi.ts +1 -0
  30. package/src/i18n/fr.ts +1 -0
  31. package/src/i18n/he.ts +1 -0
  32. package/src/i18n/it.ts +1 -0
  33. package/src/i18n/ja.ts +1 -0
  34. package/src/i18n/ko.ts +1 -0
  35. package/src/i18n/nb.ts +1 -0
  36. package/src/i18n/pl.ts +1 -0
  37. package/src/i18n/pt.ts +1 -0
  38. package/src/i18n/ru.ts +1 -0
  39. package/src/i18n/sv.ts +1 -0
  40. package/src/i18n/tr.ts +1 -0
  41. package/src/i18n/uk.ts +1 -0
  42. package/src/i18n/zh.ts +1 -0
  43. package/src/i18n.ts +2 -0
  44. package/src/icons.ts +2 -1
  45. package/src/layout.ts +2 -2
  46. package/src/runtime/charts.ts +8 -3
  47. package/src/runtime/devbar.ts +35 -14
  48. package/src/runtime/engine.ts +7 -4
  49. package/src/runtime/locale.ts +2 -2
  50. package/src/runtime/regions.ts +25 -2
  51. package/src/runtime/render.ts +18 -4
  52. package/src/runtime/report.ts +15 -9
  53. package/src/runtime/styles.ts +5 -2
@@ -3,7 +3,146 @@ import { owner, type Client } from '../db.ts';
3
3
  import { passwordProblem } from '../accounts.ts';
4
4
  import { replaceApp } from '../cli/replace.ts';
5
5
  import { placeApp } from './workspaces.ts';
6
- import { NEW_APP_BASE } from '../runtime/styles.ts';
6
+ import { BASE_STYLES, NEW_APP_BASE } from '../runtime/styles.ts';
7
+ import { LANGUAGE_NAMES } from '../i18n.ts';
8
+ import { isIcon } from '../icons.ts';
9
+ import { secretKeyConfigured } from '../secrets.ts';
10
+
11
+ /** (097) The icon of a new application's home page when the developer chose none. */
12
+ export const DEFAULT_APP_ICON = 'layers';
13
+ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
14
+
15
+ /** (sprint 72) The page number of a generated application's About page. */
16
+ export const ABOUT_PAGE = 9000;
17
+
18
+ /**
19
+ * (sprint 72) The About page of a generated application (APEX: the About Page
20
+ * feature): the application's name, its description or the pages of its menu,
21
+ * and a Help menu with it in the header's navigation bar (a list NAVBAR, unless
22
+ * the application has a navigation bar list already). Nothing when the page
23
+ * number is taken.
24
+ */
25
+ export async function addAboutPage(c: Client, appId: number, description?: string | null) {
26
+ const app = (await c.query<{ name: string; navbar_list: string | null; home_page: number }>('select name, navbar_list, home_page from meta.app where id = $1', [appId])).rows[0];
27
+ if ((await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, ABOUT_PAGE])).rowCount) return;
28
+ const pages = (
29
+ await c.query<{ label: string }>(
30
+ `select e.label from meta.nav_entry e where e.app_id = $1 and e.parent_id is null and e.target_page is not null and e.target_page <> $2 order by e.seq, e.id`,
31
+ [appId, app.home_page],
32
+ )
33
+ ).rows.map((r) => r.label);
34
+ const text = description
35
+ ? `<p>${escapeHtml(description)}</p>`
36
+ : pages.length
37
+ ? `<p>${escapeHtml(app.name)} has these pages:</p><ul>${pages.map((l) => `<li>${escapeHtml(l)}</li>`).join('')}</ul>`
38
+ : `<p>${escapeHtml(app.name)}</p>`;
39
+ const page = (await c.query(`insert into meta.page (app_id, page_no, name, title, parent_page) values ($1, $2, 'About', $3, $4) returning id`, [appId, ABOUT_PAGE, `About ${app.name}`.slice(0, 200), app.home_page])).rows[0].id;
40
+ await c.query(`insert into meta.region (page_id, seq, title, type, source) values ($1, 10, $2, 'static', $3)`, [page, app.name, text]);
41
+ if (app.navbar_list) return;
42
+ await c.query(`insert into meta.list (app_id, name, description) values ($1, 'NAVBAR', 'The navigation bar: the Help menu') on conflict (app_id, name) do nothing`, [appId]);
43
+ const help = (await c.query(`insert into meta.list_entry (app_id, list_name, seq, label, icon) values ($1, 'NAVBAR', 10, 'Help', 'help') returning id`, [appId])).rows[0].id;
44
+ await c.query(`insert into meta.list_entry (app_id, list_name, parent_id, seq, label, icon, target_page) values ($1, 'NAVBAR', $2, 10, 'About', 'info', $3)`, [appId, help, ABOUT_PAGE]);
45
+ await c.query(`update meta.app set navbar_list = 'NAVBAR' where id = $1`, [appId]);
46
+ }
47
+
48
+ /**
49
+ * (sprint 73) The Create Application wizard's features (APEX: Features). Each maps to a setting or to pages:
50
+ * - about: an About page in a Help menu (addAboutPage)
51
+ * - access: authorization schemes CONTRIBUTOR and READER next to ADMIN (roles admin, contributor, reader)
52
+ * - activity: an Activity page for administrators (page 9010: page views per day and the latest activity)
53
+ * - feedback: users may send feedback (the navigation bar's Feedback entry)
54
+ * - theme: users may choose light or dark (theme.user_choice)
55
+ * - pwa, push: installable, with push notifications (push needs PGKILN_SECRET_KEY; its key is made after the commit)
56
+ */
57
+ export interface Features { about: boolean; access: boolean; activity: boolean; feedback: boolean; theme: boolean; pwa: boolean; push: boolean }
58
+ export const FEATURES: [keyof Features, string, string, boolean][] = [
59
+ ['about', 'About page', 'An About page in a Help menu of the header.', true],
60
+ ['access', 'Access control', 'Authorization schemes for the roles admin, contributor and reader, to put on pages, regions and buttons.', false],
61
+ ['activity', 'Activity reporting', 'A page for administrators with the page views per day and the latest activity.', false],
62
+ ['feedback', 'Feedback', 'Users send feedback from the header; you read it under Activity → Feedback.', false],
63
+ ['theme', 'Theme style selection', 'Users may choose the light or the dark theme.', true],
64
+ ['pwa', 'Install as an app (PWA)', 'Installable on phones and desktops, with offline pages.', false],
65
+ ['push', 'Push notifications', 'Notifications to the users\' devices (with Install as an app).', false],
66
+ ];
67
+ /** The features ticked on a form (a form without them: the defaults). */
68
+ export const featuresFrom = (b: Record<string, unknown>, posted: boolean): Features =>
69
+ Object.fromEntries(FEATURES.map(([k, , , on]) => [k, posted ? b[`f_${k}`] === 'true' : on])) as unknown as Features;
70
+
71
+ /** (sprint 73) The Activity page of a generated application. */
72
+ export const ACTIVITY_PAGE = 9010;
73
+
74
+ async function applyFeatures(c: Client, appId: number, f: Partial<Features>, notes: string[]) {
75
+ if (f.access) {
76
+ await c.query(
77
+ `insert into meta.authz_scheme (app_id, name, type, value, error_message) values
78
+ ($1, 'CONTRIBUTOR', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'')', 'Only contributors can do this.'),
79
+ ($1, 'READER', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'') or meta.has_role(''reader'')', 'Only readers can open this page.')
80
+ on conflict do nothing`,
81
+ [appId],
82
+ );
83
+ }
84
+ if (f.activity && !(await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, ACTIVITY_PAGE])).rowCount) {
85
+ const home = (await c.query('select home_page from meta.app where id = $1', [appId])).rows[0].home_page;
86
+ const page = (await c.query(`insert into meta.page (app_id, page_no, name, title, parent_page, authz) values ($1, $2, 'Activity', 'Activity', $3, 'ADMIN') returning id`, [appId, ACTIVITY_PAGE, home])).rows[0].id;
87
+ await c.query(
88
+ `insert into meta.region (page_id, seq, title, type, source, config) values
89
+ ($1, 10, 'Page views per day', 'chart', $2, '{"kind": "column"}'),
90
+ ($1, 20, 'Latest activity', 'report', $3, '{"headings": {"at": "Time", "username": "User", "event": "Event", "page_no": "Page", "elapsed_ms": "Time taken (ms)"}}')`,
91
+ [page,
92
+ `select to_char(date_trunc('day', at), 'YYYY-MM-DD') as "Day", count(*) as "Page views"
93
+ from meta.app_activity(30)
94
+ where event = 'page_view'
95
+ group by 1
96
+ order by 1`,
97
+ `select at, username, event, page_no, elapsed_ms
98
+ from meta.app_activity(30)`],
99
+ );
100
+ await c.query(`insert into meta.nav_entry (app_id, seq, label, icon, target_page, authz) values ($1, 9010, 'Activity', 'activity', $2, 'ADMIN')`, [appId, ACTIVITY_PAGE]);
101
+ }
102
+ if (f.feedback) await c.query('update meta.app set feedback = true where id = $1', [appId]);
103
+ if (f.theme === false) await c.query(`update meta.app set theme = theme || '{"user_choice": false}' where id = $1`, [appId]);
104
+ if (f.pwa || f.push) await c.query('update meta.app set pwa = true where id = $1', [appId]);
105
+ if (f.push) {
106
+ if (secretKeyConfigured()) await c.query('update meta.app set pwa_push = true where id = $1', [appId]);
107
+ else notes.push('Push notifications are off: they need PGKILN_SECRET_KEY in the server\'s environment.');
108
+ }
109
+ }
110
+
111
+ /** (sprint 72) After a wizard generated the pages: the home page's cards and, unless turned off, the About page; (sprint 73) the chosen features. */
112
+ export async function finishGeneratedApp(c: Client, appId: number, o: { about?: boolean; description?: string | null; features?: Partial<Features>; notes?: string[] } = {}) {
113
+ const f = { ...(o.features ?? {}) };
114
+ if (f.about === undefined) f.about = o.about !== false;
115
+ await applyFeatures(c, appId, f, o.notes ?? []);
116
+ await addHomeCards(c, appId);
117
+ if (f.about) await addAboutPage(c, appId, o.description);
118
+ }
119
+
120
+ /**
121
+ * (097) A card per page of the navigation menu on the home page (a static list
122
+ * HOME_PAGES shown by a list region with the cards template), after the pages
123
+ * of a new application were generated. Does nothing when the application has
124
+ * no other pages, or the list exists already.
125
+ */
126
+ export async function addHomeCards(c: Client, appId: number) {
127
+ const entries = (
128
+ await c.query<{ label: string; icon: string | null; target_page: number; authz: string | null }>(
129
+ `select e.label, e.icon, e.target_page, e.authz from meta.nav_entry e join meta.app a on a.id = e.app_id
130
+ where e.app_id = $1 and e.parent_id is null and e.target_page is not null and e.target_page <> a.home_page
131
+ order by e.seq, e.id`,
132
+ [appId],
133
+ )
134
+ ).rows;
135
+ if (!entries.length) return;
136
+ const made = await c.query(`insert into meta.list (app_id, name, description) values ($1, 'HOME_PAGES', 'The cards of the home page') on conflict (app_id, name) do nothing`, [appId]);
137
+ if (!made.rowCount) return;
138
+ for (const [n, e] of entries.entries())
139
+ await c.query('insert into meta.list_entry (app_id, list_name, seq, label, icon, target_page, authz) values ($1, $2, $3, $4, $5, $6, $7)', [appId, 'HOME_PAGES', (n + 1) * 10, e.label, e.icon, e.target_page, e.authz]);
140
+ await c.query(
141
+ `insert into meta.region (page_id, seq, title, type, template, config)
142
+ select p.id, 20, 'Pages', 'list', 'plain', '{"list": "HOME_PAGES", "template": "cards"}' from meta.page p join meta.app a on a.id = p.app_id and p.page_no = a.home_page where a.id = $1`,
143
+ [appId],
144
+ );
145
+ }
7
146
 
8
147
  // Creating an application (Create → blank application, and Create → from a
9
148
  // file): the parsing schema, a database role app_<alias> that can use only
@@ -18,6 +157,15 @@ export interface NewAppInput {
18
157
  authentication?: string;
19
158
  admin_user?: string;
20
159
  admin_password?: string;
160
+ /** (097) a line about the application, shown on the home page */
161
+ description?: string;
162
+ /** (097) the application's icon (a name from icons.svg) */
163
+ icon?: string;
164
+ /** (sprint 73) appearance: base style, menu position, accent colour; the primary language */
165
+ base?: string;
166
+ nav?: string;
167
+ accent?: string;
168
+ language?: string;
21
169
  }
22
170
 
23
171
  export interface CheckedApp {
@@ -31,6 +179,16 @@ export interface CheckedApp {
31
179
  adminPassword: string | null;
32
180
  /** the workspace that gets the application (the developer's current one) */
33
181
  workspaceId: number;
182
+ /** (097) shown under the name on the home page */
183
+ description?: string | null;
184
+ /** (097) the home page's icon */
185
+ icon?: string | null;
186
+ /** (sprint 73) the theme: base style (Iris when not given), menu at the top, own accent colour */
187
+ base?: 'iris' | 'lavender' | 'standard';
188
+ topNav?: boolean;
189
+ accent?: string | null;
190
+ /** (sprint 73) the primary language (default en) */
191
+ language?: string;
34
192
  }
35
193
 
36
194
  /** pgkiln's and the system's schemas never become an application's parsing schema. */
@@ -56,7 +214,17 @@ export async function checkNewApp(b: NewAppInput, workspaceId: number): Promise<
56
214
  if (problem) throw new Error(problem);
57
215
  adminPassword = known ? null : (b.admin_password ?? '');
58
216
  }
59
- return { name, alias, schema, role: `app_${alias.replace(/-/g, '_')}`, authentication, adminUser, adminPassword, workspaceId };
217
+ const description = (b.description ?? '').trim().slice(0, 300) || null;
218
+ const appIcon = (b.icon ?? '').trim();
219
+ if (appIcon && !isIcon(appIcon)) throw new Error('Choose one of the icons in the list.');
220
+ const base = b.base === 'lavender' || b.base === 'standard' ? b.base : 'iris';
221
+ const accent = /^#[0-9a-f]{6}$/i.test(b.accent ?? '') && b.accent!.toLowerCase() !== BASE_STYLES[base].accent ? b.accent!.toLowerCase() : null;
222
+ const language = (b.language ?? '').trim() || 'en';
223
+ if (!(language in LANGUAGE_NAMES)) throw new Error('Choose one of the languages in the list.');
224
+ return {
225
+ name, alias, schema, role: `app_${alias.replace(/-/g, '_')}`, authentication, adminUser, adminPassword, workspaceId, description, icon: appIcon || null,
226
+ base, topNav: b.nav === 'top', accent, language,
227
+ };
60
228
  }
61
229
 
62
230
  /** Create the schema, the role and the application (inside the caller's owner transaction). */
@@ -93,12 +261,21 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
93
261
  await c.query(`alter default privileges in schema ${S} grant usage, select on sequences to ${R}`);
94
262
  await c.query(`alter default privileges in schema ${S} grant execute on functions to ${R}`);
95
263
 
96
- // new applications start with the newest base style (Iris); existing ones keep theirs
97
- const r = await c.query('insert into meta.app (alias, name, authentication, db_role, theme) values ($1, $2, $3, $4, $5) returning id', [a.alias, a.name, a.authentication, a.role, { base: NEW_APP_BASE }]);
264
+ // new applications start with the newest base style (Iris), light (users may switch to dark); existing ones keep theirs
265
+ // (sprint 73) the wizard's appearance: base style, menu at the top, an own accent colour
266
+ const theme: Record<string, string> = { mode: 'light' };
267
+ const base = a.base ?? NEW_APP_BASE;
268
+ if (base !== 'standard') theme.base = base;
269
+ if (a.topNav) theme.nav = 'top';
270
+ if (a.accent) theme.accent = a.accent;
271
+ const r = await c.query('insert into meta.app (alias, name, authentication, db_role, theme, language) values ($1, $2, $3, $4, $5, $6) returning id', [a.alias, a.name, a.authentication, a.role, theme, a.language ?? 'en']);
98
272
  const appId = r.rows[0].id as number;
99
273
  await placeApp(c, appId, a.workspaceId);
100
274
  const p = await c.query(`insert into meta.page (app_id, page_no, name, title) values ($1, 1, 'Home', 'Home') returning id`, [appId]);
101
- await c.query(`insert into meta.region (page_id, title, type, source) values ($1, 'Welcome', 'static', '<p>Hello, &APP_USER.! Edit this page in the builder.</p>')`, [p.rows[0].id]);
275
+ // (097) a hero with the application's icon, name and description (no developer wording for end users)
276
+ await c.query(`insert into meta.region (page_id, seq, title, type, source, template, config) values ($1, 10, $2, 'static', $3, 'hero', $4)`, [
277
+ p.rows[0].id, a.name, a.description ? `<p>${escapeHtml(a.description)}</p>` : '', { icon: a.icon ?? DEFAULT_APP_ICON },
278
+ ]);
102
279
  await c.query(`insert into meta.nav_entry (app_id, seq, label, icon, target_page) values ($1, 1, 'Home', 'home', 1)`, [appId]);
103
280
  await c.query(`insert into meta.authz_scheme (app_id, name, type, value, error_message) values ($1, 'ADMIN', 'role', 'admin', 'Only administrators can access this page.')`, [appId]);
104
281
  let existingAccount = false;
@@ -17,11 +17,10 @@ import { APP_COLORS, appHeader, back, BASE, builderHead, csrf, developer, flash,
17
17
  import { appOr404 } from './forms.ts';
18
18
  import { docToFiles, filesToZip } from '../appfiles.ts';
19
19
  import { homeRoutes, rememberApp } from './home.ts';
20
- import { appAllowed, currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
20
+ import { currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
21
21
  import { instanceRoutes } from './instance.ts';
22
22
  import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
23
23
  import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
24
- import { checkNewApp, createApp, createAppError, startFromBoilerplate } from './newapp.ts';
25
24
  import { appFromFileRoutes } from './appfromfile.ts';
26
25
  import { clearDevbar } from '../runtime/devbar.ts';
27
26
  import { EMAIL } from './find-workspace.ts';
@@ -187,30 +186,7 @@ export async function builderRoutes(app: FastifyInstance) {
187
186
  await wizardRoutes(app);
188
187
  await appFromFileRoutes(app);
189
188
 
190
- app.post(`${BASE}/apps`, async (req: Req, reply) => {
191
- const s = await developer(req, reply);
192
- if (!s) return;
193
- const b = req.body ?? {};
194
- try {
195
- const checked = await checkNewApp(b, currentWorkspace(s));
196
- const boilerplate = /^\d{1,9}$/.test(b.boilerplate ?? '') ? Number(b.boilerplate) : null;
197
- if (boilerplate && !(await appAllowed(s, boilerplate))) throw new Error('Choose a boilerplate application (an application of type Boilerplate).');
198
- const { id, existingAccount } = await owner.tx(async (c) => {
199
- const made = await createApp(c, checked);
200
- if (boilerplate) await startFromBoilerplate(c, made.id, checked, boilerplate);
201
- return made;
202
- }).catch((e) => {
203
- throw new Error(createAppError(e, checked.alias));
204
- });
205
- flash(s, existingAccount
206
- ? `Application created. The existing account ${checked.adminUser} got the admin role (its password was not changed).`
207
- : 'Application created.');
208
- return back(reply, s, `${BASE}/apps/${id}`);
209
- } catch (e) {
210
- flash(s, (e as Error).message, 'error');
211
- return back(reply, s, `${BASE}/create`);
212
- }
213
- });
189
+ // (sprint 73) POST /apps, creating an application, is in appwizard.ts
214
190
 
215
191
  app.post(`${BASE}/import`, async (req: Req, reply) => {
216
192
  const s = await developer(req, reply);
@@ -425,7 +401,7 @@ export async function builderRoutes(app: FastifyInstance) {
425
401
  : html`<p class="muted">No identity providers configured. <a href="${BASE}/users/providers">Add one</a> for single sign-on.</p>`}
426
402
  <h3>Theme</h3>
427
403
  <div class="form-grid">
428
- ${select('base', 'Base style', baseStyleOf(a.theme), [['iris', 'Iris (the default for new applications)'], ['standard', 'Standard (pgkiln until 0.28)']], 'Colours, corners, font and shadows of the whole application, light and dark. The colours below override its accent and header.')}
404
+ ${select('base', 'Base style', baseStyleOf(a.theme), [['iris', 'Iris (the default for new applications)'], ['lavender', 'Lavender (Iris until pgkiln 0.41)'], ['standard', 'Standard (pgkiln until 0.28)']], 'Colours, corners, font and shadows of the whole application, light and dark. The colours below override its accent and header.')}
429
405
  ${input('accent', 'Accent colour', a.theme?.accent ?? BASE_STYLES[baseStyleOf(a.theme)].accent, { type: 'color' })}
430
406
  ${input('header', 'Header colour', a.theme?.header ?? BASE_STYLES[baseStyleOf(a.theme)].header, { type: 'color' })}
431
407
  ${(['accent_dark', 'header_dark'] as const).map((k) => html`<div class="field"><label class="label" for="f_${k}">${k === 'accent_dark' ? 'Accent colour in dark mode' : 'Header colour in dark mode'}</label>
@@ -516,7 +492,7 @@ export async function builderRoutes(app: FastifyInstance) {
516
492
  app_type = $28, session_group = $29, updated_at = now() where id = $1`,
517
493
  [req.params.id, b.name?.trim(), b.alias?.trim().toLowerCase(), Number(b.home_page) || 1, b.authentication, b.db_role?.trim() || null, b.debug === 'true',
518
494
  JSON.stringify({
519
- base: baseStyleOf({ base: b.base }) === 'iris' ? 'iris' : undefined,
495
+ base: baseStyleOf({ base: b.base }) === 'standard' ? undefined : baseStyleOf({ base: b.base }),
520
496
  accent_dark: b.accent_dark_own === 'true' && /^#[0-9a-f]{6}$/i.test(b.accent_dark ?? '') ? b.accent_dark!.toLowerCase() : undefined,
521
497
  header_dark: b.header_dark_own === 'true' && /^#[0-9a-f]{6}$/i.test(b.header_dark ?? '') ? b.header_dark!.toLowerCase() : undefined,
522
498
  // a colour equal to a base style's own is not stored, so changing the base style changes it too
package/src/i18n/ar.ts CHANGED
@@ -148,6 +148,7 @@ export const ar: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} نموذج في انتظار الإرسال",
149
149
  'pwa.send_now': "إرسال الآن",
150
150
  'pwa.discard': "تجاهل",
151
+ 'pwa.install': "تثبيت التطبيق",
151
152
  'pwa.status.waiting': "في الانتظار",
152
153
  'pwa.status.signin': "سجّل الدخول للإرسال",
153
154
  'pwa.status.invalid': "مرفوض: افتح الصفحة وصحّحه",
package/src/i18n/cs.ts CHANGED
@@ -148,6 +148,7 @@ export const cs: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "Formuláře čekající na odeslání: {n}",
149
149
  'pwa.send_now': "Odeslat hned",
150
150
  'pwa.discard': "Zahodit",
151
+ 'pwa.install': "Nainstalovat aplikaci",
151
152
  'pwa.status.waiting': "čeká",
152
153
  'pwa.status.signin': "pro odeslání se přihlaste",
153
154
  'pwa.status.invalid': "odmítnuto: otevřete stránku a opravte ho",
package/src/i18n/da.ts CHANGED
@@ -148,6 +148,7 @@ export const da: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} formular(er) venter på at blive sendt",
149
149
  'pwa.send_now': "Send nu",
150
150
  'pwa.discard': "Kassér",
151
+ 'pwa.install': "Installér appen",
151
152
  'pwa.status.waiting': "venter",
152
153
  'pwa.status.signin': "log ind for at sende",
153
154
  'pwa.status.invalid': "afvist: åbn siden og ret den",
package/src/i18n/de.ts CHANGED
@@ -148,6 +148,7 @@ export const de: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': '{n} Formular(e) warten auf den Versand',
149
149
  'pwa.send_now': 'Jetzt senden',
150
150
  'pwa.discard': 'Verwerfen',
151
+ 'pwa.install': "App installieren",
151
152
  'pwa.status.waiting': 'wartet',
152
153
  'pwa.status.signin': 'zum Senden anmelden',
153
154
  'pwa.status.invalid': 'abgelehnt: Öffnen Sie die Seite und korrigieren Sie es',
package/src/i18n/el.ts CHANGED
@@ -148,6 +148,7 @@ export const el: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} φόρμα(-ες) περιμένουν αποστολή",
149
149
  'pwa.send_now': "Αποστολή τώρα",
150
150
  'pwa.discard': "Απόρριψη",
151
+ 'pwa.install': "Εγκατάσταση εφαρμογής",
151
152
  'pwa.status.waiting': "σε αναμονή",
152
153
  'pwa.status.signin': "συνδεθείτε για αποστολή",
153
154
  'pwa.status.invalid': "απορρίφθηκε: ανοίξτε τη σελίδα και διορθώστε την",
package/src/i18n/es.ts CHANGED
@@ -148,6 +148,7 @@ export const es: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': '{n} formulario(s) pendiente(s) de envío',
149
149
  'pwa.send_now': 'Enviar ahora',
150
150
  'pwa.discard': 'Descartar',
151
+ 'pwa.install': "Instalar la aplicación",
151
152
  'pwa.status.waiting': 'pendiente',
152
153
  'pwa.status.signin': 'inicie sesión para enviar',
153
154
  'pwa.status.invalid': 'rechazado: abra la página y corríjalo',
package/src/i18n/fi.ts CHANGED
@@ -148,6 +148,7 @@ export const fi: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} lomaketta odottaa lähetystä",
149
149
  'pwa.send_now': "Lähetä nyt",
150
150
  'pwa.discard': "Hylkää",
151
+ 'pwa.install': "Asenna sovellus",
151
152
  'pwa.status.waiting': "odottaa",
152
153
  'pwa.status.signin': "kirjaudu lähettääksesi",
153
154
  'pwa.status.invalid': "hylätty: avaa sivu ja korjaa",
package/src/i18n/fr.ts CHANGED
@@ -148,6 +148,7 @@ export const fr: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': '{n} formulaire(s) en attente d’envoi',
149
149
  'pwa.send_now': 'Envoyer maintenant',
150
150
  'pwa.discard': 'Abandonner',
151
+ 'pwa.install': "Installer l'application",
151
152
  'pwa.status.waiting': 'en attente',
152
153
  'pwa.status.signin': 'connectez-vous pour envoyer',
153
154
  'pwa.status.invalid': 'refusé : ouvrez la page et corrigez-le',
package/src/i18n/he.ts CHANGED
@@ -148,6 +148,7 @@ export const he: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} טפסים ממתינים לשליחה",
149
149
  'pwa.send_now': "שליחה עכשיו",
150
150
  'pwa.discard': "ביטול",
151
+ 'pwa.install': "התקנת האפליקציה",
151
152
  'pwa.status.waiting': "ממתין",
152
153
  'pwa.status.signin': "יש להיכנס כדי לשלוח",
153
154
  'pwa.status.invalid': "נדחה: פתחו את הדף ותקנו",
package/src/i18n/it.ts CHANGED
@@ -148,6 +148,7 @@ export const it: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} modulo/i in attesa di invio",
149
149
  'pwa.send_now': "Invia ora",
150
150
  'pwa.discard': "Scarta",
151
+ 'pwa.install': "Installa l'app",
151
152
  'pwa.status.waiting': "in attesa",
152
153
  'pwa.status.signin': "accedi per inviare",
153
154
  'pwa.status.invalid': "rifiutato: apri la pagina e correggilo",
package/src/i18n/ja.ts CHANGED
@@ -148,6 +148,7 @@ export const ja: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "送信待ちのフォーム: {n} 件",
149
149
  'pwa.send_now': "今すぐ送信",
150
150
  'pwa.discard': "破棄",
151
+ 'pwa.install': "アプリをインストール",
151
152
  'pwa.status.waiting': "待機中",
152
153
  'pwa.status.signin': "送信するにはサインイン",
153
154
  'pwa.status.invalid': "拒否: ページを開いて修正してください",
package/src/i18n/ko.ts CHANGED
@@ -148,6 +148,7 @@ export const ko: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "전송 대기 중인 양식 {n}개",
149
149
  'pwa.send_now': "지금 보내기",
150
150
  'pwa.discard': "버리기",
151
+ 'pwa.install': "앱 설치",
151
152
  'pwa.status.waiting': "대기 중",
152
153
  'pwa.status.signin': "보내려면 로그인하세요",
153
154
  'pwa.status.invalid': "거부됨: 페이지를 열어 수정하세요",
package/src/i18n/nb.ts CHANGED
@@ -148,6 +148,7 @@ export const nb: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} skjema(er) venter på å bli sendt",
149
149
  'pwa.send_now': "Send nå",
150
150
  'pwa.discard': "Forkast",
151
+ 'pwa.install': "Installer appen",
151
152
  'pwa.status.waiting': "venter",
152
153
  'pwa.status.signin': "logg inn for å sende",
153
154
  'pwa.status.invalid': "avvist: åpne siden og rett det",
package/src/i18n/pl.ts CHANGED
@@ -148,6 +148,7 @@ export const pl: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "Formularze czekające na wysłanie: {n}",
149
149
  'pwa.send_now': "Wyślij teraz",
150
150
  'pwa.discard': "Odrzuć",
151
+ 'pwa.install': "Zainstaluj aplikację",
151
152
  'pwa.status.waiting': "oczekuje",
152
153
  'pwa.status.signin': "zaloguj się, aby wysłać",
153
154
  'pwa.status.invalid': "odrzucony: otwórz stronę i popraw go",
package/src/i18n/pt.ts CHANGED
@@ -148,6 +148,7 @@ export const pt: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} formulário(s) a aguardar envio",
149
149
  'pwa.send_now': "Enviar agora",
150
150
  'pwa.discard': "Descartar",
151
+ 'pwa.install': "Instalar a aplicação",
151
152
  'pwa.status.waiting': "a aguardar",
152
153
  'pwa.status.signin': "inicie sessão para enviar",
153
154
  'pwa.status.invalid': "rejeitado: abra a página e corrija-o",
package/src/i18n/ru.ts CHANGED
@@ -148,6 +148,7 @@ export const ru: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "Форм ожидают отправки: {n}",
149
149
  'pwa.send_now': "Отправить сейчас",
150
150
  'pwa.discard': "Отменить",
151
+ 'pwa.install': "Установить приложение",
151
152
  'pwa.status.waiting': "ожидает",
152
153
  'pwa.status.signin': "войдите, чтобы отправить",
153
154
  'pwa.status.invalid': "отклонено: откройте страницу и исправьте",
package/src/i18n/sv.ts CHANGED
@@ -148,6 +148,7 @@ export const sv: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} formulär väntar på att skickas",
149
149
  'pwa.send_now': "Skicka nu",
150
150
  'pwa.discard': "Kasta",
151
+ 'pwa.install': "Installera appen",
151
152
  'pwa.status.waiting': "väntar",
152
153
  'pwa.status.signin': "logga in för att skicka",
153
154
  'pwa.status.invalid': "avvisat: öppna sidan och rätta det",
package/src/i18n/tr.ts CHANGED
@@ -148,6 +148,7 @@ export const tr: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "Gönderilmeyi bekleyen {n} form",
149
149
  'pwa.send_now': "Şimdi gönder",
150
150
  'pwa.discard': "At",
151
+ 'pwa.install': "Uygulamayı yükle",
151
152
  'pwa.status.waiting': "bekliyor",
152
153
  'pwa.status.signin': "göndermek için oturum açın",
153
154
  'pwa.status.invalid': "reddedildi: sayfayı açıp düzeltin",
package/src/i18n/uk.ts CHANGED
@@ -148,6 +148,7 @@ export const uk: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "Форм очікують надсилання: {n}",
149
149
  'pwa.send_now': "Надіслати зараз",
150
150
  'pwa.discard': "Відкинути",
151
+ 'pwa.install': "Встановити застосунок",
151
152
  'pwa.status.waiting': "очікує",
152
153
  'pwa.status.signin': "увійдіть, щоб надіслати",
153
154
  'pwa.status.invalid': "відхилено: відкрийте сторінку й виправте",
package/src/i18n/zh.ts CHANGED
@@ -148,6 +148,7 @@ export const zh: Record<MessageKey, string> = {
148
148
  'pwa.queue_waiting': "{n} 个表单等待发送",
149
149
  'pwa.send_now': "立即发送",
150
150
  'pwa.discard': "放弃",
151
+ 'pwa.install': "安装应用",
151
152
  'pwa.status.waiting': "等待中",
152
153
  'pwa.status.signin': "登录后发送",
153
154
  'pwa.status.invalid': "被拒绝:请打开页面并更正",
package/src/i18n.ts CHANGED
@@ -175,6 +175,7 @@ const en = {
175
175
  'pwa.queue_waiting': '{n} form(s) waiting to be sent',
176
176
  'pwa.send_now': 'Send now',
177
177
  'pwa.discard': 'Discard',
178
+ 'pwa.install': 'Install app',
178
179
  'pwa.status.waiting': 'waiting',
179
180
  'pwa.status.signin': 'sign in to send',
180
181
  'pwa.status.invalid': 'rejected: open the page and correct it',
@@ -863,6 +864,7 @@ const nl: Record<MessageKey, string> = {
863
864
  'pwa.queue_waiting': '{n} formulier(en) wachten op verzending',
864
865
  'pwa.send_now': 'Nu versturen',
865
866
  'pwa.discard': 'Weggooien',
867
+ 'pwa.install': 'App installeren',
866
868
  'pwa.status.waiting': 'wacht',
867
869
  'pwa.status.signin': 'meld je aan om te versturen',
868
870
  'pwa.status.invalid': 'geweigerd: open de pagina en verbeter het',
package/src/icons.ts CHANGED
@@ -63,7 +63,8 @@ export const ICON_MODIFIERS = Object.keys(MODIFIERS);
63
63
  export function iconParts(value: string | null | undefined): { name: string; set: 'pgkiln' | 'lucide'; classes: string[] } | null {
64
64
  if (!value) return null;
65
65
  const tokens = String(value).trim().toLowerCase().split(/\s+/).slice(0, 8).map((t) => (t.startsWith('fa-') ? t.slice(3) : t)).filter((t) => t && t !== 'fa');
66
- const name = tokens.find((t) => !(t in MODIFIERS));
66
+ // (sprint 72) "info" is a modifier and an icon: alone, it is the icon
67
+ const name = tokens.find((t) => !(t in MODIFIERS)) ?? (tokens.length === 1 && (ICONS as readonly string[]).includes(tokens[0]) ? tokens[0] : undefined);
67
68
  if (!name) return null;
68
69
  const set = (ICONS as readonly string[]).includes(name) ? 'pgkiln' : lucideNames().has(name) ? 'lucide' : null;
69
70
  if (!set) return null;
package/src/layout.ts CHANGED
@@ -7,13 +7,13 @@ export interface RootAttrs {
7
7
  dir?: 'ltr' | 'rtl';
8
8
  theme?: 'auto' | 'light' | 'dark';
9
9
  /** a base style other than Standard (src/runtime/styles.ts BASE_STYLES) */
10
- style?: 'iris';
10
+ style?: 'iris' | 'lavender';
11
11
  }
12
12
 
13
13
  export function documentShell(title: string, body: Raw, bodyClass = '', data: Record<string, string> = {}, head: Raw | '' = '', root: RootAttrs = {}) {
14
14
  const attrs = Object.entries(data).map(([k, v]) => html` ${k}="${v}"`);
15
15
  return html`<!doctype html>
16
- <html lang="${root.lang ?? 'en'}"${root.dir === 'rtl' ? html` dir="rtl"` : ''}${root.theme && root.theme !== 'auto' ? html` data-theme="${root.theme}"` : ''}${root.style === 'iris' ? html` data-style="iris"` : ''}>
16
+ <html lang="${root.lang ?? 'en'}"${root.dir === 'rtl' ? html` dir="rtl"` : ''}${root.theme && root.theme !== 'auto' ? html` data-theme="${root.theme}"` : ''}${root.style === 'iris' || root.style === 'lavender' ? html` data-style="${root.style}"` : ''}>
17
17
  <head>
18
18
  <meta charset="utf-8">
19
19
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -173,18 +173,23 @@ const tip = (label: string, series: Series[], i: number) =>
173
173
  // ---------------------------------------------------------------- bar (horizontal)
174
174
  function bar(labels: string[], series: Series[]) {
175
175
  const all = series.flatMap((s) => s.values);
176
- const max = Math.max(...all.map(Math.abs), 1);
176
+ // (097) a value axis with gridlines: bars are measured against a nice scale from zero
177
+ const scale = niceScale(0, Math.max(...all.map(Math.abs), 1));
178
+ const max = scale.hi || 1;
179
+ const ticks = scale.ticks.filter((t) => t >= 0 && t <= max);
180
+ const at = (t: number) => css.cls(`left:${pct((t / max) * 100)}`);
181
+ const grid = html`<span class="bar-grid" aria-hidden="true">${ticks.map((t) => html`<span class="bar-gridline${t === 0 ? ' baseline' : ''} ${at(t)}"></span>`)}</span>`;
177
182
  const multi = series.length > 1;
178
183
  return html`<div class="chart-bar${multi ? ' multi' : ''}">${labels.map((l, i) => {
179
184
  const inner = html`<span class="bar-label" title="${l}">${l}</span>
180
- <span class="bar-stack">${series.map((s, si) => {
185
+ <span class="bar-stack">${grid}${series.map((s, si) => {
181
186
  const b = html`<span class="bar s${si + 1} ${css.cls(`width:${pct((Math.abs(s.values[i]) / max) * 100)}`)}"></span>`;
182
187
  // several series: each bar is its own link
183
188
  return multi && link ? mark('span', i, si, 'bar-track', `${l} · ${s.name}: ${fmt.format(s.values[i])}`, b, false) : html`<span class="bar-track">${b}</span>`;
184
189
  })}</span>
185
190
  <span class="bar-value">${multi ? '' : fmt.format(series[0].values[i])}</span>`;
186
191
  return !multi && link ? mark('div', i, 0, 'bar-row', tip(l, series, i), inner) : html`<div class="bar-row" data-tip="${tip(l, series, i)}">${inner}</div>`;
187
- })}</div>`;
192
+ })}<div class="bar-axis" aria-hidden="true"><span></span><span class="bar-ticks">${ticks.map((t) => html`<span class="${at(t)}">${compact.format(t)}</span>`)}</span><span></span></div></div>`;
188
193
  }
189
194
 
190
195
  // ---------------------------------------------------------------- shared y axis