@arcforgelabs/flow 2026.10.25 → 2026.10.27

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 (60) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -1
  4. package/server/api/invoicing.js +586 -0
  5. package/server/app.js +38 -1
  6. package/server/auth/people.js +26 -0
  7. package/server/auth/routes.js +13 -0
  8. package/server/core/automations.js +140 -0
  9. package/server/core/invoice-rules.js +224 -0
  10. package/server/core/invoices.js +11 -1
  11. package/server/core/invoicing-rules.js +255 -0
  12. package/server/core/ledger.js +50 -14
  13. package/server/core/links.js +3 -3
  14. package/server/core/monitoring.js +34 -0
  15. package/server/core/pipeline.js +495 -0
  16. package/server/core/sending.js +282 -0
  17. package/server/db/migrations/0008_people_appearance.sql +1 -0
  18. package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
  19. package/server/db/migrations/0010_invoicing_rules.sql +51 -0
  20. package/server/db/migrations/0011_invoice_rules.sql +21 -0
  21. package/server/db/migrations/0012_invoice_sending.sql +9 -0
  22. package/server/db/migrations/meta/0008_snapshot.json +2479 -0
  23. package/server/db/migrations/meta/0009_snapshot.json +2809 -0
  24. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  25. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  26. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  27. package/server/db/migrations/meta/_journal.json +35 -0
  28. package/server/db/schema.js +186 -2
  29. package/server/index.js +20 -3
  30. package/server/integrations/adapters.js +2 -1
  31. package/server/integrations/port.js +8 -0
  32. package/server/integrations/seazona/feed.js +11 -0
  33. package/server/integrations/seazona/index.js +7 -1
  34. package/server/integrations/seazona/ledger.js +4 -0
  35. package/server/integrations/sentry/index.js +105 -0
  36. package/server/integrations/xero/client.js +5 -0
  37. package/server/integrations/xero/drafts.js +123 -16
  38. package/server/integrations/xero/import.js +22 -0
  39. package/server/integrations/xero/index.js +21 -3
  40. package/server/integrations/xero/lookup.js +94 -0
  41. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
  42. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  43. package/server/theme/color-contrast.js +39 -2
  44. package/server/theme/contrast-audit.js +63 -0
  45. package/server/theme/themes.js +10 -2
  46. package/server/ui/app-page.js +6 -10
  47. package/server/ui/app.js +364 -105
  48. package/server/ui/appearance.js +3 -2
  49. package/server/ui/automations.js +96 -0
  50. package/server/ui/clerk-appearance.js +18 -2
  51. package/server/ui/flow.css +334 -22
  52. package/server/ui/invoice-rules.js +237 -0
  53. package/server/ui/invoicing-settings.js +108 -0
  54. package/server/ui/invoicing.js +597 -0
  55. package/server/ui/jobs.js +172 -0
  56. package/server/ui/logos/seazona.svg +1 -0
  57. package/server/ui/logos/sentry.svg +1 -0
  58. package/server/ui/logos/xero.svg +1 -0
  59. package/server/ui/monitoring.js +53 -0
  60. package/server/ui/routes.js +5 -1
package/server/ui/app.js CHANGED
@@ -9,6 +9,11 @@
9
9
  import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/assets/ui/appearance.js';
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
+ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
13
+ import { automationSection } from '/assets/ui/automations.js';
14
+ import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
15
+ import { renderJobs } from '/assets/ui/jobs.js';
16
+ import { startMonitoring } from '/assets/ui/monitoring.js';
12
17
 
13
18
  const $ = (id) => document.getElementById(id);
14
19
 
@@ -47,6 +52,8 @@ function clerkAppearance(kind) {
47
52
  function mountClerk(kind, element, options = {}) {
48
53
  const Clerk = window.Clerk;
49
54
  const props = { ...options, appearance: clerkAppearance(kind) };
55
+ // The "Manage account" modal does not inherit the button's appearance.
56
+ if (kind === 'userButton') props.userProfileProps = { ...options.userProfileProps, appearance: clerkAppearance('modal') };
50
57
  if (kind === 'signIn') Clerk.mountSignIn(element, props);
51
58
  else if (kind === 'userButton') Clerk.mountUserButton(element, props);
52
59
  else if (kind === 'profile') Clerk.mountUserProfile(element, props);
@@ -70,8 +77,10 @@ function unmountAllIn(container) {
70
77
  }
71
78
 
72
79
  // Clerk reads its appearance when a component mounts, so a new appearance
73
- // remounts each one (after the tokens have changed on <html>).
80
+ // remounts each one (after the tokens have changed on <html>). Modals Clerk
81
+ // opens itself use the appearance from Clerk.load(), so update that too.
74
82
  onAppearanceChange(() => {
83
+ window.Clerk?.__internal_updateProps?.({ appearance: clerkAppearance('modal') });
75
84
  for (const [element, { kind, options }] of [...mounted]) {
76
85
  unmountClerk(element);
77
86
  mountClerk(kind, element, options);
@@ -109,7 +118,7 @@ async function flow(path, options = {}) {
109
118
  headers: { Authorization: `Bearer ${token}`, ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
110
119
  });
111
120
  const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
112
- if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code });
121
+ if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code, details: body.details });
113
122
  return body;
114
123
  }
