@arcforgelabs/flow 2026.10.42 → 2026.10.44

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 (40) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +4 -3
  3. package/package.json +5 -3
  4. package/server/auth/people.js +40 -2
  5. package/server/auth/routes.js +11 -0
  6. package/server/auth/session.js +22 -3
  7. package/server/core/custom-fields.js +4 -3
  8. package/server/core/invoice-rules.js +8 -3
  9. package/server/core/job-match.js +6 -2
  10. package/server/core/sending.js +4 -3
  11. package/server/integrations/sentry/index.js +9 -1
  12. package/server/release-channel.js +5 -2
  13. package/server/ui/app-page.js +1 -1
  14. package/server/ui/app.js +46 -162
  15. package/server/ui/automations.js +9 -3
  16. package/server/ui/columns.js +268 -0
  17. package/server/ui/custom-fields.js +17 -16
  18. package/server/ui/flow.css +268 -55
  19. package/server/ui/invoice-notes.js +7 -5
  20. package/server/ui/invoice-rules.js +3 -3
  21. package/server/ui/invoicing-settings.js +92 -35
  22. package/server/ui/invoicing.js +206 -119
  23. package/server/ui/job-board.js +20 -8
  24. package/server/ui/job-change.js +36 -0
  25. package/server/ui/job-view.js +255 -60
  26. package/server/ui/jobs.js +197 -145
  27. package/server/ui/kit.js +278 -0
  28. package/server/ui/list-views.js +73 -0
  29. package/server/ui/monitoring.js +1 -0
  30. package/server/ui/person.js +29 -0
  31. package/server/ui/price-list.js +2 -3
  32. package/server/ui/price-reconcile.js +7 -8
  33. package/server/ui/reconcile.js +114 -61
  34. package/server/ui/routes.js +1 -1
  35. package/server/ui/saved-views.js +141 -0
  36. package/server/ui/search.js +8 -0
  37. package/server/ui/sidecar.js +72 -0
  38. package/server/ui/tip.js +99 -0
  39. package/server/ui/view-bar.js +176 -70
  40. package/server/ui/xero-import.js +7 -10
package/server/ui/app.js CHANGED
@@ -10,36 +10,23 @@ import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/as
10
10
  import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
11
11
  import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
12
12
  import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
13
- import { automationSection } from '/assets/ui/automations.js';
13
+ import { automationSection, renderAutomations } from '/assets/ui/automations.js';
14
14
  import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
15
15
  import { renderPriceList } from '/assets/ui/price-list.js';
16
16
  import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
17
- import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
17
+ import { renderCustomFields } from '/assets/ui/custom-fields.js';
18
18
  import { renderJobs } from '/assets/ui/jobs.js';
19
19
  import { renderReconcile } from '/assets/ui/reconcile.js';
20
20
  import { renderXeroImport } from '/assets/ui/xero-import.js';
21
21
  import { startMonitoring } from '/assets/ui/monitoring.js';
22
- import { openSearch } from '/assets/ui/search.js';
22
+ import { isSearchKey, openSearch } from '/assets/ui/search.js';
23
23
  import { alertStrip } from '/assets/ui/alerts.js';
24
+ import { initialsOf, personLabel } from '/assets/ui/person.js';
25
+ import { startTips } from '/assets/ui/tip.js';
26
+ import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
24
27
 
25
28
  const $ = (id) => document.getElementById(id);
26
29
 
27
- // h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
28
- function h(tag, attrs = {}, ...children) {
29
- const node = document.createElement(tag);
30
- for (const [key, value] of Object.entries(attrs ?? {})) {
31
- if (value == null || value === false) continue;
32
- if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
33
- else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
34
- else node.setAttribute(key, value === true ? '' : String(value));
35
- }
36
- for (const child of children.flat()) {
37
- if (child == null || child === false) continue;
38
- node.append(child instanceof Node ? child : document.createTextNode(String(child)));
39
- }
40
- return node;
41
- }
42
-
43
30
  const ROLE_LABEL = { owner: 'Owner', admin: 'Admin', staff: 'Staff' };
