@arcforgelabs/flow 2026.10.25 → 2026.10.26

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