115
124
 
@@ -180,42 +189,77 @@ function roleSelect(value, attrs = {}) {
180
189
 
181
190
  let me = null;
182
191
 
192
+ // The sidebar lists the views in this order, under their group's heading.
183
193
  const VIEWS = {
184
- people: { label: 'People', ownerOnly: true, render: renderPeople },
185
- integrations: { label: 'Integrations', ownerOnly: true, render: renderIntegrations },
186
- account: { label: 'Account', render: renderAccount },
187
- appearance: { label: 'Appearance', render: renderAppearance },
194
+ jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
195
+ invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
196
+ 'invoicing-settings': { label: 'Invoicing', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
197
+ people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
198
+ integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
199
+ account: { label: 'Account', icon: 'person', group: 'Settings', render: renderAccount },
200
+ appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
188
201
  };
189
202
 
203
+ // Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
204
+ const ICON_PATHS = {
205
+ 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"/>',
206
+ invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
207
+ 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"/>',
208
+ plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
209
+ person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
210
+ flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
211
+ palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
212
+ };
213
+
214
+ function icon(name) {
215
+ const node = h('span', { class: 'fx-ico', 'aria-hidden': 'true' });
216
+ node.innerHTML = `<svg viewBox="0 0 24 24">${ICON_PATHS[name] ?? ''}</svg>`;
217
+ return node;
218
+ }
219
+
220
+ // The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
190
221
  function currentView() {
191
- const name = location.pathname.replace(/^\/+|\/+$/g, '');
192
- if (VIEWS[name] && (!VIEWS[name].ownerOnly || me?.person.role === 'owner')) return name;
193
- return me?.person.role === 'owner' ? 'people' : 'account';
222
+ const [name, id = null] = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
223
+ if (VIEWS[name] && (!VIEWS[name].ownerOnly || me?.person.role === 'owner')) return [name, id];
224
+ return ['jobs', null];
194
225
  }
195
226
 
196
227
  async function route() {
197
228
  const main = $('main');
198
229
  unmountAllIn(main);
199
- const view = currentView();
200
- if (location.pathname !== `/${view}`) history.replaceState(null, '', `/${view}`);
230
+ const [view, id] = currentView();
231
+ const path = id ? `/${view}/${id}` : `/${view}`;
232
+ if (location.pathname !== path) history.replaceState(null, '', path);
201
233
  for (const link of document.querySelectorAll('[data-nav]')) {
202
234
  link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
203
235
  }
204
236
  main.replaceChildren();
237
+ main.className = 'fx-main';
205
238
  document.title = `${VIEWS[view].label} · Flow`;
206
- await VIEWS[view].render(main);
239
+ await VIEWS[view].render(main, id);
207
240
  main.focus({ preventScroll: true });
208
241
  }
209
242
 
210
- // A view's link opens it in place; the address changes, the page stays.
243
+ // Opens a path in place; the address changes, the page stays.
244
+ function go(path, { replace = false } = {}) {
245
+ if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState'](null, '', path);
246
+ route();
247
+ }
248
+
249
+ // A link's click opens it in place (a new tab still works).
211
250
  function navigate(event) {
212
251
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
213
252
  event.preventDefault();
214
- const href = event.currentTarget.getAttribute('href');
215
- if (href !== location.pathname) history.pushState(null, '', href);
216
- route();
253
+ go(event.currentTarget.getAttribute('href'));
217
254
  }
218
255
 
256
+ // The shared pieces a view module (invoicing.js) builds with. Its `go`
257
+ // takes a path, or a link's click event.
258
+ const ui = () => ({
259
+ h, flow, toast, notice, pageHeader, dialog, me,
260
+ go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
261
+ });
262
+
219
263
  function renderSignedOut() {
220
264
  document.body.dataset.state = 'signed-out';
221
265
  document.title = 'Sign in · Flow';
@@ -236,10 +280,21 @@ function renderSignedOut() {
236
280
  function renderShell() {
237
281
  document.body.dataset.state = 'signed-in';
238
282
  const nav = $('nav');
239
- nav.replaceChildren(...Object.entries(VIEWS)
240
- .filter(([, view]) => !view.ownerOnly || me?.person.role === 'owner')
241
- .map(([name, view]) => h('a', { href: `/${name}`, 'data-nav': name, onclick: navigate }, view.label)));
283
+ const items = [];
284
+ let group = null;
285
+ for (const [name, view] of Object.entries(VIEWS)) {
286
+ if (view.ownerOnly && me?.person.role !== 'owner') continue;
287
+ if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
288
+ group = view.group;
289
+ items.push(h('a', { href: `/${name}`, 'data-nav': name, onclick: navigate }, icon(view.icon), h('span', {}, view.label)));
290
+ }
291
+ nav.replaceChildren(...items);
242
292
  mountClerk('userButton', $('user-button'), { afterSignOutUrl: '/sign-in', showName: false });
293
+ // Errors and problem reports go to the installation's own Sentry, when the
294
+ // Sentry integration is on (monitoring.js). Flow works the same without it.
295
+ $('report-problem').replaceChildren(icon('flag'));
296
+ startMonitoring({ flow, person: me.person, button: $('report-problem') })
297
+ .catch((error) => console.warn(`[monitoring] ${error.message}`));
243
298
  window.addEventListener('popstate', route);
244
299
  return route();
245
300
  }
@@ -348,39 +403,138 @@ function personRow(person, host) {
348
403
  h('td', { class: 'fx-table__actions' }, h('div', { class: 'fx-row fx-row--end' }, actions)));
349
404
  }
350
405
 
351
- // Integrations: an owner turns Xero and Seazona on and enters their settings
352
- // here. Credentials are write-only: the server says whether each is set,
406
+ // Integrations: a classic catalogue. Each system is a card with its logo;
407
+ // the card opens a side panel where an owner turns it on and enters its
408
+ // settings. Credentials are write-only: the server says whether each is set,
353
409
  // never its value (server/integrations/setup.js).
354
- const STATE_LABEL = { ready: 'On', disabled: 'Off', failed: 'Needs attention', restart_needed: 'Restart needed', not_started: 'Starting' };
355
- const STATE_TONE = { ready: 'success', disabled: 'info', failed: 'danger', restart_needed: 'warn', not_started: 'info' };
410
+ const STATE_LABEL = { ready: 'Connected', disabled: 'Not set up', failed: 'Needs attention', restart_needed: 'Restart needed', not_started: 'Starting' };
411
+ const STATE_TONE = { ready: 'success', disabled: 'mute', failed: 'danger', restart_needed: 'warn', not_started: 'info' };
412
+
413
+ // What the catalogue shows for each adapter: its group, one line on what it
414
+ // does, and its logo (server/ui/logos/, each maker's own mark).
415
+ const CATALOGUE = {
416
+ xero: { maker: 'Xero', category: 'accounting', blurb: 'Send invoices to Xero as drafts and bring payments back.', logo: 'xero.svg' },
417
+ seazona: { maker: 'Seazona', category: 'lab', blurb: 'Bring lab cases and orders into Flow as jobs.', logo: 'seazona.svg' },
418
+ sentry: { maker: 'Sentry', category: 'monitoring', blurb: 'Send errors and problem reports to your own Sentry project.', logo: 'sentry.svg' },
419
+ };
420
+ const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
421
+ const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
422
+
423
+ function integrationLogo(integration, size = '') {
424
+ const file = CATALOGUE[integration.id]?.logo;
425
+ return h('span', { class: `fx-logo${size ? ` fx-logo--${size}` : ''}`, 'aria-hidden': 'true' },
426
+ file ? h('img', { src: `/assets/ui/logos/${file}`, alt: '' }) : icon('plug'));
427
+ }
356
428
 
357
429
  async function renderIntegrations(main) {
358
- const host = h('div', { class: 'fx-fields' }, h('div', { class: 'fx-skeleton' }));
430
+ const state = { tab: 'all', category: 'all', q: '' };
431
+ const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
432
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search integrations', 'aria-label': 'Search integrations' });
433
+ const category = h('select', { class: 'fx-select', 'aria-label': 'Category' },
434
+ CATEGORIES.map(([id, label]) => h('option', { value: id }, id === 'all' ? 'All categories' : label)));
435
+ const notices = h('div', { class: 'fx-fields' });
436
+ const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
359
437
  main.append(
360
- pageHeader('Integrations', { subtitle: 'Connect Flow to your accounting and lab systems. Each one uses an app you create in that system.' }),
361
- host);
438
+ h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
439
+ h('div', { class: 'fx-int-bar' }, tabs, search),
440
+ notices, content);
362
441
  let body;
363
442
  try {
364
443
  body = await flow('/api/integrations');
365
444
  } catch (error) {
366
- host.replaceChildren(notice(error.message, 'danger'));
445
+ content.replaceChildren(notice(error.message, 'danger'));
367
446
  return;
368
447
  }
369
448
  const canChange = Boolean(me.ownerActions?.allowed);
370
- host.replaceChildren(...[
449
+ notices.replaceChildren(...[
371
450
  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.'),
372
451
  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.'),
373
- ...body.integrations.map((integration) => integrationCard(integration, { canChange, organisations: body.organisations })),
374
452
  ].filter(Boolean));
375
- }
453
+ const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
454
+ const connected = (i) => i.enabled;
455
+
456
+ const draw = () => {
457
+ tabs.replaceChildren(...[['all', 'All integrations'], ['yours', 'Your integrations']].map(([id, label]) => h('button', {
458
+ type: 'button', role: 'tab', class: 'fx-seg__item', 'aria-selected': String(state.tab === id), 'aria-checked': String(state.tab === id),
459
+ onclick: () => { state.tab = id; draw(); },
460
+ }, label)));
461
+ const q = state.q.toLowerCase();
462
+ const matches = (i) => !q || `${i.name} ${CATALOGUE[i.id]?.blurb ?? ''}`.toLowerCase().includes(q);
463
+ const sections = [];
464
+ if (state.tab === 'yours') {
465
+ const yours = list.filter(connected).filter(matches);
466
+ sections.push(h('section', { class: 'fx-int-section-list' },
467
+ yours.length ? h('div', { class: 'fx-int-grid' }, yours.map(integrationTile))
468
+ : h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All integrations.')));
469
+ } else {
470
+ // Recommended: what is not connected yet, once the catalogue is long
471
+ // enough that a short row helps (with a few, it would repeat the grid).
472
+ const recommended = q || list.length < 4 ? [] : list.filter((i) => !connected(i)).slice(0, 3);
473
+ if (recommended.length) {
474
+ sections.push(h('section', { class: 'fx-int-section-list' },
475
+ h('h2', { class: 'fx-int-heading' }, 'Recommended for you'),
476
+ h('div', { class: 'fx-int-grid' }, recommended.map(integrationTile))));
477
+ }
478
+ const all = list.filter(matches).filter((i) => state.category === 'all' || CATALOGUE[i.id]?.category === state.category);
479
+ sections.push(h('section', { class: 'fx-int-section-list' },
480
+ h('div', { class: 'fx-int-heading-row' }, h('h2', { class: 'fx-int-heading' }, 'All integrations'), category),
481
+ all.length ? h('div', { class: 'fx-int-grid' }, all.map(integrationTile)) : h('p', { class: 'fx-empty' }, 'No integration matches.')));
482
+ }
483
+ content.replaceChildren(...sections);
484
+ };
485
+ search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
486
+ category.addEventListener('change', () => { state.category = category.value; draw(); });
487
+ draw();
488
+ }
489
+
490
+ function integrationTile(integration) {
491
+ const href = `/integrations/${integration.id}`;
492
+ const entry = CATALOGUE[integration.id];
493
+ const on = integration.enabled;
494
+ const attention = ['failed', 'restart_needed'].includes(integration.state);
495
+ return h('a', { class: 'fx-int-card', href, onclick: navigate },
496
+ h('header', { class: 'fx-int-card__head' },
497
+ integrationLogo(integration),
498
+ h('h3', {}, integration.name),
499
+ on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[integration.state] ?? 'mute' }, STATE_LABEL[integration.state] ?? integration.state) : null),
500
+ h('p', { class: 'fx-int-card__blurb' }, entry?.blurb ?? ''),
501
+ h('span', { class: 'fx-int-card__btn' }, attention ? 'Fix' : on ? 'Manage' : '+ Connect'));
502
+ }
503
+
504
+ // One integration's page (/integrations/<id>): its logo and maker, Connect
505
+ // with each step shown as it happens, the setup steps, its settings, and the
506
+ // facts beside them.
507
+ async function renderIntegration(main, id) {
508
+ const host = h('div', { class: 'fx-int-page' }, h('div', { class: 'fx-skeleton' }));
509
+ main.append(host);
510
+ let body;
511
+ try {
512
+ body = await flow('/api/integrations');
513
+ } catch (error) {
514
+ host.replaceChildren(notice(error.message, 'danger'));
515
+ return;
516
+ }
517
+ const found = body.integrations.find((i) => i.id === id);
518
+ if (!found) {
519
+ host.replaceChildren(notice('No integration by that name.', 'danger'));
520
+ return;
521
+ }
522
+ const canChange = Boolean(me.ownerActions?.allowed);
376
523
 
377
- function integrationCard(integration, { canChange, organisations }) {
378
- const card = h('section', { class: 'fx-panel', 'aria-label': integration.name });
379
- const draw = (current) => {
524
+ const draw = (current, progress = null) => {
380
525
  const inputs = new Map();
381
- const fields = current.fields.map((field) => {
382
- const fromHost = field.source === 'host';
383
- const disabled = !canChange || fromHost;
526
+ const rows = [];
527
+ for (const field of current.fields) {
528
+ if (field.source === 'host') {
529
+ // The host sets it: a value to read, not a box to type in.
530
+ rows.push(settingRow({
531
+ label: field.label,
532
+ hint: 'Set by whoever hosts Flow.',
533
+ control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
534
+ }));
535
+ continue;
536
+ }
537
+ const disabled = !canChange;
384
538
  let input;
385
539
  if (field.kind === 'choice' || field.kind === 'onoff') {
386
540
  const choices = field.kind === 'onoff' ? [{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }] : field.choices;
@@ -388,40 +542,64 @@ function integrationCard(integration, { canChange, organisations }) {
388
542
  field.kind === 'choice' ? h('option', { value: '' }, 'Choose…') : null,
389
543
  choices.map((c) => h('option', { value: c.value, selected: (field.value ?? (field.kind === 'onoff' ? 'off' : '')) === c.value }, c.label)));
390
544
  } else if (field.secret) {
391
- input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? 'Set. Type a new one to replace it.' : 'Paste it here' });
545
+ input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? 'Saved' : 'Paste it here' });
392
546
  } else {
393
547
  input = h('input', { class: 'fx-input', type: field.kind === 'url' ? 'url' : 'text', autocomplete: 'off', disabled, value: field.value ?? '' });
394
548
  }
395
549
  inputs.set(field.name, { field, input });
396
550
  const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
397
- return h('label', { class: 'fx-field' },
398
- h('span', { class: 'fx-label' }, field.label),
399
- input,
400
- fromHost ? h('span', { class: 'fx-dim' }, 'Set by the host. Change it there.') : null,
401
- field.unreadable ? h('span', { class: 'fx-dim' }, 'Stored with another key. Enter it again.') : null,
402
- field.help ? h('span', { class: 'fx-dim' }, field.help) : null,
403
- suggestion && !disabled ? h('div', { class: 'fx-row' },
404
- h('code', {}, suggestion),
405
- h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { navigator.clipboard?.writeText(suggestion); toast('Copied.', 'success'); } }, 'Copy'),
406
- h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { input.value = suggestion; } }, 'Use this')) : null);
407
- });
551
+ rows.push(settingRow({
552
+ label: field.label,
553
+ hint: field.unreadable ? 'Stored with another key. Enter it again.' : field.help,
554
+ control: suggestion && !disabled
555
+ ? h('div', { class: 'fx-setting__stack' }, input,
556
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this installation\'s address'))
557
+ : input,
558
+ }));
559
+ }
408
560
 