44
31
  const ROLE_HINT = {
45
32
  owner: 'Manages people, connections and every record.',
@@ -136,83 +123,8 @@ async function flow(path, options = {}) {
136
123
  return body;
137
124
  }
138
125
 
139
- // Keeps a button off for `ms`, with the time left on it, then puts it back.
140
- function holdButton(button, ms) {
141
- if (!button || !(ms > 0)) return;
142
- clearInterval(button.fxHold);
143
- const label = button.dataset.label ?? button.textContent;
144
- button.dataset.label = label;
145
- const until = Date.now() + ms;
146
- const draw = () => {
147
- const left = Math.ceil((until - Date.now()) / 1000);
148
- if (left <= 0) {
149
- clearInterval(button.fxHold);
150
- button.textContent = label;
151
- delete button.dataset.label;
152
- button.removeAttribute('title');
153
- button.disabled = false;
154
- return;
155
- }
156
- const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
157
- button.disabled = true;
158
- button.textContent = `${label} · ${time}`;
159
- button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
160
- };
161
- draw();
162
- button.fxHold = setInterval(draw, 1000);
163
- }
164
-
165
126
  // ── Shared pieces ──────────────────────────────────────────────────────────
166
127
 
167
- function pageHeader(title, { subtitle, actions = [] } = {}) {
168
- return h('header', { class: 'fx-ph' },
169
- h('div', { class: 'fx-ph__text' },
170
- h('h1', { class: 'fx-title' }, title),
171
- subtitle ? h('p', { class: 'fx-ph__sub' }, subtitle) : null),
172
- actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
173
- }
174
-
175
- function notice(text, tone = 'info', title = null) {
176
- return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
177
- title ? h('strong', {}, title) : null,
178
- h('span', {}, text));
179
- }
180
-
181
- function toast(text, tone = 'info') {
182
- const region = $('toasts');
183
- const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
184
- region.append(item);
185
- setTimeout(() => item.remove(), 4200);
186
- }
187
-
188
- // A modal built on <dialog>: resolves with the button's value, or null.
189
- function dialog({ title, body, confirm, tone = 'primary' }) {
190
- return new Promise((resolve) => {
191
- const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
192
- h('h2', { class: 'fx-dialog__title' }, title),
193
- body,
194
- h('div', { class: 'fx-dialog__actions' },
195
- h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
196
- h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
197
- const element = h('dialog', { class: 'fx-dialog' }, form);
198
- element.addEventListener('close', () => {
199
- const ok = element.returnValue === 'ok';
200
- const data = ok ? new FormData(form) : null;
201
- element.remove();
202
- resolve(ok ? data : null);
203
- });
204
- document.body.append(element);
205
- element.showModal();
206
- element.querySelector('input, select, button[value="ok"]')?.focus();
207
- });
208
- }
209
-
210
- function initials(person) {
211
- const source = (person.name || person.email || '?').replace(/@.*/, '');
212
- const parts = source.split(/[\s._-]+/).filter(Boolean);
213
- return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
214
- }
215
-
216
128
  function statusPill(person) {
217
129
  if (person.status === 'pending') return h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Waiting for approval');
218
130
  if (person.status === 'disabled') return h('span', { class: 'fx-status', 'data-tone': 'mute' }, 'Turned off');
@@ -235,7 +147,8 @@ const VIEWS = {
235
147
  jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
236
148
  invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
237
149
  reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
238
- 'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
150
+ 'invoicing-settings': { label: 'Rules', icon: 'rules', group: 'Settings', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
151
+ automations: { label: 'Automations', icon: 'bolt', group: 'Settings', render: (main) => renderAutomations(main, ui()) },
239
152
  'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
240
153
  'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
241
154
  people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
@@ -252,6 +165,8 @@ const VIEWS = {
252
165
  const ICON_PATHS = {
253
166
  jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
254
167
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
168
+ bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
169
+ rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
255
170
  reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
256
171
  people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
257
172
  tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
@@ -292,11 +207,11 @@ function drawMenu() {
292
207
  const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the sidebar' : 'Hide the sidebar');
293
208
  toggle.setAttribute('aria-expanded', String(open));
294
209
  toggle.setAttribute('aria-label', label);
295
- toggle.title = label;
210
+ toggle.dataset.tip = label;
296
211
  // Folded, a page's name shows when the pointer rests on its icon.
297
212
  for (const link of document.querySelectorAll('[data-nav]')) {
298
- if (folded && !NARROW.matches) link.title = link.dataset.label;
299
- else link.removeAttribute('title');
213
+ if (folded && !NARROW.matches) Object.assign(link.dataset, { tip: link.dataset.label, tipSide: 'right' });
214
+ else { delete link.dataset.tip; delete link.dataset.tipSide; }
300
215
  }
301
216
  }
302
217
 
@@ -343,7 +258,7 @@ function search() {
343
258
  }
344
259
 
345
260
  document.addEventListener('keydown', (event) => {
346
- if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
261
+ if (!isSearchKey(event)) return;
347
262
  if (document.body.dataset.state !== 'signed-in') return;
348
263
  event.preventDefault();
349
264
  search();
@@ -388,6 +303,8 @@ async function route() {
388
303
  }
389
304
 
390
305
  // Opens a path in place; the address changes, the page stays.
306
+ linksOpenWith((path) => go(path));
307
+
391
308
  function go(path, { replace = false } = {}) {
392
309
  if (path !== location.pathname + location.search) history[replace ? 'replaceState' : 'pushState'](null, '', path);
393
310
  route();
@@ -403,7 +320,7 @@ function navigate(event) {
403
320
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
404
321
  // takes a path, or a link's click event.
405
322
  const ui = () => ({
406
- h, flow, toast, notice, pageHeader, dialog, me, holdButton,
323
+ h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, dialog, me, holdButton, tileCard, tileSection, crumbs,
407
324
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
408
325
  });
409
326
 
@@ -529,28 +446,6 @@ async function refreshPeople(host) {
529
446
  ].filter(Boolean));
530
447
  }
531
448
 
532
- let peopleMenus = 0;
533
-
534
- // A row's ⋯ menu, as on the Custom fields page.
535
- function rowMenu(label, items) {
536
- const id = `fx-people-menu-${++peopleMenus}`;
537
- const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
538
- const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
539
- panel.addEventListener('toggle', (event) => {
540
- button.setAttribute('aria-expanded', String(event.newState === 'open'));
541
- if (event.newState !== 'open') return;
542
- const close = () => panel.hidePopover();
543
- panel.replaceChildren(...items.map((entry) => (entry === '-'
544
- ? h('div', { class: 'fx-pop__sep' })
545
- : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
546
- h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
547
- const at = button.getBoundingClientRect();
548
- panel.style.top = `${Math.round(at.bottom + 6)}px`;
549
- panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
550
- });
551
- return [button, panel];
552
- }
553
-
554
449
  function personRow(person, host) {
555
450
  const self = person.id === me.person.id;
556
451
  const act = async (fn, done) => {
@@ -564,9 +459,9 @@ function personRow(person, host) {
564
459
  };
565
460
  const patch = (body) => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify(body) });
566
461
  const who = h('div', { class: 'fx-person' },
567
- h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initials(person)),
462
+ h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initialsOf(person)),
568
463
  h('span', { class: 'fx-person__text' },
569
- h('span', { class: 'fx-person__name' }, person.name || person.email, self ? h('span', { class: 'fx-tag' }, 'You') : null),
464
+ h('span', { class: 'fx-person__name' }, personLabel(person), self ? h('span', { class: 'fx-tag' }, 'You') : null),
570
465
  person.name ? h('span', { class: 'fx-person__email' }, person.email) : null));
571
466
  const role = self
572
467
  ? h('span', { title: ROLE_HINT[person.role] }, ROLE_LABEL[person.role])
@@ -641,9 +536,9 @@ async function renderIntegrations(main) {
641
536
  const notices = h('div', { class: 'fx-fields' });
642
537
  const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
643
538
  main.append(
644
- h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
539
+ pageHeader('Integrations'),
645
540
  alertStrip(ui(), 'integrations').element,
646
- h('div', { class: 'fx-int-bar' }, tabs, search),
541
+ h('div', { class: 'fx-int-bar' }, tabs, h('div', { class: 'fx-row' }, category, search)),
647
542
  notices, content);
648
543
  let body;
649
544
  try {
@@ -655,13 +550,14 @@ async function renderIntegrations(main) {
655
550
  const canChange = Boolean(me.ownerActions?.allowed);
656
551
  notices.replaceChildren(...[
657
552
  canChange ? null : notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
658
- body.secretsKey ? null : notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
553
+ body.secretsKey ? null : notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
659
554
  ].filter(Boolean));
660
555
  const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
661
556
  const connected = (i) => i.enabled;
662
557
 
663
558
  const draw = () => {
664
- tabs.replaceChildren(...[['all', 'All integrations'], ['yours', 'Your integrations']].map(([id, label]) => h('button', {
559
+ category.hidden = state.tab !== 'all';
560
+ tabs.replaceChildren(...[['all', 'All'], ['yours', 'Connected']].map(([id, label]) => h('button', {
665
561
  type: 'button', role: 'tab', class: 'fx-seg__item', 'aria-selected': String(state.tab === id), 'aria-checked': String(state.tab === id),
666
562
  onclick: () => { state.tab = id; draw(); },
667
563
  }, label)));
@@ -672,7 +568,7 @@ async function renderIntegrations(main) {
672
568
  const yours = list.filter(connected).filter(matches);
673
569
  sections.push(h('section', { class: 'fx-int-section-list' },
674
570
  yours.length ? h('div', { class: 'fx-int-grid' }, yours.map(integrationTile))
675
- : h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All integrations.')));
571
+ : h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All.')));
676
572
  } else {
677
573
  // Recommended: what is not connected yet, once the catalogue is long
678
574
  // enough that a short row helps (with a few, it would repeat the grid).
@@ -684,7 +580,7 @@ async function renderIntegrations(main) {
684
580
  }
685
581
  const all = list.filter(matches).filter((i) => state.category === 'all' || CATALOGUE[i.id]?.category === state.category);
686
582
  sections.push(h('section', { class: 'fx-int-section-list' },
687
- h('div', { class: 'fx-int-heading-row' }, h('h2', { class: 'fx-int-heading' }, 'All integrations'), category),
583
+ recommended.length ? h('h2', { class: 'fx-int-heading' }, 'All') : null,
688
584
  all.length ? h('div', { class: 'fx-int-grid' }, all.map(integrationTile)) : h('p', { class: 'fx-empty' }, 'No integration matches.')));
689
585
  }
690
586
  content.replaceChildren(...sections);
@@ -695,19 +591,20 @@ async function renderIntegrations(main) {
695
591
  }
696
592
 
697
593
  function integrationTile(integration) {
698
- const href = `/integrations/${integration.id}`;
699
594
  const entry = CATALOGUE[integration.id];
700
595
  const on = integration.enabled;
701
596
  const attention = ['failed', 'restart_needed'].includes(integration.state);
702
- return h('a', { class: 'fx-int-card', href, onclick: navigate },
703
- h('header', { class: 'fx-int-card__head' },
704
- integrationLogo(integration),
705
- h('h3', {}, integration.name),
706
- on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[integration.state] ?? 'mute' }, STATE_LABEL[integration.state] ?? integration.state) : null),
707
- h('p', { class: 'fx-int-card__blurb' }, entry?.blurb ?? ''),
708
- h('span', { class: 'fx-int-card__btn' }, attention ? 'Fix' : on ? 'Manage' : '+ Connect'));
597
+ return tileCard({
598
+ href: `/integrations/${integration.id}`,
599
+ mark: integrationLogo(integration),
600
+ title: integration.name,
601
+ status: on ? { label: STATE_LABEL[integration.state] ?? integration.state, tone: STATE_TONE[integration.state] } : null,
602
+ blurb: entry?.blurb,
603
+ action: attention ? 'Fix' : on ? 'Manage' : '+ Connect',
604
+ });
709
605
  }
710
606
 
607
+
711
608
  // One integration's page (/integrations/<id>): its logo and maker, Connect
712
609
  // with each step shown as it happens, the setup steps, its settings, and the
713
610
  // facts beside them.
@@ -842,8 +739,7 @@ async function renderIntegration(main, id) {
842
739
  busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
843
740
 
844
741
  host.replaceChildren(
845
- h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
846
- h('a', { href: '/integrations', onclick: navigate }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, current.name)),
742
+ crumbs(['/integrations', 'Integrations'], current.name),
847
743
  h('header', { class: 'fx-int-hero' },
848
744
  integrationLogo(current, 'xl'),
849
745
  h('div', { class: 'fx-int-hero__text' },
@@ -858,7 +754,7 @@ async function renderIntegration(main, id) {
858
754
  h('div', { class: 'fx-int-page__main' }, ...[
859
755
  alerts.element,
860
756
  !canChange ? notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.') : null,
861
- !body.secretsKey ? notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
757
+ !body.secretsKey ? notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
862
758
  current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
863
759
  current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
864
760
  current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
@@ -918,22 +814,6 @@ function personSettingsSection(integration) {
918
814
  return section;
919
815
  }
920
816
 
921
- // One setting, laid out the same on every settings screen: a short name, one
922
- // line of smaller text under it, and the control on the right. Longer help
923
- // lives behind a link, not here. A wide control (a grid of choices) sits on
924
- // its own line under the name. `tone: 'danger'` marks a row that needs a person.
925
- function settingRow({ label, hint, control, wide = false, tone = null }) {
926
- const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
927
- const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
928
- return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
929
- h('div', { class: 'fx-setting__text' },
930
- id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
931
- hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
932
- h('div', { class: 'fx-setting__control' }, control));
933
- }
934
- settingRow.next = 0;
935
-
936
- // The steps of a Connect, each ticked, running or crossed, with the time taken.
937
817
  function connectProgress({ steps, started }) {
938
818
  const seconds = Math.max(0, Math.round((Date.now() - started) / 1000));
939
819
  return h('div', { class: 'fx-progress', role: 'status' },
@@ -1103,7 +983,7 @@ function renderAccount(main) {
1103
983
  // in it at once.
1104
984
  function renderAppearance(main) {
1105
985
  main.append(
1106
- pageHeader('Appearance', { subtitle: 'Saved with your account, on every device.' }),
986
+ pageHeader('Appearance'),
1107
987
  h('div', { class: 'fx-appearance', id: 'appearance' }));
1108
988
  drawAppearance();
1109
989
  }
@@ -1169,13 +1049,16 @@ function drawAppearance() {
1169
1049
  (p) => chooseAppearance({ accent: p.hex ? p.id : null }),
1170
1050
  (p) => p.hex
1171
1051
  ? h('button', { type: 'button', class: 'fx-swatch', title: p.label, 'aria-label': p.label, style: { background: p.hex } })
1172
- : h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', title: `${active.label}'s own`, 'aria-label': `${active.label}'s own accent` },
1173
- h('span', { 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } })));
1052
+ // The design system's own accent, named "Default" on the swatch: a
1053
+ // click puts it back.
1054
+ : h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', 'aria-label': `Default: ${active.label}'s own accent` },
1055
+ h('span', { class: 'fx-swatch__dot', 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } }),
1056
+ 'Default'));
1174
1057
  accents.classList.add('fx-swatches');
1175
1058
  const custom = h('label', { class: 'fx-swatch-custom' },
1176
1059
  h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => chooseAppearance({ accent: event.target.value }) }),
1177
1060
  'Custom');
1178
- const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `${active.label}'s own`;
1061
+ const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `the default, ${active.label}'s own`;
1179
1062
 
1180
1063
  host.replaceChildren(h('div', { class: 'fx-settings' },
1181
1064
  settingRow({ label: 'Mode', hint: 'Auto follows this device.', control: modeGroup }),
@@ -1230,7 +1113,7 @@ function drawModeToggles() {
1230
1113
  button.innerHTML = dark ? SUN : MOON;
1231
1114
  if (!button.classList.contains('fx-btn--icon')) button.append(` ${dark ? 'Light' : 'Dark'}`);
1232
1115
  button.setAttribute('aria-label', label);
1233
- button.title = label;
1116
+ button.dataset.tip = label;
1234
1117
  }
1235
1118
  }
1236
1119
 
@@ -1291,4 +1174,5 @@ async function start() {
1291
1174
  await renderShell();
1292
1175
  }
1293
1176
 
1177
+ startTips();
1294
1178
  start();
@@ -1,6 +1,6 @@
1
1
  // Automation rows (server/core/automations.js, /api/invoicing/automations).
2
2
  // Each automation shows where its trigger lives: the work system's ready rule
3
- // on that integration's page, the draft rule on Settings, Invoicing. One row
3
+ // on that integration's page, the invoicing ones on Settings, Automations. One row
4
4
  // for each: its name, one short line, and its switch, plus a row for each of
5
5
  // its settings. A change saves at once. Everyone can read them; an owner or
6
6
  // admin changes them. The caller passes in app.js's shared pieces as `ui`.
@@ -86,11 +86,17 @@ export function automationSection(ui, place, title) {
86
86
  (async () => {
87
87
  try {
88
88
  const { automations } = await flow(`/api/invoicing/automations?place=${encodeURIComponent(place)}`);
89
- section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), ...automations.flatMap(rowsOf),
90
- h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How automations work')));
89
+ section.replaceChildren(ui.sectionTitle(title, { href: DOCS, label: 'How automations work' }), ...automations.flatMap(rowsOf));
91
90
  } catch (error) {
92
91
  section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), notice(error.message, 'danger'));
93
92
  }
94
93
  })();
95
94
  return section;
96
95
  }
96
+
97
+ // Settings, Automations (/automations): the invoicing automations. A work
98
+ // system's own automations stay on its integration page.
99
+ export function renderAutomations(main, ui) {
100
+ const { h, pageHeader } = ui;
101
+ main.append(pageHeader('Automations'), h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui, 'invoicing', 'Invoicing')));
102
+ }