409
561
  const values = () => {
410
562
  const out = {};
411
563
  for (const [name, { field, input }] of inputs) {
412
- if (field.source === 'host') continue;
413
564
  const value = input.value.trim();
414
565
  if (field.secret) { if (value) out[name] = value; continue; }
415
566
  if (value !== (field.value ?? (field.kind === 'onoff' ? 'off' : ''))) out[name] = value || null;
416
567
  }
417
568
  return out;
418
569
  };
570
+ const put = (enabled) => flow(`/api/integrations/${current.id}`, { method: 'PUT', body: JSON.stringify({ values: values(), ...(enabled === undefined ? {} : { enabled }) }) });
571
+
572
+ // Connect: each step is shown as it runs, then ticked or crossed.
573
+ const connect = async () => {
574
+ const steps = [
575
+ { label: 'Saving settings', state: 'running' },
576
+ { label: `Starting ${current.name}`, state: 'waiting' },
577
+ ];
578
+ const started = Date.now();
579
+ const show = () => draw(current, { steps, started });
580
+ show();
581
+ let next;
582
+ try {
583
+ next = (await put(true)).integration;
584
+ } catch (error) {
585
+ steps[0] = { label: error.message, state: 'failed' };
586
+ show();
587
+ return;
588
+ }
589
+ steps[0].state = 'done';
590
+ steps[1].state = next.state === 'ready' ? 'done' : 'failed';
591
+ if (next.state !== 'ready') steps[1].label = next.error ? `${current.name} could not start: ${next.error}` : `${current.name}: ${STATE_LABEL[next.state] ?? next.state}`;
592
+ if (next.state === 'ready') steps.push({ label: `${current.name} is connected`, state: 'done' });
593
+ current = next;
594
+ show();
595
+ setTimeout(() => { if (steps.every((step) => step.state === 'done')) draw(current); }, 2400);
596
+ };
419
597
  const save = async (enabled) => {
420
598
  try {
421
- const res = await flow(`/api/integrations/${current.id}`, { method: 'PUT', body: JSON.stringify({ values: values(), ...(enabled === undefined ? {} : { enabled }) }) });
422
- const next = res.integration;
423
- toast(next.state === 'ready' ? `${next.name} is on.` : next.state === 'disabled' ? `${next.name} settings saved. It is off.` : `${next.name} needs attention.`, next.state === 'failed' ? 'danger' : 'success');
424
- draw(next);
599
+ const next = (await put(enabled)).integration;
600
+ toast(enabled === false ? `${next.name} is disconnected.` : `${next.name} settings saved.`, 'success');
601
+ current = next;
602
+ draw(current);
425
603
  } catch (error) {
426
604
  toast(error.message, 'danger');
427
605
  }
@@ -429,64 +607,121 @@ function integrationCard(integration, { canChange, organisations }) {
429
607
 
430
608
  const on = current.enabled;
431
609
  const guide = current.guide;
432
- card.replaceChildren(...[
433
- h('div', { class: 'fx-row' },
434
- h('h2', { class: 'fx-title' }, current.name),
435
- h('span', { class: 'fx-tag', 'data-tone': STATE_TONE[current.state] ?? 'info' }, STATE_LABEL[current.state] ?? current.state),
436
- current.enabledBy === 'host' ? h('span', { class: 'fx-dim' }, 'Turned on by the host.') : null),
437
- current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
438
- guide ? h('details', { open: !on },
439
- h('summary', {}, guide.title),
440
- h('ol', {}, guide.steps.map((step) => h('li', {}, step))),
441
- guide.link ? h('p', {}, h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, guide.link)) : null) : null,
442
- h('div', { class: 'fx-fields' }, fields),
443
- canChange ? h('div', { class: 'fx-row fx-row--end' },
444
- h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => save(undefined) }, 'Save'),
445
- current.enabledBy === 'host' ? null : on
446
- ? h('button', { class: 'fx-btn fx-btn--danger', type: 'button', onclick: () => save(false) }, `Turn ${current.name} off`)
447
- : h('button', { class: 'fx-btn fx-btn--primary', type: 'button', onclick: () => save(true) }, `Save and turn ${current.name} on`)) : null,
448
- current.id === 'xero' && current.state === 'ready' ? xeroConnection(organisations, canChange) : null,
449
- ].filter(Boolean));
610
+ const hostOwned = current.enabledBy === 'host';
611
+ const entry = CATALOGUE[current.id];
612
+ const mode = current.fields.find((f) => /_MODE$/.test(f.name));
613
+ const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
614
+ const busy = progress && progress.steps.some((step) => step.state === 'running');
615
+
616
+ const primary = !canChange || hostOwned ? null : on
617
+ ? h('button', { class: 'fx-btn', type: 'button', onclick: () => save(false) }, 'Disconnect')
618
+ : h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: busy, onclick: connect },
619
+ busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
620
+
621
+ host.replaceChildren(
622
+ h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
623
+ h('a', { href: '/integrations', onclick: navigate }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, current.name)),
624
+ h('header', { class: 'fx-int-hero' },
625
+ integrationLogo(current, 'xl'),
626
+ h('div', { class: 'fx-int-hero__text' },
627
+ h('h1', { class: 'fx-title' }, current.name),
628
+ h('p', { class: 'fx-int-hero__maker' }, entry?.maker ?? current.name, h('span', { class: 'fx-dim' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')),
629
+ h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
630
+ h('div', { class: 'fx-int-hero__actions' },
631
+ primary,
632
+ on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute' }, STATE_LABEL[current.state] ?? current.state) : null,
633
+ progress ? connectProgress(progress) : null))),
634
+ h('div', { class: 'fx-int-page__grid' },
635
+ h('div', { class: 'fx-int-page__main' }, ...[
636
+ !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,
637
+ !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,
638
+ current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
639
+ current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
640
+ current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
641
+ current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
642
+ guide ? h('section', { class: 'fx-int-section' },
643
+ h('h2', { class: 'fx-int-section__title' }, 'Set up'),
644
+ h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
645
+ guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `Open ${current.name} developer site ↗`) : null) : null,
646
+ rows.length ? h('section', { class: 'fx-int-section' },
647
+ h('h2', { class: 'fx-int-section__title' }, 'Settings'),
648
+ h('div', { class: 'fx-settings' }, rows),
649
+ canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
650
+ ].filter(Boolean)),
651
+ h('aside', { class: 'fx-int-page__meta' },
652
+ h('dl', { class: 'fx-meta' }, ...[
653
+ ['Status', h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute' }, STATE_LABEL[current.state] ?? current.state)],
654
+ ['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
655
+ modeLabel ? ['Mode', modeLabel] : null,
656
+ ['Turned on by', current.enabledBy === 'host' ? 'The host' : current.enabledBy === 'flow' ? 'An owner, here' : '—'],
657
+ current.updatedAt ? ['Last changed', new Date(current.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })] : null,
658
+ guide?.link ? ['Documentation', h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, 'Developer site ↗')] : null,
659
+ ].filter(Boolean).map(([label, value]) => h('div', {}, h('dt', {}, label), h('dd', {}, value)))))));
450
660
  };
451
- draw(integration);
452
- return card;
661
+ document.title = `${found.name} · Flow`;
662
+ draw(found);
663
+ }
664
+
665
+ // One setting, laid out the same on every settings screen: a short name, one
666
+ // line of smaller text under it, and the control on the right. Longer help
667
+ // lives behind a link, not here. A wide control (a grid of choices) sits on
668
+ // its own line under the name.
669
+ function settingRow({ label, hint, control, wide = false }) {
670
+ const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
671
+ const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
672
+ return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}` },
673
+ h('div', { class: 'fx-setting__text' },
674
+ id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
675
+ hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
676
+ h('div', { class: 'fx-setting__control' }, control));
677
+ }
678
+ settingRow.next = 0;
679
+
680
+ // The steps of a Connect, each ticked, running or crossed, with the time taken.
681
+ function connectProgress({ steps, started }) {
682
+ const seconds = Math.max(0, Math.round((Date.now() - started) / 1000));
683
+ return h('div', { class: 'fx-progress', role: 'status' },
684
+ h('div', { class: 'fx-progress__head' }, h('span', { class: 'fx-strong' }, 'Connecting'), h('span', { class: 'fx-dim' }, `${seconds}s`)),
685
+ h('ol', {}, steps.map((step) => h('li', { 'data-state': step.state },
686
+ h('span', { class: 'fx-progress__mark', 'aria-hidden': 'true' }, step.state === 'done' ? '✓' : step.state === 'failed' ? '!' : ''),
687
+ step.label))));
453
688
  }
454
689
 
455
690
  // Once Xero is on: each organisation's connection, the history copy, and
456
691
  // Connect, which goes to Xero and comes back.
457
692
  function xeroConnection(organisations, canChange) {
458
- const host = h('div', { class: 'fx-fields' });
693
+ const host = h('div', { class: 'fx-settings' });
459
694
  for (const organisation of organisations) {
460
- const row = h('div', { class: 'fx-row' }, h('span', {}, organisation.name), h('span', { class: 'fx-dim' }, 'Checking…'));
695
+ const row = settingRow({ label: organisation.name, hint: 'Checking…' });
461
696
  host.append(row);
462
697
  (async () => {
698
+ let hint;
699
+ let control = null;
463
700
  try {
464
701
  const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
465
- const parts = [h('span', {}, organisation.name)];
466
702
  if (status.connected) {
467
- parts.push(h('span', { class: 'fx-tag', 'data-tone': 'success' }, `Connected to ${status.connection?.tenantName ?? 'Xero'}`));
468
703
  const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
469
- if (mirror) parts.push(h('span', { class: 'fx-dim' }, `History copied: ${mirror.counts.invoices} invoices, ${mirror.counts.credit_notes} credit notes, ${mirror.counts.payments} payments.`));
704
+ hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
470
705
  } else {
471
- parts.push(h('span', { class: 'fx-dim' }, 'Not connected.'));
706
+ hint = 'Not connected.';
472
707
  }
473
708
  if (canChange) {
474
- parts.push(h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', onclick: async () => {
709
+ control = h('button', { class: `fx-btn fx-btn--sm${status.connected ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
475
710
  try {
476
711
  const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
477
712
  location.assign(authorizeUrl);
478
713
  } catch (error) {
479
714
  toast(error.message, 'danger');
480
715
  }
481
- } }, status.connected ? 'Connect again' : 'Connect to Xero'));
716
+ } }, status.connected ? 'Connect again' : 'Connect to Xero');
482
717
  }
483
- row.replaceChildren(...parts);
484
718
  } catch (error) {
485
- row.replaceChildren(h('span', {}, organisation.name), notice(error.message, 'danger'));
719
+ hint = error.message;
486
720
  }
721
+ row.replaceWith(settingRow({ label: organisation.name, hint, control }));
487
722
  })();
488
723
  }
489
- return h('div', {}, h('h3', { class: 'fx-label' }, 'Connection'), host);
724
+ return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
490
725
  }
491
726
 
492
727
  async function invitePerson() {
@@ -524,11 +759,13 @@ function renderAccount(main) {
524
759
  mountClerk('profile', host, { routing: 'virtual' });
525
760
  }
526
761
 
527
- // Appearance: light or dark, the design system and an accent colour, for
528
- // this browser.
762
+ // Appearance: light or dark, the design system and an accent colour. Each
763
+ // person's choice is saved with their account (PUT /api/me/appearance) and
764
+ // applied when they sign in; the browser keeps a copy so the next page paints
765
+ // in it at once.
529
766
  function renderAppearance(main) {
530
767
  main.append(
531
- pageHeader('Appearance', { subtitle: 'For this browser. Clerk\'s sign-in and account screens follow it too.' }),
768
+ pageHeader('Appearance', { subtitle: 'Saved with your account, on every device.' }),
532
769
  h('div', { class: 'fx-appearance', id: 'appearance' }));
533
770
  drawAppearance();
534
771
  }
@@ -564,13 +801,13 @@ function drawAppearance() {
564
801
 
565
802
  const modes = [{ value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }, { value: 'system', label: 'Auto' }];
566
803
  const modeGroup = radioGroup('Mode', modes, modes.findIndex((m) => m.value === colorMode),
567
- (m) => setAppearance({ colorMode: m.value }),
804
+ (m) => chooseAppearance({ colorMode: m.value }),
568
805
  (m) => h('button', { type: 'button', class: 'fx-seg__item' }, m.label));
569
806
  modeGroup.classList.add('fx-seg');
570
807
 
571
808
  const familyIndex = THEME_FAMILIES.findIndex((f) => f.id === family);
572
809
  const families = radioGroup('Design system', THEME_FAMILIES, familyIndex,
573
- (f) => setAppearance({ family: f.id }),
810
+ (f) => chooseAppearance({ family: f.id }),
574
811
  (f) => {
575
812
  // The card shows the family as it is, not the active theme.
576
813
  const t = tokensFor(f.id, mode);
@@ -591,30 +828,51 @@ function drawAppearance() {
591
828
  const presetIndex = accent ? ACCENT_PRESETS.findIndex((p) => p.id === accent) : 0;
592
829
  const customHex = accent && presetIndex < 0 ? normalizeAccent(accent) : null;
593
830
  const accents = radioGroup('Accent colour', ACCENT_PRESETS, presetIndex,
594
- (p) => setAppearance({ accent: p.hex ? p.id : null }),
831
+ (p) => chooseAppearance({ accent: p.hex ? p.id : null }),
595
832
  (p) => p.hex
596
833
  ? h('button', { type: 'button', class: 'fx-swatch', title: p.label, 'aria-label': p.label, style: { background: p.hex } })
597
834
  : h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', title: `${active.label}'s own`, 'aria-label': `${active.label}'s own accent` },
598
835
  h('span', { 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } })));
599
836
  accents.classList.add('fx-swatches');
600
837
  const custom = h('label', { class: 'fx-swatch-custom' },
601
- h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => setAppearance({ accent: event.target.value }) }),
838
+ h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => chooseAppearance({ accent: event.target.value }) }),
602
839
  'Custom');
603
840
  const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `${active.label}'s own`;
604
841
 
605
- host.replaceChildren(
606
- h('section', { class: 'fx-group' },
607
- h('h2', { class: 'fx-label' }, 'Mode'),
608
- modeGroup,
609
- h('p', { class: 'fx-dim' }, 'Auto follows this device\'s light or dark setting.')),
610
- h('section', { class: 'fx-group' },
611
- h('h2', { class: 'fx-label' }, 'Design system'),
612
- families,
613
- h('p', { class: 'fx-dim' }, 'Each design system brings its own colours, type and corners, in light and dark. Harbour is Flow\'s own; the rest are OpenClaw\'s themes.')),
614
- h('section', { class: 'fx-group' },
615
- h('h2', { class: 'fx-label' }, 'Accent colour'),
616
- h('div', { class: 'fx-row' }, accents, custom),
617
- h('p', { class: 'fx-dim', 'aria-live': 'polite' }, `Using ${accentLabel}. Buttons, highlights and focus rings take the accent; text in the accent is adjusted until it reads clearly.`)));
842
+ host.replaceChildren(h('div', { class: 'fx-settings' },
843
+ settingRow({ label: 'Mode', hint: 'Auto follows this device.', control: modeGroup }),
844
+ settingRow({ label: 'Accent colour', hint: `Using ${accentLabel}.`, control: h('div', { class: 'fx-row' }, accents, custom) }),
845
+ settingRow({ label: 'Design system', hint: 'Colours, type and corners.', control: families, wide: true })));
846
+ }
847
+
848
+ // A person's own change: show it, then save it to their account once they
849
+ // are signed in. Signed out (the sign-in screen's switch) it stays in this
850
+ // browser.
851
+ let savingAppearance = null;
852
+ function chooseAppearance(update) {
853
+ setAppearance(update);
854
+ saveAccountAppearance();
855
+ }
856
+
857
+ function accountAppearance() {
858
+ const { colorMode, family, accent } = getAppearance();
859
+ return { colorMode, themeFamily: family, ...(accent ? { accent } : {}) };
860
+ }
861
+
862
+ function saveAccountAppearance() {
863
+ if (!me) return;
864
+ clearTimeout(savingAppearance);
865
+ savingAppearance = setTimeout(() => {
866
+ flow('/api/me/appearance', { method: 'PUT', body: JSON.stringify(accountAppearance()) })
867
+ .catch(() => toast('Flow could not save your appearance to your account. It stays in this browser for now.', 'danger'));
868
+ }, 300);
869
+ }
870
+
871
+ // On sign-in: the account's appearance wins. An account with none yet takes
872
+ // this browser's current choice.
873
+ function adoptAccountAppearance(saved) {
874
+ if (!saved) return saveAccountAppearance();
875
+ setAppearance({ colorMode: saved.colorMode, family: saved.themeFamily, accent: saved.accent ?? null });
618
876
  }
619
877
 
620
878
  onAppearanceChange(() => {
@@ -639,7 +897,7 @@ function drawModeToggles() {
639
897
  }
640
898
 
641
899
  for (const button of document.querySelectorAll('[data-mode-toggle]')) {
642
- button.addEventListener('click', () => setAppearance({ colorMode: getAppearance().mode === 'dark' ? 'light' : 'dark' }));
900
+ button.addEventListener('click', () => chooseAppearance({ colorMode: getAppearance().mode === 'dark' ? 'light' : 'dark' }));
643
901
  }
644
902
  onAppearanceChange(drawModeToggles);
645
903
  drawModeToggles();
@@ -687,6 +945,7 @@ async function start() {
687
945
  }
688
946
  try {
689
947
  me = await flow('/api/me');
948
+ adoptAccountAppearance(me.appearance);
690
949
  } catch (error) {
691
950
  renderNoAccess(error);
692
951
  return;